ZDDC/zddc/internal/apps/embedded/archive.html
ZDDC 8c2e65e4a2 fix(mdedit): two-line ZDDC tree display + dark-mode editor contrast
Two issues from one session:

* File tree: ZDDC-conforming filenames render as a single line
  even though the JS already produced two-div markup (filename-main +
  filename-secondary). Cause: .tree-row__label was display:flex
  (row-direction), so the two divs laid out side-by-side. Fix: wrap
  each label's text in a new .tree-row__name span styled
  flex-direction:column. Both file and folder code paths use the
  same wrapper now; non-ZDDC entries collapse to a single
  .filename-main line so typography stays consistent across the tree.
  Tested by injecting a ZDDC filename into a mock directory and
  asserting filename-secondary's bounding-box top is below
  filename-main's bottom.

* Toast UI Editor was unreadable in dark mode. Toast UI ships with
  light-only chrome; its .toastui-editor-md-container has color #222
  on a transparent bg, so when mdedit's dark theme rendered the
  surrounding pane in #1e1e1e the editor text fell on near-black
  background → effectively invisible. Fix: add CSS overrides in
  mdedit/css/editor.css that target the editor's load-bearing
  surfaces (md-container, md-preview, ww-container, ProseMirror,
  toolbar, mode-switch tabs, popups) and apply var(--bg) /
  var(--text). Toolbar icons get a filter:invert(0.85) hue-rotate
  to flip the sprite-baked dark glyphs. Both manual override
  (data-theme="dark") and OS-pref auto fallback (prefers-color-scheme)
  are covered. Tested by computing contrast ratios on every editor
  surface in dark mode — all came in at 10:1+ (well above WCAG AA's
  4.5:1).

Embedded snapshots refreshed to current main HEAD's dev build label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:09:46 -05:00

8511 lines
295 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Archive</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPHJlY3Qgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0IiByeD0iMTIiIGZpbGw9IiMxZTNhNWYiLz4KICA8ZyBmaWxsPSIjZmZmIj4KICAgIDxyZWN0IHg9IjE0IiB5PSIxOCIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICAgIDxwb2x5Z29uIHBvaW50cz0iNDMsMjUgNTAsMjUgMjEsNDMgMTQsNDMiLz4KICAgIDxyZWN0IHg9IjE0IiB5PSI0MyIgd2lkdGg9IjM2IiBoZWlnaHQ9IjciLz4KICA8L2c+Cjwvc3ZnPgo=">
<style>
/* ==========================================================================
ZDDC Shared Base — single source of truth for tokens and primitives
Included first by every tool's build.sh via ../shared/base.css
========================================================================== */
/* ── CSS custom properties ────────────────────────────────────────────────── */
:root {
/* Brand / accent (matches zddc.varasys.io website --accent) */
--primary: #2a5a8a;
--primary-hover: #1d4060;
--primary-active: #163352;
--primary-light: #e8f0f7;
/* Semantic colours */
--success: #28a745;
--warning: #d97706;
--danger: #dc3545;
--info: #17a2b8;
/* Backgrounds */
--bg: #ffffff;
--bg-secondary: #f8f9fa;
--bg-hover: #f0f4f8;
--bg-selected: var(--primary-light);
/* Text */
--text: #212529;
--text-muted: #6c757d;
--text-light: #ffffff;
/* Borders */
--border: #dee2e6;
--border-dark: #adb5bd;
/* Shape */
--radius: 4px;
/* Typography */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
}
/* ── Dark mode tokens ─────────────────────────────────────────────────────── */
/* Applied via: OS preference (auto) or [data-theme="dark"] on <html> */
/* The [data-theme="light"] selector locks light mode regardless of OS pref. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
}
/* Manual dark override — wins over media query */
[data-theme="dark"] {
--primary: #4a90c4;
--primary-hover: #5ba3d9;
--primary-active: #6ab5e8;
--primary-light: #1a3550;
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--bg-selected: #1a3550;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--text-light: #ffffff;
--border: #3e3e42;
--border-dark: #6e6e72;
}
/* ── Reset ────────────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base document ────────────────────────────────────────────────────────── */
html, body {
height: 100%;
font-family: var(--font);
font-size: 16px;
line-height: 1.5;
color: var(--text);
background-color: var(--bg-secondary);
}
/* ── Typography ───────────────────────────────────────────────────────────── */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
line-height: 1.2;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ── Utility ──────────────────────────────────────────────────────────────── */
.hidden {
display: none !important;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Scrollbars (webkit) ──────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a0a0a0;
}
/* ── Button primitive ─────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4rem 0.85rem;
font-family: var(--font);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.4;
text-align: center;
text-decoration: none;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: var(--radius);
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s, color 0.15s;
background: var(--bg-secondary);
color: var(--text);
}
.btn:disabled,
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.btn:not(:disabled):hover {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
}
.btn:not(:disabled):active {
box-shadow: none;
}
/* Variants */
.btn-primary {
background: var(--primary);
color: var(--text-light);
border-color: var(--primary);
}
.btn-primary:not(:disabled):hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--text-light);
}
.btn-primary:not(:disabled):active {
background: var(--primary-active);
border-color: var(--primary-active);
}
.btn-secondary {
background: var(--bg);
color: var(--text);
border-color: var(--border);
}
.btn-secondary:not(:disabled):hover {
background: var(--bg-secondary);
}
.btn-success {
background: var(--success);
color: var(--text-light);
border-color: var(--success);
}
.btn-danger {
background: var(--danger);
color: var(--text-light);
border-color: var(--danger);
}
/* Sizes */
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.btn-lg {
padding: 0.6rem 1.4rem;
font-size: 1rem;
}
.btn-link {
background: transparent;
border-color: transparent;
color: var(--primary);
padding-left: 0;
padding-right: 0;
}
.btn-link:not(:disabled):hover {
text-decoration: underline;
box-shadow: none;
}
/* ── App header chrome ────────────────────────────────────────────────────── */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.35rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
/* Tool name inside the header */
.app-header__title {
font-size: 17px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.01em;
white-space: nowrap;
}
/* Brand logo — sits left of the title in every tool's app-header.
Self-contained: the SVG provides its own dark blue rounded background,
so no extra wrapper styling is needed. */
.app-header__logo {
width: 26px;
height: 26px;
flex-shrink: 0;
display: block;
}
/* ── Build timestamp ──────────────────────────────────────────────────────── */
.build-timestamp {
font-size: 0.55rem;
color: var(--text-muted);
opacity: 0.7;
font-weight: 300;
white-space: nowrap;
padding-top: 0.15rem;
}
/* Title + timestamp stacked vertically on the left side of the header */
.header-title-group {
display: flex;
flex-direction: column;
gap: 0;
line-height: 1;
}
/* ── Icon buttons (help, theme, refresh) ─────────────────────────────────── */
/* Square, centered — overrides the asymmetric text-button padding/line-height */
#help-btn,
#theme-btn,
#refreshHeaderBtn {
width: 2rem;
height: 2rem;
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
/* Toast CSS lives in classifier/css/base.css — only that tool uses toasts. */
/* ── Theme and help icon buttons ─────────────────────────────────────────── */
#theme-btn,
#help-btn {
font-size: 1rem;
}
/* ── Help panel (shared slide-out drawer) ─────────────────────────────────── */
/* Used by all four tools. Toggle open/close via shared/help.js. */
.help-panel {
position: fixed;
top: 0;
right: 0;
width: min(420px, 85vw);
height: 100vh;
z-index: 1000;
background: var(--bg);
border-left: 1px solid var(--border);
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
}
.help-panel:not([hidden]) {
transform: translateX(0);
}
.help-panel[hidden] {
display: flex;
transform: translateX(100%);
pointer-events: none;
}
.help-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: var(--bg);
}
.help-panel__title {
font-size: 1rem;
font-weight: 700;
color: var(--text);
margin: 0;
}
.help-panel__close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.35rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
line-height: 1;
transition: background 0.15s, color 0.15s;
}
.help-panel__close:hover {
color: var(--text);
background: var(--bg-secondary);
}
.help-panel__body {
flex: 1;
overflow-y: auto;
padding: 1rem 1rem 2rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.help-panel__body h3 {
font-size: 0.95rem;
font-weight: 700;
margin: 1.25rem 0 0.35rem;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 0.15rem;
}
.help-panel__body h3:first-child {
margin-top: 0;
}
.help-panel__body h4 {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 1.25rem 0 0.3rem;
padding-left: 0.5rem;
border-left: 3px solid var(--border-dark);
color: var(--text-muted);
}
.help-panel__body p {
margin: 0 0 0.5rem;
}
.help-panel__body ol,
.help-panel__body ul {
padding-left: 1.5rem;
margin: 0.3rem 0 0.5rem;
}
.help-panel__body li {
margin-bottom: 0.3rem;
}
.help-panel__body dl {
margin: 0.3rem 0;
}
.help-panel__body dt {
font-weight: 600;
color: var(--text);
}
.help-panel__body dd {
margin: 0 0 0.5rem 1rem;
color: var(--text-muted);
}
.help-panel__body code {
font-family: var(--font-mono);
font-size: 0.8em;
background: var(--bg-secondary);
padding: 0.1em 0.3em;
border-radius: 3px;
}
.help-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.1rem 0.35rem;
border-radius: var(--radius);
vertical-align: middle;
letter-spacing: 0.02em;
}
.help-badge--draft {
color: #2563eb;
background: #eff6ff;
}
.help-badge--published {
color: #7c3aed;
background: #f5f3ff;
}
/* Shrink main content when help panel is open */
body.help-open .app-header {
margin-right: min(420px, 85vw);
}
/* ── Column filter inputs (shared across archive, classifier, transmittal) ─── */
.column-filter {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 0.25rem;
padding: 0.2rem 0.4rem;
font-size: 0.8rem;
font-family: var(--font);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
transition: border-color 0.15s;
}
.column-filter:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 1px rgba(42, 90, 138, 0.35);
}
.column-filter::placeholder {
color: var(--text-muted);
}
/* Archive-specific base overrides
Reset, tokens, and font are provided by shared/base.css */
#appContainer {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.note {
font-size: 0.9em;
color: var(--text-muted);
font-style: italic;
}
/* Scan spinner */
.scan-spinner {
display: inline-block;
width: 0.85em;
height: 0.85em;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: middle;
margin-left: 0.4rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Archive layout — tokens from shared/base.css */
/* Header — shared/base.css provides base .app-header; add archive-specific overrides */
.app-header {
padding: 0.5rem 1rem;
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-right {
display: flex;
gap: 0.5rem;
align-items: center;
}
.preview-toggle-label {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
cursor: pointer;
white-space: nowrap;
}
/* Main Container */
.main-container {
display: flex;
flex: 1;
overflow: hidden;
}
/* Navigation Pane */
.nav-pane {
width: 300px;
min-width: 200px;
background: var(--bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
position: relative;
}
.nav-section {
display: flex;
flex-direction: column;
padding: 1rem;
border-bottom: 1px solid var(--border);
overflow: hidden;
position: relative;
}
/* Grouping section - larger default size */
.nav-section:first-child {
flex: 0 0 auto;
height: 250px;
min-height: 50px;
}
/* Grouping section when collapsed */
.nav-section:first-child.collapsed {
height: auto;
flex: 0 0 auto;
}
/* Transmittal section takes remaining space */
.nav-section:last-child {
flex: 1;
min-height: 150px;
border-bottom: none;
}
/* Nav section content wrapper */
.nav-section-content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* Hide content when collapsed */
.nav-section.collapsed .nav-section-content {
display: none;
}
/* Resize handles — persistent 1px divider; grab cursor on hover */
.resize-handle-horizontal {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 5px;
cursor: ew-resize;
z-index: 10;
/* Persistent 1px right-edge indicator */
border-right: 1px solid var(--border-dark);
}
.resize-handle-horizontal:hover,
.resize-handle-horizontal.resizing {
background: rgba(42, 90, 138, 0.25);
cursor: col-resize;
}
.resize-handle-vertical {
position: absolute;
left: 0;
right: 0;
bottom: -3px;
height: 6px;
cursor: ns-resize;
z-index: 10;
/* Persistent 1px bottom-edge indicator */
border-bottom: 1px solid var(--border-dark);
}
.resize-handle-vertical:hover,
.resize-handle-vertical.resizing {
background: rgba(42, 90, 138, 0.25);
cursor: row-resize;
}
.nav-section h3 {
font-size: 1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.5rem;
flex-shrink: 0;
}
.nav-section h3 {
font-size: 1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0;
flex-shrink: 0;
}
.folder-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
margin-top: 0.5rem;
min-height: 0;
}
/* Content Area */
.content-area {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
overflow: hidden;
}
.content-header {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.content-header .content-actions {
margin-left: auto;
}
.content-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Table Container */
.table-container {
flex: 1;
overflow: auto;
background: var(--bg);
margin: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
}
/* Status Bar */
.status-bar {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 0.35rem 1rem;
background: var(--bg);
border-top: 1px solid var(--border);
font-size: 0.85em;
color: var(--text-muted);
gap: 1rem;
}
/* Empty State — positioned below the app header */
.empty-state {
position: absolute;
top: 50px; /* clear the header */
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
z-index: 10;
}
.empty-state-content {
text-align: center;
max-width: 500px;
padding: 2rem;
}
/* Project warning banner */
.project-warning-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #fff3cd;
border-bottom: 1px solid #ffc107;
color: #664d03;
font-size: 0.875rem;
gap: 12px;
}
.project-warning-banner.hidden { display: none; }
.project-warning-dismiss {
background: none;
border: none;
cursor: pointer;
color: #664d03;
font-size: 1rem;
padding: 0 4px;
flex-shrink: 0;
}
.empty-state-content h2 {
color: var(--text);
margin-bottom: 1rem;
}
.empty-state-content p {
margin-bottom: 1rem;
color: var(--text-muted);
}
/* Project access warning banner */
.project-warning-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #fff3cd;
border-bottom: 1px solid #ffc107;
color: #664d03;
font-size: 0.875rem;
gap: 12px;
}
.project-warning-banner.hidden { display: none; }
.project-warning-dismiss {
background: none;
border: none;
cursor: pointer;
color: #664d03;
font-size: 1rem;
padding: 0 4px;
flex-shrink: 0;
}
/* Archive component styles — tokens from shared/base.css */
/* Select All checkbox label */
.select-all-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
}
.select-all-label input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
/* One-line bar variant — sits below the section header */
.select-all-bar {
padding: 0.2rem 0;
margin-bottom: 0.35rem;
}
/* Filter + Select All inline row */
.filter-select-row {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.35rem;
}
.filter-select-row .filter-input {
flex: 1;
margin-bottom: 0;
}
/* Inline variant: label to the right of the filter, text above checkbox */
.select-all-inline {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
font-size: 0.7rem;
line-height: 1.1;
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
text-align: center;
}
.select-all-inline input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
/* Form Inputs */
.filter-input,
.form-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.9rem;
font-family: var(--font);
background: var(--bg);
color: var(--text);
transition: border-color 0.2s;
}
.filter-input:focus,
.form-input:focus {
outline: none;
border-color: var(--primary);
}
.filter-input.filter-active {
background: rgba(234, 179, 8, 0.18);
border-color: rgba(234, 179, 8, 0.7);
}
/* Form Groups */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.form-help {
display: block;
margin-top: 0.25rem;
font-size: 0.85rem;
color: var(--text-muted);
}
/* Checkboxes */
input[type="checkbox"] {
margin-right: 0.5rem;
cursor: pointer;
}
/* Folder Tree Chevrons */
.folder-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
font-size: 0.6rem;
color: var(--text-muted);
cursor: pointer;
transition: transform 0.15s ease;
flex-shrink: 0;
margin-right: 0.25rem;
}
.folder-chevron:not(.collapsed) {
transform: rotate(90deg);
}
.folder-chevron:hover {
color: var(--primary);
}
.folder-chevron-placeholder {
width: 1rem;
flex-shrink: 0;
margin-right: 0.25rem;
}
/* Folder Items */
.folder-item {
display: flex;
align-items: center;
padding: 0.25rem 0.5rem;
cursor: pointer;
user-select: none;
border-radius: 3px;
border-left: 3px solid transparent;
}
.folder-item:hover {
background: var(--bg-hover);
}
.folder-item.selected {
background: var(--bg-selected);
color: inherit;
border-left: 3px solid var(--primary);
padding-left: calc(0.5rem - 3px);
}
.folder-item.selected:hover {
background: var(--bg-hover);
}
.folder-item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: text;
cursor: text;
}
/* Transmittal folder formatting */
.transmittal-folder-content {
flex: 1;
overflow: hidden;
user-select: text;
cursor: text;
}
[data-folder-type="transmittal"] {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.transmittal-first-line {
font-size: 0.9em;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.transmittal-second-line {
font-size: 0.85em;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Empty filter message in folder lists */
.folder-list-empty {
padding: 0.75rem 0.5rem;
color: var(--text-muted);
font-size: 0.85rem;
font-style: italic;
text-align: center;
}
/* Focus styles for keyboard navigation */
.folder-list:focus {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.folder-list:focus .folder-item:focus {
outline: 1px dotted var(--primary);
outline-offset: -1px;
}
/* ── Folder type toggle bar ─────────────────────────────────────────────── */
.folder-type-bar {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
padding: 0.3rem 0 0.4rem;
flex-shrink: 0;
}
.folder-type-toggle {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
font-family: var(--font);
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg);
color: var(--text-muted);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
line-height: 1.4;
white-space: nowrap;
}
.folder-type-toggle:hover {
background: var(--bg-hover);
color: var(--text);
border-color: var(--border-dark);
}
.folder-type-toggle.active {
background: var(--primary);
color: var(--text-light);
border-color: var(--primary);
}
.folder-type-toggle.active:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
/* Date Group Headers */
.date-group-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
font-weight: 600;
color: var(--text);
position: sticky;
top: 0;
z-index: 1;
}
.date-group-header:hover {
background: var(--bg-hover);
}
.date-group-toggle {
font-size: 0.8em;
width: 1rem;
text-align: center;
}
.date-group-date {
flex: 1;
}
.date-group-count {
font-size: 0.85em;
color: var(--text-muted);
font-weight: normal;
}
/* Nav section header with button */
.nav-section-header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-secondary);
margin: -1rem -1rem 0.75rem -1rem;
padding: 0.4rem 1rem;
border-bottom: 1px solid var(--border);
}
.nav-section-header h3 {
margin-bottom: 0;
}
.btn-icon {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--text-muted);
font-size: 1rem;
line-height: 1;
border-radius: 3px;
transition: background 0.2s;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--text);
}
/* Modals */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background: var(--bg);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-large {
width: 80vw;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-muted);
padding: 0;
width: 2rem;
height: 2rem;
cursor: pointer;
}
.modal-close:hover {
color: var(--text);
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
}
/* Preview Table */
.preview-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
}
.preview-table th,
.preview-table td {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
}
.preview-table th {
font-weight: 600;
background: var(--bg-secondary);
}
/* Drag & Drop */
.drag-over {
background: var(--bg-selected) !important;
border-color: var(--primary) !important;
}
/* Loading Spinner */
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid var(--border);
border-top: 2px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Revision Title Styling */
.titles-container {
display: flex;
flex-direction: column;
}
.revision-title-base,
.revision-title-modifier {
margin-bottom: 0.5rem;
}
.revision-title-base:last-child,
.revision-title-modifier:last-child {
margin-bottom: 0;
}
.revision-title-base {
color: var(--text);
}
.revision-title-modifier {
color: var(--text-muted);
}
/* Modifier Filter Dropdown */
.modifier-filter-container {
position: relative;
display: inline-block;
}
.modifier-filter-btn {
min-width: 100px;
}
.modifier-filter-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
min-width: 180px;
background: var(--bg);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-top: 4px;
}
.modifier-filter-header {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.modifier-filter-header label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
cursor: pointer;
}
.modifier-filter-list {
max-height: 250px;
overflow-y: auto;
padding: 0.25rem 0;
}
.modifier-filter-item {
padding: 0.4rem 0.75rem;
}
.modifier-filter-item label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.modifier-filter-item:hover {
background: var(--bg-hover);
}
.modifier-base {
font-weight: 500;
color: var(--text);
}
.modifier-type {
color: var(--text-muted);
}
/* Active toggle button state */
.btn-active {
background: var(--primary);
color: var(--text-light);
border-color: var(--primary);
}
.btn-active:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
/* Path Error Row Warning */
.file-row-path-error {
background: rgba(217, 119, 6, 0.08) !important;
}
.file-row-path-error:hover {
background: rgba(217, 119, 6, 0.15) !important;
}
.path-error-indicator {
color: var(--warning);
cursor: help;
margin-right: 0.25rem;
}
.file-link-disabled {
color: var(--text-muted);
text-decoration: none;
cursor: not-allowed;
}
.file-link-disabled:hover {
text-decoration: none;
color: var(--text-muted);
}
/* PDF Preview Toggle */
.preview-toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--text);
cursor: pointer;
padding: 0.4rem 0.85rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.15s;
}
.preview-toggle-label:hover {
background: var(--bg-secondary);
}
.preview-toggle-label input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
.preview-toggle-label input[type="checkbox"]:checked + span {
color: var(--primary);
font-weight: 500;
}
/* ── Download progress indicator ────────────────────────────────────────── */
.progress-indicator {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
min-width: 300px;
}
.progress-indicator__message {
margin-bottom: 10px;
}
.progress-indicator__track {
background: var(--bg-secondary);
height: 20px;
border-radius: 10px;
overflow: hidden;
}
.progress-indicator__fill {
background: var(--primary);
height: 100%;
transition: width 0.3s;
}
.progress-indicator__label {
text-align: center;
margin-top: 5px;
font-size: 0.9em;
color: var(--text-muted);
}
/* ── Welcome screen list ─────────────────────────────────────────────────── */
.welcome-list {
text-align: left;
margin: 0.5rem auto;
max-width: 400px;
}
/* ── Windows path tip (inside welcome screen) ────────────────────────────── */
.windows-tip {
text-align: left;
margin: 1rem auto;
max-width: 500px;
font-size: 0.9rem;
}
.windows-tip summary {
cursor: pointer;
color: var(--text-muted);
}
.windows-tip__body {
margin-top: 0.5rem;
padding: 0.75rem;
background: var(--bg);
border: 1px solid var(--warning);
border-radius: var(--radius);
}
.windows-tip__body > p:first-child {
margin: 0 0 0.5rem 0;
}
.windows-tip__body ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.windows-tip__code {
display: block;
margin: 0.25rem 0;
padding: 0.25rem 0.5rem;
background: var(--bg-secondary);
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 0.85em;
}
.windows-tip__note {
margin: 0.5rem 0 0 0;
font-size: 0.85rem;
color: var(--text-muted);
}
/* Outstanding virtual transmittal — pinned at top of transmittal list */
.outstanding-transmittal {
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
margin-bottom: 0.25rem;
}
.outstanding-label {
font-style: italic;
color: var(--text-muted);
}
.outstanding-transmittal.selected .outstanding-label {
color: var(--text);
}
/* Reset Filters Button */
.btn-icon-only {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
font-size: 1.1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.btn-icon-only:hover {
background: var(--bg-hover);
border-color: var(--primary);
}
.btn-icon-only:active {
background: var(--primary);
border-color: var(--primary);
color: var(--text-light);
}
/* Toolbar separator */
.toolbar-separator {
width: 1px;
height: 1.5rem;
background: var(--border);
margin: 0 0.25rem;
align-self: center;
flex-shrink: 0;
}
/* ── Preset dropdown ─────────────────────────────────────────────────────── */
.preset-section {
position: relative;
display: inline-flex;
align-items: center;
}
.preset-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
min-width: 350px;
max-height: 400px;
background: var(--bg);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
}
.preset-section-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
padding: 0.5rem 0.75rem 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.preset-list {
padding: 0.25rem 0;
max-height: 200px;
overflow-y: auto;
}
.preset-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
cursor: pointer;
user-select: none;
}
.preset-item:hover {
background: var(--bg-hover);
}
.preset-item .preset-delete {
background: none;
border: none;
color: var(--text-muted);
font-size: 1rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
border-radius: 3px;
line-height: 1;
}
.preset-item .preset-delete:hover {
background: rgba(255, 0, 0, 0.1);
color: var(--error);
}
.preset-no-presets {
padding: 0.75rem 0.75rem;
color: var(--text-muted);
font-size: 0.85rem;
font-style: italic;
text-align: center;
}
.preset-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.preset-projects-list {
padding: 0.25rem 0;
max-height: 200px;
overflow-y: auto;
}
.preset-project-item {
padding: 0.25rem 0.75rem;
}
.preset-project-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
user-select: none;
}
.preset-project-label input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
.preset-footer-actions {
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.preset-footer-naming {
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
display: flex;
gap: 0.5rem;
}
.preset-name-input {
flex: 1;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.9rem;
font-family: var(--font);
background: var(--bg);
color: var(--text);
}
.preset-name-input:focus {
outline: none;
border-color: var(--primary);
}
.preset-section-top,
.preset-section-bottom {
padding: 0.25rem 0;
}
.preset-section-bottom {
flex: 1;
overflow-y: auto;
}
/* Table styles */
.files-table {
width: 100%;
border-collapse: collapse;
background: var(--bg);
}
/* Table Header */
.files-table thead {
position: sticky;
top: 0;
background: var(--bg);
z-index: 10;
}
.files-table th {
position: relative;
text-align: left;
font-weight: 600;
background: var(--bg-secondary);
border-bottom: 2px solid var(--border);
user-select: none;
}
.th-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
cursor: default;
}
.sortable .th-content {
cursor: pointer;
}
.sortable .th-content:hover {
background: var(--bg-hover);
}
/* Sort Indicators */
.sort-indicator {
display: inline-block;
width: 0.75rem;
height: 1rem;
margin-left: 0.5rem;
position: relative;
}
.sort-indicator::before,
.sort-indicator::after {
content: '';
position: absolute;
left: 0;
width: 0;
height: 0;
border-style: solid;
}
.sort-indicator::before {
top: 0;
border-width: 0 0.375rem 0.375rem 0.375rem;
border-color: transparent transparent var(--border-dark) transparent;
}
.sort-indicator::after {
bottom: 0;
border-width: 0.375rem 0.375rem 0 0.375rem;
border-color: var(--border-dark) transparent transparent transparent;
}
th[data-sort="asc"] .sort-indicator::before {
border-bottom-color: var(--text);
}
th[data-sort="desc"] .sort-indicator::after {
border-top-color: var(--text);
}
/* Resize Handle */
.resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
background: transparent;
}
.resize-handle:hover {
background: var(--primary);
}
/* Table Body */
.files-table tbody tr {
transition: background-color 0.1s;
}
.files-table tbody tr.group-last {
border-bottom: 1px solid var(--border);
}
.files-table tbody tr:hover {
background: var(--bg-hover);
}
/* Preview-active highlight: marks the row + specific file (when there are
multiple files per revision) that the preview popup is currently showing,
so the user can match what's on screen to its location in the table. */
.files-table tbody tr.is-previewing {
background: var(--bg-selected, rgba(42, 90, 138, 0.10));
box-shadow: inset 3px 0 0 var(--primary);
}
.files-table tbody tr.is-previewing:hover {
background: var(--bg-selected-hover, rgba(42, 90, 138, 0.18));
}
.revision-file.is-previewing {
outline: 1.5px solid var(--primary);
outline-offset: 2px;
border-radius: 3px;
}
.files-table td {
padding: 0.25rem 1rem;
vertical-align: top;
}
/* Tracking Number Column */
td[data-field="trackingNumber"],
th[data-sort="trackingNumber"] {
white-space: nowrap;
}
td[data-field="trackingNumber"] {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
/* Revisions Column */
.revision-group {
margin-bottom: 0.5rem;
}
.revision-group:last-child {
margin-bottom: 0;
}
.revision-item {
display: flex;
align-items: center;
margin-bottom: 0.25rem;
}
.revision-item:last-child {
margin-bottom: 0;
}
.revision-info {
display: inline-flex;
align-items: center;
margin-right: 0.5rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
.revision-id {
font-weight: 600;
margin-right: 0.25rem;
}
.revision-status {
color: var(--text-muted);
font-size: 0.85em;
}
.revision-file {
display: inline-flex;
align-items: center;
margin-left: 0.25rem;
}
.file-link,
.file-link-disabled {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
margin-right: 0.25rem;
line-height: 1.1;
}
.file-link {
color: var(--primary);
text-decoration: none;
}
.file-link:hover {
text-decoration: underline;
}
.file-ext {
color: var(--text-muted);
font-size: 0.85em;
text-transform: uppercase;
}
/* Empty Table State */
.empty-table {
text-align: center;
padding: 3rem;
color: var(--text-muted);
}
/* Column Widths */
.files-table th:nth-child(1),
.files-table td:nth-child(1) {
width: 240px;
min-width: 220px;
}
.files-table th:nth-child(2),
.files-table td:nth-child(2) {
width: 40%;
min-width: 200px;
}
.files-table th:nth-child(3),
.files-table td:nth-child(3) {
width: auto;
min-width: 300px;
}
/* File size — half the height of the extension badge, left-aligned below it */
.file-size {
color: var(--text-muted);
font-size: 0.5em;
line-height: 1;
margin-top: 0.15em;
}
/* Active column filter highlight */
.column-filter.filter-active {
background: rgba(234, 179, 8, 0.18);
border-color: rgba(234, 179, 8, 0.7);
}
/* Print styles */
@media print {
/* Hide UI elements */
.app-header,
.nav-pane,
.content-header,
.status-bar,
.modal,
.btn,
.filter-input,
.global-search,
.column-filter,
input[type="checkbox"],
.resize-handle,
.sort-indicator {
display: none !important;
}
/* Reset layout */
body {
font-size: 10pt;
line-height: 1.4;
}
#appContainer {
height: auto;
}
.main-container {
display: block;
}
.content-area {
background: white;
}
.table-container {
margin: 0;
border: none;
overflow: visible;
}
/* Table adjustments */
.files-table {
font-size: 9pt;
border: 1px solid #000;
}
.files-table thead {
position: static;
}
.files-table th {
background: #f0f0f0;
border: 1px solid #000;
padding: 4pt 6pt;
font-weight: bold;
}
.files-table td {
border: 1px solid #000;
padding: 3pt 6pt;
}
.files-table tbody tr:hover {
background: transparent;
}
/* Show only text content for revisions */
.revision-item {
display: inline;
margin-right: 0.5em;
}
.file-link {
color: black;
text-decoration: none;
}
.file-link::after {
content: " (" attr(href) ")";
font-size: 8pt;
color: #666;
}
/* Page breaks */
.files-table {
page-break-inside: auto;
}
.files-table tr {
page-break-inside: avoid;
page-break-after: auto;
}
/* Header on each page */
@page {
size: letter portrait;
margin: 0.5in;
}
/* Add document title */
body::before {
content: "Archive Browser Report";
display: block;
font-size: 16pt;
font-weight: bold;
margin-bottom: 12pt;
}
/* Add timestamp */
body::after {
content: "Generated: " attr(data-print-date);
display: block;
margin-top: 12pt;
font-size: 9pt;
color: #666;
text-align: right;
}
}
</style>
</head>
<body>
<div id="appContainer">
<!-- Project access warning banner (shown when URL contains inaccessible projects) -->
<div id="projectWarningBanner" class="project-warning-banner hidden" role="alert">
<span class="project-warning-text"></span>
<button class="project-warning-dismiss" onclick="dismissProjectWarning()" aria-label="Dismiss">×</button>
</div>
<!-- Header -->
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 02:07:03 · 17b0a4d-dirty</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;"></button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div>
</header>
<!-- Main Container -->
<div class="main-container">
<!-- Navigation Pane -->
<nav id="navigationPane" class="nav-pane">
<!-- Grouping Folders Section -->
<div class="nav-section" id="groupingSection">
<div class="nav-section-header">
<h3>Parties</h3>
<div class="preset-section" id="presetSection">
<button id="presetBtn" class="btn btn-secondary btn-sm" title="Party presets">▾ Presets</button>
<div id="presetDropdown" class="preset-dropdown hidden"></div>
</div>
<button id="toggleGroupingBtn" class="btn-icon" title="Collapse/Expand">
<span id="toggleGroupingIcon"></span>
</button>
</div>
<div id="groupingContent" class="nav-section-content">
<!-- Global folder type toggle bar -->
<div id="folderTypeBar" class="folder-type-bar">
<!-- Dynamically populated by renderFolderTypeBar() -->
</div>
<div class="filter-select-row">
<input type="text"
id="groupingFilter"
class="filter-input"
placeholder="Filter parties...">
<label class="select-all-label select-all-inline" title="Auto-select all visible parties">
<span>Select<br>All</span>
<input type="checkbox" id="selectAllGroupingCheckbox" checked>
</label>
</div>
<div id="groupingFoldersList" class="folder-list">
<!-- Dynamically populated -->
</div>
</div>
<div class="resize-handle-vertical" data-resize="nav-sections"></div>
</div>
<!-- Transmittal Folders Section -->
<div class="nav-section" id="transmittalSection">
<div class="nav-section-header">
<h3>Transmittal Folders</h3>
<button id="toggleAllDatesBtn" class="btn-icon" title="Expand/Collapse All">
<span id="toggleAllDatesIcon"></span>
</button>
</div>
<div class="filter-select-row">
<input type="text"
id="transmittalFilter"
class="filter-input"
placeholder="Filter transmittal folders...">
<label class="select-all-label select-all-inline" title="Auto-select all visible transmittals">
<span>Select<br>All</span>
<input type="checkbox" id="selectAllTransmittalsCheckbox" checked>
</label>
</div>
<div id="transmittalFoldersList" class="folder-list">
<!-- Dynamically populated -->
</div>
</div>
<div class="resize-handle-horizontal" data-resize="nav-pane"></div>
</nav>
<!-- Content Area -->
<main class="content-area">
<!-- Content Header -->
<div class="content-header">
<!-- Reset Filters -->
<button id="resetFiltersBtn" class="btn btn-secondary btn-icon-only" title="Reset all column filters"></button>
<!-- Preview toggle (default on; users can opt out for direct downloads) -->
<label class="preview-toggle-label" title="Preview PDF, Word, and Excel files in a popup window instead of downloading">
<input type="checkbox" id="filePreviewToggle" checked>
<span>Preview</span>
</label>
<!-- Modifier Filter Dropdown -->
<div class="modifier-filter-container">
<button id="modifierFilterBtn" class="btn btn-secondary modifier-filter-btn">
Modifiers ▼
</button>
<div id="modifierFilterDropdown" class="modifier-filter-dropdown hidden">
<div class="modifier-filter-header">
<label><input type="checkbox" id="modifierSelectAll" checked> Select All</label>
</div>
<div id="modifierFilterList" class="modifier-filter-list">
<!-- Dynamically populated -->
</div>
</div>
</div>
<div class="toolbar-separator"></div>
<div class="content-actions">
<button id="filterSelectedBtn" class="btn btn-secondary">Filter Selected</button>
<button id="downloadSelectedBtn" class="btn btn-secondary">Download (ZIP)</button>
<button id="exportCsvBtn" class="btn btn-secondary">Export (CSV)</button>
</div>
</div>
<!-- Files Table -->
<div class="table-container">
<table id="filesTable" class="files-table">
<thead>
<tr>
<th class="sortable resizable" data-field="trackingNumber">
<div class="th-content">
<span>Tracking Number</span>
<span class="sort-indicator"></span>
</div>
<input type="text"
class="column-filter"
data-filter-field="trackingNumber"
placeholder="Filter...">
<div class="resize-handle"></div>
</th>
<th class="sortable resizable" data-field="title">
<div class="th-content">
<span>Title</span>
<span class="sort-indicator"></span>
</div>
<input type="text"
class="column-filter"
data-filter-field="title"
placeholder="Filter...">
<div class="resize-handle"></div>
</th>
<th class="resizable" data-field="revisions">
<div class="th-content" style="justify-content: flex-start;">
<input type="checkbox"
id="selectAllVisibleCheckbox"
title="Select/deselect all visible files"
style="margin-right: 0.5rem;">
<span>Revisions</span>
</div>
<input type="text"
class="column-filter"
data-filter-field="revisions"
placeholder="Filter...">
<div class="resize-handle"></div>
</th>
</tr>
</thead>
<tbody id="filesTableBody">
<!-- Dynamically populated -->
</tbody>
</table>
</div>
<!-- Status Bar -->
<div class="status-bar">
<span id="fileCount">0 files</span>
<span id="selectedCount">0 selected</span>
<span id="scanStatus"></span><span id="scanSpinner" class="scan-spinner hidden"></span>
</div>
</main>
</div>
<!-- Drop Modal -->
<div id="dropModal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content">
<div class="modal-header">
<h2>Create Transmittal</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Transmittal Folder Name:</label>
<input type="text" id="transmittalName" class="form-input">
<small class="form-help">Format: YYYY-MM-DD_TRACKINGNUMBER (STATUS) - TITLE</small>
</div>
<div class="files-preview">
<h3>Files to Add:</h3>
<table class="preview-table">
<thead>
<tr>
<th>Original Name</th>
<th>New Name</th>
<th>Status</th>
</tr>
</thead>
<tbody id="filesPreviewBody">
<!-- Dynamically populated -->
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary modal-cancel">Cancel</button>
<button class="btn btn-primary modal-confirm">Create Transmittal</button>
</div>
</div>
</div>
<!-- No Directory Selected Message -->
<div id="noDirectoryMessage" class="empty-state">
<div class="empty-state-content">
<h2>Welcome to ZDDC Archive</h2>
<p>Click <strong>Add Local Directory</strong> to select an archive folder to browse.</p>
<p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p>
<p><strong>How to navigate:</strong></p>
<ul class="welcome-list">
<li>Select a party to see their transmittal folders; toggle folder types (Issued, Received, MDL, Incoming) above the list</li>
<li>Select transmittal folders to see their files</li>
<li>Use <kbd>Ctrl+Click</kbd> to select multiple folders</li>
<li>Use <kbd>Shift+Click</kbd> to select a range</li>
<li><kbd>Ctrl+Click</kbd> chevrons to recursively expand/collapse</li>
</ul>
<details class="windows-tip">
<summary><strong>⚠️ Windows Path Length Deficiency</strong></summary>
<div class="windows-tip__body">
<p>Microsoft Windows has a legacy 260-character path limit that affects most applications. If you see "files skipped" warnings, use Microsoft's own workaround:</p>
<ol>
<li>Open Command Prompt as Administrator</li>
<li>Map your archive to a short drive letter:<br>
<code class="windows-tip__code">subst Z: "C:\Your\Long\Path\To\Archive"</code>
</li>
<li>Use the <strong>Z:</strong> drive in Archive Browser</li>
<li>To remove later: <code>subst Z: /d</code></li>
</ol>
<p class="windows-tip__note">This limitation dates back to Windows 95. The mapping persists until reboot.</p>
</div>
</details>
<p class="note">Note: This application works entirely in your browser and does not transmit any data.</p>
</div>
</div>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Archive</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is the Archive Browser?</h3>
<p>The Archive Browser lets you search and retrieve files from a ZDDC-compliant archive stored on your local file system. Everything runs in your browser — no data is transmitted anywhere.</p>
<h3>Getting Started</h3>
<ol>
<li>When opened from a web server, the archive loads automatically from that server.</li>
<li>Click <strong>Add Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li>
<li>The browser scans for grouping folders and transmittal folders automatically.</li>
<li>Select folders in the left panel to see their files in the main table.</li>
</ol>
<h3>Navigating Folders</h3>
<p>The left panel has two sections:</p>
<dl>
<dt>Parties</dt>
<dd>Top-level folders representing other parties. Select one or more to filter which transmittals are shown. Use the folder type buttons above the list to show or hide Issued, Received, MDL, and Incoming folder content.</dd>
<dt>Transmittal Folders</dt>
<dd>Grouped by date. Select one or more to filter which files appear in the table.</dd>
</dl>
<p><strong>Multi-select:</strong> Hold <kbd>Ctrl</kbd> and click to toggle individual folders. Hold <kbd>Shift</kbd> and click to select a range. <kbd>Ctrl+Click</kbd> a chevron (&#9654;) to recursively expand or collapse all sub-folders.</p>
<h3>Searching and Filtering</h3>
<dl>
<dt>Column Filters</dt>
<dd>Type in the filter row under each column header to filter by tracking number, title, or revision/status/extension. Filters support the expression syntax below. Active filters are highlighted in blue; use the ↺ reset button in the toolbar to clear all filters at once.</dd>
</dl>
<dl>
<dt><code>term</code></dt>
<dd>Contains "term" (case-insensitive)</dd>
<dt><code>!term</code></dt>
<dd>Does not contain "term"</dd>
<dt><code>^term</code></dt>
<dd>Starts with "term"</dd>
<dt><code>term$</code></dt>
<dd>Ends with "term"</dd>
<dt><code>a b</code></dt>
<dd>Matches both (AND)</dd>
<dt><code>a | b</code></dt>
<dd>Matches either (OR)</dd>
<dt><code>^IFA | ^IFB</code></dt>
<dd>Starts with IFA or IFB</dd>
<dt><code>pdf !draft</code></dt>
<dd>Contains "pdf" and not "draft"</dd>
<dt><code>!^~</code></dt>
<dd>Does not start with ~ (excludes drafts)</dd>
<dt><code>el.*spc</code></dt>
<dd>Regex: contains "el" followed by "spc" (use <code>.</code> for any char, <code>.*</code> for any sequence)</dd>
<dt><code>[ei]fa</code></dt>
<dd>Regex character class: matches "efa" or "ifa"</dd>
</dl>
<dl>
<dt>Modifiers</dt>
<dd>Use the Modifiers dropdown to show or hide files by revision modifier type (+B, +C, +N, +Q, or base).</dd>
</dl>
<h3>Downloading Files</h3>
<dl>
<dt>Download Selected (ZIP)</dt>
<dd>Packages all checked files into a ZIP archive for download.</dd>
<dt>Export Selected (CSV)</dt>
<dd>Exports the visible file list as a CSV spreadsheet.</dd>
<dt>File Preview</dt>
<dd>When enabled, clicking a PDF, Word, or Excel file opens a preview popup instead of downloading it.</dd>
</dl>
<h3>Keyboard Shortcuts</h3>
<dl>
<dt><kbd>Ctrl+A</kbd></dt>
<dd>Select / deselect all visible files in the table.</dd>
<dt><kbd>F5</kbd></dt>
<dd>Refresh — rescan the current directory.</dd>
<dt><kbd>Escape</kbd></dt>
<dd>Close this help panel (or any open modal).</dd>
</dl>
<h3>Windows Path Length Note</h3>
<p>Windows limits file paths to 260 characters by default. If files are skipped during scanning, map your archive to a short drive letter using <code>subst Z: "C:\Your\Long\Path"</code> in an Administrator Command Prompt, then open the <strong>Z:</strong> drive in the Archive Browser.</p>
</div>
</aside>
</div>
<script>
/**
* ZDDC — shared naming convention library
*
* Canonical implementation of all ZDDC filename, folder name, tracking number,
* revision, and status logic. Included in every tool's build via shared/zddc.js.
*
* Exposed as window.zddc (plain global) so it works with every tool's module
* pattern (archive globals, classifier IIFE, transmittal IIFE, mdedit globals).
*
* Public API
* ----------
* zddc.parseFilename(str) → ParsedFile | null
* zddc.parseFolder(str) → ParsedFolder | null
* zddc.parseRevision(str) → ParsedRevision
* zddc.formatFilename(parts) → string
* zddc.formatFolder(parts) → string
* zddc.compareRevisions(a, b) → number (-1 | 0 | 1)
* zddc.isValidStatus(str) → boolean
* zddc.STATUSES → string[]
*
* ParsedFile { trackingNumber, revision, status, title, extension }
* ParsedFolder { date, trackingNumber, status, title }
* ParsedRevision { base, modifier, modifierType, modifierNumber, isDraft, full }
*/
(function (root) {
'use strict';
// ── Valid status codes ───────────────────────────────────────────────────
/**
* Complete list of valid ZDDC document status codes.
* '---' denotes an unknown or not-yet-assigned status.
*/
var STATUSES = [
'---',
'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU',
'REC',
'RSA', 'RSB', 'RSC', 'RSD', 'RSI',
];
var STATUS_SET = {};
for (var _i = 0; _i < STATUSES.length; _i++) {
STATUS_SET[STATUSES[_i]] = true;
}
function isValidStatus(str) {
return !!STATUS_SET[str];
}
// ── Filename parsing ─────────────────────────────────────────────────────
/**
* Canonical file regex.
* Matches: TRACKING_REVISION (STATUS) - TITLE.EXT
*
* Tracking number: no underscores, no whitespace.
* Revision: no whitespace, no parentheses.
* Status: anything inside parentheses (validated separately).
* Title: everything up to the last dot.
* Extension: after the last dot (lowercased by parseFilename).
*/
var FILE_RE = /^([^_\s]+)_([^\s()_]+)\s*\(([^)]+)\)\s*-\s*(\S.*\S|\S)\.\s*([^\s.]+)$/;
/**
* Parse a ZDDC filename.
*
* @param {string} filename
* @returns {{ trackingNumber: string, revision: string, status: string,
* title: string, extension: string, valid: boolean } | null}
* null only if filename is falsy.
* `valid` is true when all fields matched the ZDDC pattern.
*/
function parseFilename(filename) {
if (!filename) { return null; }
var match = filename.match(FILE_RE);
if (!match) {
var lastDot = filename.lastIndexOf('.');
return {
trackingNumber: '',
revision: '',
status: '',
title: lastDot > 0 ? filename.substring(0, lastDot) : filename,
extension: lastDot > 0 ? filename.substring(lastDot + 1).toLowerCase() : '',
valid: false,
};
}
return {
trackingNumber: match[1].trim(),
revision: match[2].trim(),
status: match[3].trim(),
title: match[4].trim(),
extension: match[5].toLowerCase(),
valid: true,
};
}
// ── Folder name parsing ──────────────────────────────────────────────────
/**
* Transmittal folder regex.
* Matches: YYYY-MM-DD_TRACKING (STATUS) - TITLE
*/
var FOLDER_RE = /^(\d{4}-\d{2}-\d{2})_([^_\s(]+)\s*\(([^)]+)\)\s*-\s*(.+)$/;
/**
* Parse a ZDDC transmittal folder name.
*
* @param {string} foldername
* @returns {{ date: string, trackingNumber: string, status: string,
* title: string, valid: boolean } | null}
* null only if foldername is falsy.
*/
function parseFolder(foldername) {
if (!foldername) { return null; }
var match = foldername.match(FOLDER_RE);
if (!match) {
return {
date: '',
trackingNumber: '',
status: '',
title: foldername,
valid: false,
};
}
return {
date: match[1],
trackingNumber: match[2].trim(),
status: match[3].trim(),
title: match[4].trim(),
valid: true,
};
}
// ── Revision parsing ─────────────────────────────────────────────────────
/**
* Modifier sub-regex: +LETTER DIGITS e.g. +C1, +B2, +N1, +Q1
* The draft prefix (~) may appear inside the modifier: A+~C1
*/
var MODIFIER_RE = /^\+(~?)([A-Za-z])(\d+)$/;
/**
* Parse a ZDDC revision string.
*
* Revision grammar:
* revision = ['~'] base ['+' ['~'] modifier_letter modifier_number]
* base = letter(s) | digit(s) | date(YYYY-MM-DD)
* modifier = letter + digits e.g. C1, B2, N1, Q1
*
* @param {string} revision
* @returns {{
* base: string,
* modifier: string, full modifier string e.g. '+C1', '' if none
* modifierType: string, modifier letter e.g. 'C', '' if none
* modifierNumber: number, modifier number e.g. 1, 0 if none
* modifierIsDraft: boolean,
* isDraft: boolean, true if base revision starts with ~
* full: string, original input
* }}
*/
function parseRevision(revision) {
var raw = (revision || '').toString();
// Split on '+' to separate base from optional modifier
var plusIdx = raw.indexOf('+');
var basePart = plusIdx === -1 ? raw : raw.substring(0, plusIdx);
var modifierPart = plusIdx === -1 ? '' : raw.substring(plusIdx);
// Draft flag on the base part
var isDraft = basePart.startsWith('~');
var base = isDraft ? basePart.substring(1) : basePart;
// Parse modifier
var modifier = '';
var modifierType = '';
var modifierNumber = 0;
var modifierIsDraft = false;
if (modifierPart) {
var mMatch = modifierPart.match(MODIFIER_RE);
if (mMatch) {
modifierIsDraft = mMatch[1] === '~';
modifierType = mMatch[2].toUpperCase();
modifierNumber = parseInt(mMatch[3], 10);
modifier = modifierPart;
} else {
// Unrecognised modifier — preserve as-is
modifier = modifierPart;
}
}
return {
base: base,
modifier: modifier,
modifierType: modifierType,
modifierNumber: modifierNumber,
modifierIsDraft: modifierIsDraft,
isDraft: isDraft,
full: raw,
};
}
// ── Revision comparison ──────────────────────────────────────────────────
/**
* Classify a base revision string into a sort tier:
* 0 = date (YYYY-MM-DD)
* 1 = letter(s) A, B, AA …
* 2 = number(s) 0, 1, 2, 1.5 …
* 3 = other
*/
function _baseTier(base) {
if (/^\d{4}-\d{2}-\d{2}$/.test(base)) { return 0; }
if (/^[A-Za-z]+$/.test(base)) { return 1; }
if (/^\d+(\.\d+)?$/.test(base)) { return 2; }
return 3;
}
/**
* Compare two base revision strings.
* Sort order: dates < letters < numbers < other.
*/
function _compareBase(a, b) {
var ta = _baseTier(a);
var tb = _baseTier(b);
if (ta !== tb) { return ta - tb; }
if (ta === 0) { return a < b ? -1 : a > b ? 1 : 0; } // date lexicographic = chronological
if (ta === 1) { return a.toUpperCase() < b.toUpperCase() ? -1 : a.toUpperCase() > b.toUpperCase() ? 1 : 0; }
if (ta === 2) { return parseFloat(a) - parseFloat(b); }
return a.localeCompare(b);
}
/**
* Compare two ZDDC revision strings for sort ordering.
*
* Canonical order (ascending = older → newer):
* ~A < A < A+B1 < A+C1 < A+~C2 < A+C2 < A+N1 < A+Q1
* < ~B < B < … < 0 < 1 < 2
*
* Rules:
* 1. Compare base revisions first (dates < letters < numbers).
* 2. For equal bases, draft (isDraft=true) comes before final.
* 3. For equal base+draft, no-modifier < has-modifier.
* 4. For equal base+draft+modifier presence:
* a. modifier draft comes before modifier final (modifierIsDraft).
* b. Sort modifier by type letter then by number.
*
* @param {string} a
* @param {string} b
* @returns {number} negative if a < b, 0 if equal, positive if a > b
*/
function compareRevisions(a, b) {
var pa = parseRevision(a);
var pb = parseRevision(b);
// 1. Base revision
var baseCmp = _compareBase(pa.base, pb.base);
if (baseCmp !== 0) { return baseCmp; }
// 2. Draft before final (for same base)
if (pa.isDraft !== pb.isDraft) { return pa.isDraft ? -1 : 1; }
// 3. No modifier before any modifier
var aHasMod = pa.modifier !== '';
var bHasMod = pb.modifier !== '';
if (aHasMod !== bHasMod) { return aHasMod ? 1 : -1; }
if (!aHasMod) { return 0; } // both have no modifier
// 4. Compare modifiers: type → number → draft (draft is a tie-breaker only)
// 4a. Modifier type letter (B < C < N < Q …)
if (pa.modifierType !== pb.modifierType) {
return pa.modifierType < pb.modifierType ? -1 : 1;
}
// 4b. Modifier number (1 < 2 …)
if (pa.modifierNumber !== pb.modifierNumber) {
return pa.modifierNumber - pb.modifierNumber;
}
// 4c. Draft of a modifier comes before the final modifier (same type+number)
if (pa.modifierIsDraft !== pb.modifierIsDraft) {
return pa.modifierIsDraft ? -1 : 1;
}
return 0;
}
// ── Filename / folder formatting ─────────────────────────────────────────
/**
* Build a ZDDC filename from its components.
*
* @param {{ trackingNumber: string, revision: string, status: string,
* title: string, extension: string }} parts
* @returns {string} e.g. "123456-EL-SPC-2623_A (IFR) - Specification.pdf"
*/
function formatFilename(parts) {
var tn = (parts.trackingNumber || '').trim();
var rev = (parts.revision || '').trim();
var st = (parts.status || '').trim();
var ttl = (parts.title || '').trim();
var ext = (parts.extension || '').replace(/^\./, '');
if (!tn || !rev || !st || !ttl) { return ''; }
var name = tn + '_' + rev + ' (' + st + ') - ' + ttl;
return ext ? name + '.' + ext : name;
}
/**
* Build a ZDDC transmittal folder name from its components.
*
* @param {{ date: string, trackingNumber: string, status: string,
* title: string }} parts
* @returns {string} e.g. "2025-10-31_123456-EM-SUB-0001 (IFR) - Title"
*/
function formatFolder(parts) {
var dt = (parts.date || '').trim();
var tn = (parts.trackingNumber || '').trim();
var st = (parts.status || '').trim();
var ttl = (parts.title || '').trim();
if (!dt || !tn || !st || !ttl) { return ''; }
return dt + '_' + tn + ' (' + st + ') - ' + ttl;
}
// ── Filename / extension splitting ───────────────────────────────────────
/**
* Split a filename into its base name and extension (no leading dot).
* Treats leading dot ('.gitignore') as no extension.
*
* @param {string} filename
* @returns {{ name: string, extension: string }}
*/
function splitExtension(filename) {
if (!filename) { return { name: '', extension: '' }; }
var lastDot = filename.lastIndexOf('.');
if (lastDot <= 0) { return { name: filename, extension: '' }; }
return {
name: filename.substring(0, lastDot),
extension: filename.substring(lastDot + 1).toLowerCase(),
};
}
/**
* Join a base name and extension. Tolerant of either form ('pdf' or '.pdf').
* Returns just the name when extension is empty.
*/
function joinExtension(name, extension) {
var ext = (extension || '').replace(/^\./, '');
return ext ? name + '.' + ext : name;
}
// ── Public API ───────────────────────────────────────────────────────────
root.zddc = {
STATUSES: STATUSES,
isValidStatus: isValidStatus,
parseFilename: parseFilename,
parseFolder: parseFolder,
parseRevision: parseRevision,
formatFilename: formatFilename,
formatFolder: formatFolder,
compareRevisions: compareRevisions,
splitExtension: splitExtension,
joinExtension: joinExtension,
};
}(typeof window !== 'undefined' ? window : this));
/**
* ZDDC — shared SHA-256 helpers
*
* Attaches to window.zddc.crypto. Must load AFTER shared/zddc.js (which creates
* the window.zddc object).
*
* Exports:
* zddc.crypto.sha256Hex(buffer) → Promise<string> hex digest of ArrayBuffer/Uint8Array
* zddc.crypto.sha256String(str) → Promise<string> hex digest of UTF-8 encoded string
* zddc.crypto.sha256File(file, onProgress?) → Promise<string>
* chunked streaming digest for File/Blob; for files >= 4 MB, streams 2 MB chunks
* and invokes onProgress(loaded, total) every ~8 MB.
* zddc.crypto.bytesToHex(buffer) → string (hex of ArrayBuffer/Uint8Array, no digest)
*
* Throws if Web Crypto SubtleCrypto is not available.
*/
(function (root) {
'use strict';
if (!root.zddc) {
throw new Error('shared/hash.js: window.zddc must be loaded first');
}
var HASH_CHUNK_SIZE = 2 * 1024 * 1024; // 2 MB
function requireSubtle() {
if (!root.crypto || !root.crypto.subtle || typeof root.crypto.subtle.digest !== 'function') {
throw new Error('Web Crypto SubtleCrypto is required');
}
}
function bytesToHex(buffer) {
return Array.from(new Uint8Array(buffer), function (byte) {
return byte.toString(16).padStart(2, '0');
}).join('');
}
async function sha256Hex(buffer) {
requireSubtle();
var input = (buffer instanceof Uint8Array) ? buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) : buffer;
var hash = await root.crypto.subtle.digest('SHA-256', input);
return bytesToHex(hash);
}
async function sha256String(str) {
requireSubtle();
var bytes = new TextEncoder().encode(str);
var hash = await root.crypto.subtle.digest('SHA-256', bytes);
return bytesToHex(hash);
}
async function sha256File(file, onProgress) {
requireSubtle();
// Single-shot for small files or environments without ReadableStream
if (file.size < HASH_CHUNK_SIZE * 2 || typeof file.stream !== 'function') {
if (onProgress) { onProgress(file.size, file.size); }
var buf = await file.arrayBuffer();
var hash = await root.crypto.subtle.digest('SHA-256', buf);
return bytesToHex(hash);
}
// Chunked streaming for large files
var reader = file.stream().getReader();
var loaded = 0;
var chunks = [];
var yieldCounter = 0;
while (true) {
var result = await reader.read();
if (result.done) { break; }
chunks.push(result.value);
loaded += result.value.byteLength;
yieldCounter++;
if (onProgress && yieldCounter % 4 === 0) {
onProgress(loaded, file.size);
await new Promise(function (r) { setTimeout(r, 0); });
}
}
var total = new Uint8Array(loaded);
var offset = 0;
for (var i = 0; i < chunks.length; i++) {
total.set(chunks[i], offset);
offset += chunks[i].byteLength;
}
var digest = await root.crypto.subtle.digest('SHA-256', total.buffer);
if (onProgress) { onProgress(file.size, file.size); }
return bytesToHex(digest);
}
root.zddc.crypto = {
sha256Hex: sha256Hex,
sha256String: sha256String,
sha256File: sha256File,
bytesToHex: bytesToHex,
};
})(typeof window !== 'undefined' ? window : globalThis);
/**
* ZDDC shared theme toggle — light / dark / auto.
* Persists choice to localStorage under 'zddc-theme'.
* Works with all four tools regardless of their module pattern.
* Expects: #theme-btn in the DOM (optional — skips gracefully if absent).
*
* Theme cycle: auto → light → dark → auto …
* 'auto' honours the OS prefers-color-scheme media query (CSS handles it).
* 'light' sets data-theme="light" on <html> (overrides dark media query).
* 'dark' sets data-theme="dark" on <html>.
*/
(function () {
'use strict';
var STORAGE_KEY = 'zddc-theme';
var THEMES = ['auto', 'light', 'dark'];
var LABELS = {
auto: '◐',
light: '☀',
dark: '☾'
};
var TITLES = {
auto: 'Theme: auto (follows OS)',
light: 'Theme: light',
dark: 'Theme: dark'
};
function load() {
var stored = localStorage.getItem(STORAGE_KEY);
return THEMES.indexOf(stored) !== -1 ? stored : 'auto';
}
function apply(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function save(theme) {
try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
}
function updateButton(btn, theme) {
btn.textContent = LABELS[theme];
btn.title = TITLES[theme];
btn.setAttribute('aria-label', TITLES[theme]);
}
function next(theme) {
return THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
}
function init() {
var current = load();
apply(current);
var btn = document.getElementById('theme-btn');
if (!btn) { return; }
updateButton(btn, current);
btn.addEventListener('click', function () {
current = next(current);
apply(current);
save(current);
updateButton(btn, current);
});
}
/* Apply theme immediately (before DOM ready) to avoid flash */
apply(load());
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
/**
* ZDDC — shared preview helpers
*
* Cross-tool helpers for previewing file types that need a decoder:
* - TIFF (UTIF.js) — multi-page, browser-PDF-viewer-style toolbar
* - ZIP listing (JSZip) — sortable file-list view
*
* Renderers operate on any document (parent window or popup window), so the
* same code works for tools whose preview opens in a popup (classifier,
* archive, transmittal) and tools that render inline (mdedit).
*
* Public API on window.zddc.preview:
* loadLibrary(url) → Promise<void>
* renderTiff(doc, container, arrayBuffer, opts) → Promise<void>
* renderZipListing(doc, container, arrayBuffer, opts) → Promise<void>
* TIFF_EXTENSIONS, IMAGE_EXTENSIONS, TEXT_EXTENSIONS, OFFICE_EXTENSIONS
* isTiff(ext), isImage(ext), isText(ext), isZip(ext), isOffice(ext)
*
* Each tool keeps its own dispatcher; this lib only owns the heavy renderers.
*/
(function (root) {
'use strict';
var TIFF_EXTENSIONS = ['tif', 'tiff'];
var IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
var TEXT_EXTENSIONS = [
'txt', 'md', 'markdown', 'json', 'xml', 'csv', 'tsv', 'log',
'html', 'htm', 'css', 'js', 'mjs', 'ts', 'tsx', 'jsx',
'py', 'rb', 'sh', 'bash', 'zsh', 'bat', 'ps1',
'yaml', 'yml', 'ini', 'cfg', 'conf', 'toml',
'c', 'cc', 'cpp', 'h', 'hpp', 'go', 'rs', 'java', 'kt',
'sql', 'env'
];
var OFFICE_EXTENSIONS = ['docx', 'xlsx', 'xls'];
function lowerExt(ext) { return (ext || '').toLowerCase(); }
function isTiff(ext) { return TIFF_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isImage(ext) { return IMAGE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isText(ext) { return TEXT_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
function isZip(ext) { return lowerExt(ext) === 'zip'; }
function isOffice(ext) { return OFFICE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
// ── CDN library loader (parent window cache) ─────────────────────────────
var _libCache = new Map();
function loadLibrary(url) {
if (_libCache.has(url)) return _libCache.get(url);
var p = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = url;
s.onload = function () { resolve(); };
s.onerror = function () { reject(new Error('Failed to load: ' + url)); };
document.head.appendChild(s);
});
_libCache.set(url, p);
return p;
}
// ── Style injection (idempotent per-document) ────────────────────────────
function injectStyles(doc, id, css) {
if (doc.getElementById(id)) return;
var style = doc.createElement('style');
style.id = id;
style.textContent = css;
doc.head.appendChild(style);
}
// ── Helpers ──────────────────────────────────────────────────────────────
function formatSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function formatDate(d) {
if (!d) return '';
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── TIFF renderer ────────────────────────────────────────────────────────
var TIFF_CSS =
'.tiff-toolbar{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;' +
'background:#f5f5f5;border-bottom:1px solid #ddd;flex-wrap:wrap;font-size:.85rem;}' +
'.tiff-toolbar .tiff-btn{padding:.25rem .55rem;border:1px solid #ccc;border-radius:3px;' +
'background:#fff;cursor:pointer;font-size:.85rem;line-height:1;min-width:1.8rem;}' +
'.tiff-toolbar .tiff-btn:hover:not(:disabled){background:#e8e8e8;}' +
'.tiff-toolbar .tiff-btn:disabled{opacity:.4;cursor:default;}' +
'.tiff-toolbar .tiff-page-info{display:inline-flex;align-items:center;gap:.3rem;}' +
'.tiff-toolbar .tiff-page-input{width:3.2rem;padding:.2rem .3rem;border:1px solid #ccc;' +
'border-radius:3px;text-align:center;font-size:.85rem;}' +
'.tiff-toolbar .tiff-zoom-select{padding:.2rem .3rem;border:1px solid #ccc;border-radius:3px;' +
'background:#fff;font-size:.85rem;}' +
'.tiff-toolbar .tiff-spacer{flex:1;}' +
'.tiff-viewport{flex:1;overflow:auto;background:#525659;display:flex;align-items:flex-start;' +
'justify-content:center;padding:1rem;}' +
'.tiff-canvas{background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.4);display:block;' +
'image-rendering:auto;}' +
'.tiff-error{flex:1;display:flex;align-items:center;justify-content:center;color:#900;' +
'padding:2rem;text-align:center;}';
function renderTiff(doc, container, arrayBuffer, opts) {
opts = opts || {};
injectStyles(doc, 'zddc-tiff-styles', TIFF_CSS);
return loadLibrary('https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.js').then(function () {
var ifds;
try {
ifds = window.UTIF.decode(arrayBuffer);
} catch (e) {
container.innerHTML = '<div class="tiff-error">Failed to parse TIFF: '
+ escapeHtml(e.message || e) + '</div>';
return;
}
if (!ifds || !ifds.length) {
container.innerHTML = '<div class="tiff-error">No images found in TIFF.</div>';
return;
}
// Reset container to a flex column
container.innerHTML = '';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.minHeight = '0';
container.style.height = '100%';
container.style.overflow = 'hidden';
// Toolbar
var toolbar = doc.createElement('div');
toolbar.className = 'tiff-toolbar';
var btnPrev = doc.createElement('button');
btnPrev.className = 'tiff-btn'; btnPrev.type = 'button';
btnPrev.title = 'Previous page'; btnPrev.textContent = '◀';
var pageInfo = doc.createElement('span');
pageInfo.className = 'tiff-page-info';
var pageInput = doc.createElement('input');
pageInput.type = 'number'; pageInput.min = '1'; pageInput.value = '1';
pageInput.className = 'tiff-page-input';
var pageOf = doc.createElement('span');
pageOf.textContent = ' of ' + ifds.length;
pageInfo.appendChild(doc.createTextNode('Page '));
pageInfo.appendChild(pageInput);
pageInfo.appendChild(pageOf);
var btnNext = doc.createElement('button');
btnNext.className = 'tiff-btn'; btnNext.type = 'button';
btnNext.title = 'Next page'; btnNext.textContent = '▶';
var spacer = doc.createElement('span');
spacer.className = 'tiff-spacer';
var btnZoomOut = doc.createElement('button');
btnZoomOut.className = 'tiff-btn'; btnZoomOut.type = 'button';
btnZoomOut.title = 'Zoom out'; btnZoomOut.textContent = '';
var zoomSelect = doc.createElement('select');
zoomSelect.className = 'tiff-zoom-select';
var zoomOptions = [
['fit-width', 'Fit width'],
['fit-page', 'Fit page'],
['0.5', '50%'],
['0.75', '75%'],
['1', '100%'],
['1.25', '125%'],
['1.5', '150%'],
['2', '200%'],
['3', '300%'],
['4', '400%']
];
zoomOptions.forEach(function (z) {
var o = doc.createElement('option');
o.value = z[0]; o.textContent = z[1];
zoomSelect.appendChild(o);
});
zoomSelect.value = 'fit-width';
var btnZoomIn = doc.createElement('button');
btnZoomIn.className = 'tiff-btn'; btnZoomIn.type = 'button';
btnZoomIn.title = 'Zoom in'; btnZoomIn.textContent = '+';
toolbar.appendChild(btnPrev);
toolbar.appendChild(pageInfo);
toolbar.appendChild(btnNext);
toolbar.appendChild(spacer);
toolbar.appendChild(btnZoomOut);
toolbar.appendChild(zoomSelect);
toolbar.appendChild(btnZoomIn);
// Viewport with canvas
var viewport = doc.createElement('div');
viewport.className = 'tiff-viewport';
var canvas = doc.createElement('canvas');
canvas.className = 'tiff-canvas';
viewport.appendChild(canvas);
container.appendChild(toolbar);
container.appendChild(viewport);
// Render state
var currentPage = 0;
var zoom = 1;
var fitMode = 'width'; // 'width' | 'page' | null
var decoded = new Array(ifds.length);
function decodePage(i) {
if (decoded[i]) return decoded[i];
var ifd = ifds[i];
window.UTIF.decodeImage(arrayBuffer, ifd);
var rgba = window.UTIF.toRGBA8(ifd);
decoded[i] = { rgba: rgba, w: ifd.width, h: ifd.height };
return decoded[i];
}
function applyZoom() {
var page = decoded[currentPage];
if (!page) return;
var availW = viewport.clientWidth - 32; // padding
var availH = viewport.clientHeight - 32;
var scale;
if (fitMode === 'width') {
scale = availW / page.w;
} else if (fitMode === 'page') {
scale = Math.min(availW / page.w, availH / page.h);
} else {
scale = zoom;
}
if (!isFinite(scale) || scale <= 0) scale = 1;
canvas.style.width = (page.w * scale) + 'px';
canvas.style.height = (page.h * scale) + 'px';
}
function renderPage() {
var page;
try {
page = decodePage(currentPage);
} catch (e) {
container.innerHTML = '<div class="tiff-error">Failed to decode page '
+ (currentPage + 1) + ': ' + escapeHtml(e.message || e) + '</div>';
return;
}
canvas.width = page.w;
canvas.height = page.h;
var ctx = canvas.getContext('2d');
var imgData = ctx.createImageData(page.w, page.h);
imgData.data.set(page.rgba);
ctx.putImageData(imgData, 0, 0);
applyZoom();
pageInput.value = String(currentPage + 1);
btnPrev.disabled = currentPage <= 0;
btnNext.disabled = currentPage >= ifds.length - 1;
}
function setZoomFromSelect() {
var v = zoomSelect.value;
if (v === 'fit-width') { fitMode = 'width'; }
else if (v === 'fit-page') { fitMode = 'page'; }
else { fitMode = null; zoom = parseFloat(v) || 1; }
applyZoom();
}
function nudgeZoom(factor) {
if (fitMode) {
// capture current effective scale before leaving fit mode
var page = decoded[currentPage];
if (page) {
var availW = viewport.clientWidth - 32;
var availH = viewport.clientHeight - 32;
zoom = fitMode === 'width'
? availW / page.w
: Math.min(availW / page.w, availH / page.h);
} else {
zoom = 1;
}
fitMode = null;
}
zoom = Math.max(0.1, Math.min(8, zoom * factor));
// Match select option if any are close, else show as percent
var matched = false;
for (var i = 0; i < zoomSelect.options.length; i++) {
var ov = zoomSelect.options[i].value;
if (ov !== 'fit-width' && ov !== 'fit-page' && Math.abs(parseFloat(ov) - zoom) < 0.001) {
zoomSelect.value = ov; matched = true; break;
}
}
if (!matched) {
// Nearest standard step
var best = '1', bestDiff = Infinity;
for (var j = 0; j < zoomSelect.options.length; j++) {
var v2 = zoomSelect.options[j].value;
if (v2 === 'fit-width' || v2 === 'fit-page') continue;
var diff = Math.abs(parseFloat(v2) - zoom);
if (diff < bestDiff) { bestDiff = diff; best = v2; }
}
zoom = parseFloat(best);
zoomSelect.value = best;
}
applyZoom();
}
btnPrev.addEventListener('click', function () {
if (currentPage > 0) { currentPage--; renderPage(); }
});
btnNext.addEventListener('click', function () {
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); }
});
pageInput.addEventListener('change', function () {
var n = parseInt(pageInput.value, 10);
if (!isNaN(n) && n >= 1 && n <= ifds.length) {
currentPage = n - 1;
renderPage();
} else {
pageInput.value = String(currentPage + 1);
}
});
zoomSelect.addEventListener('change', setZoomFromSelect);
btnZoomIn.addEventListener('click', function () { nudgeZoom(1.25); });
btnZoomOut.addEventListener('click', function () { nudgeZoom(1 / 1.25); });
// Keyboard nav (only when toolbar/viewport in focus path)
container.tabIndex = 0;
container.addEventListener('keydown', function (e) {
if (e.target === pageInput) return;
if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
if (currentPage > 0) { currentPage--; renderPage(); e.preventDefault(); }
} else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); e.preventDefault(); }
}
});
// Re-fit on viewport resize
if (typeof (doc.defaultView && doc.defaultView.ResizeObserver) === 'function') {
var ro = new doc.defaultView.ResizeObserver(function () { applyZoom(); });
ro.observe(viewport);
} else if (doc.defaultView) {
doc.defaultView.addEventListener('resize', function () { applyZoom(); });
}
renderPage();
});
}
// ── ZIP listing renderer ─────────────────────────────────────────────────
var ZIP_CSS =
'.zip-header{padding:.4rem .8rem;background:#f5f5f5;border-bottom:1px solid #ddd;' +
'font-size:.85rem;color:#444;}' +
'.zip-table-wrap{flex:1;overflow:auto;}' +
'.zip-table{width:100%;border-collapse:collapse;font-size:.85rem;font-family:' +
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' +
'.zip-table thead th{position:sticky;top:0;background:#f0f0f0;text-align:left;' +
'padding:.4rem .6rem;border-bottom:1px solid #ccc;cursor:pointer;user-select:none;' +
'font-weight:600;}' +
'.zip-table thead th:hover{background:#e6e6e6;}' +
'.zip-table thead th.zip-sort-asc::after{content:" ▲";font-size:.7rem;color:#888;}' +
'.zip-table thead th.zip-sort-desc::after{content:" ▼";font-size:.7rem;color:#888;}' +
'.zip-table tbody td{padding:.3rem .6rem;border-bottom:1px solid #eee;}' +
'.zip-table tbody tr:hover{background:#f6faff;}' +
'.zip-table .zip-folder{color:#888;}' +
'.zip-table .zip-name{color:#222;}' +
'.zip-table .zip-size,.zip-table .zip-date{font-variant-numeric:tabular-nums;' +
'white-space:nowrap;color:#555;}' +
'.zip-table .zip-col-size,.zip-table .zip-col-date{text-align:right;}' +
'.zip-empty{padding:2rem;text-align:center;color:#888;}';
function renderZipListing(doc, container, arrayBuffer, opts) {
opts = opts || {};
injectStyles(doc, 'zddc-zip-styles', ZIP_CSS);
return loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js').then(function () {
return window.JSZip.loadAsync(arrayBuffer);
}).then(function (zip) {
var entries = [];
zip.forEach(function (relativePath, zipEntry) {
if (zipEntry.dir) return;
var size = (zipEntry._data && zipEntry._data.uncompressedSize) || 0;
entries.push({
path: relativePath,
name: relativePath.split('/').pop(),
size: size,
modified: zipEntry.date instanceof Date ? zipEntry.date : null
});
});
container.innerHTML = '';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.minHeight = '0';
container.style.height = '100%';
container.style.overflow = 'hidden';
var totalSize = entries.reduce(function (s, e) { return s + e.size; }, 0);
var header = doc.createElement('div');
header.className = 'zip-header';
header.textContent = entries.length + ' file' + (entries.length === 1 ? '' : 's')
+ (totalSize ? ' · ' + formatSize(totalSize) + ' uncompressed' : '');
container.appendChild(header);
if (!entries.length) {
var empty = doc.createElement('div');
empty.className = 'zip-empty';
empty.textContent = '(empty archive)';
container.appendChild(empty);
return;
}
var wrap = doc.createElement('div');
wrap.className = 'zip-table-wrap';
var table = doc.createElement('table');
table.className = 'zip-table';
var thead = doc.createElement('thead');
var trh = doc.createElement('tr');
var cols = [
{ key: 'path', label: 'Name', cls: 'zip-col-name' },
{ key: 'size', label: 'Size', cls: 'zip-col-size' },
{ key: 'modified', label: 'Modified', cls: 'zip-col-date' }
];
cols.forEach(function (c) {
var th = doc.createElement('th');
th.className = c.cls;
th.dataset.key = c.key;
th.textContent = c.label;
trh.appendChild(th);
});
thead.appendChild(trh);
table.appendChild(thead);
var tbody = doc.createElement('tbody');
table.appendChild(tbody);
wrap.appendChild(table);
container.appendChild(wrap);
var sortKey = 'path';
var sortDir = 1;
function render() {
var sorted = entries.slice().sort(function (a, b) {
var av, bv;
if (sortKey === 'size') { av = a.size; bv = b.size; }
else if (sortKey === 'modified') {
av = a.modified ? a.modified.getTime() : 0;
bv = b.modified ? b.modified.getTime() : 0;
} else {
av = a.path.toLowerCase(); bv = b.path.toLowerCase();
}
if (av < bv) return -1 * sortDir;
if (av > bv) return 1 * sortDir;
return 0;
});
tbody.innerHTML = '';
sorted.forEach(function (e) {
var tr = doc.createElement('tr');
var td1 = doc.createElement('td');
var slash = e.path.lastIndexOf('/');
if (slash >= 0) {
var folder = doc.createElement('span');
folder.className = 'zip-folder';
folder.textContent = e.path.substring(0, slash + 1);
td1.appendChild(folder);
}
var name = doc.createElement('span');
name.className = 'zip-name';
name.textContent = e.name;
td1.appendChild(name);
var td2 = doc.createElement('td');
td2.className = 'zip-size';
td2.textContent = formatSize(e.size);
var td3 = doc.createElement('td');
td3.className = 'zip-date';
td3.textContent = formatDate(e.modified);
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
tbody.appendChild(tr);
});
// Update sort arrows
var ths = thead.querySelectorAll('th');
for (var i = 0; i < ths.length; i++) {
ths[i].classList.remove('zip-sort-asc', 'zip-sort-desc');
if (ths[i].dataset.key === sortKey) {
ths[i].classList.add(sortDir > 0 ? 'zip-sort-asc' : 'zip-sort-desc');
}
}
}
thead.querySelectorAll('th').forEach(function (th) {
th.addEventListener('click', function () {
var k = th.dataset.key;
if (sortKey === k) sortDir = -sortDir;
else { sortKey = k; sortDir = 1; }
render();
});
});
render();
}).catch(function (err) {
container.innerHTML = '<div class="zip-empty">Failed to read ZIP: '
+ escapeHtml(err.message || err) + '</div>';
});
}
// ── Public API ───────────────────────────────────────────────────────────
if (!root.zddc) root.zddc = {};
root.zddc.preview = {
TIFF_EXTENSIONS: TIFF_EXTENSIONS,
IMAGE_EXTENSIONS: IMAGE_EXTENSIONS,
TEXT_EXTENSIONS: TEXT_EXTENSIONS,
OFFICE_EXTENSIONS: OFFICE_EXTENSIONS,
isTiff: isTiff,
isImage: isImage,
isText: isText,
isZip: isZip,
isOffice: isOffice,
loadLibrary: loadLibrary,
renderTiff: renderTiff,
renderZipListing: renderZipListing,
formatSize: formatSize,
formatDate: formatDate
};
})(typeof window !== 'undefined' ? window : this);
/**
* ZDDC Archive - Initialization
* Sets up window.app and window.app.modules before other modules run.
* Must be the first JS file in the build.
*/
(function() {
'use strict';
window.app = {
sourceMode: null,
directories: [],
groupingFolders: [],
transmittalFolders: [],
files: [],
filteredFiles: [],
selectedFiles: new Set(),
isScanning: false,
scanProgress: '',
columnFilters: { trackingNumber: '', title: '', revisions: '' },
columnFilterASTs: { trackingNumber: null, title: null, revisions: null },
groupingFilter: '',
transmittalFilter: '',
enabledFolderTypes: new Set(['issued', 'received']),
sortField: 'trackingNumber',
sortDirection: 'asc',
selectedGroupingFolders: new Set(),
selectedTransmittalFolders: new Set(),
collapsedDateGroups: new Set(),
collapsedGroupingFolders: new Set(),
selectAllGroupingFolders: true,
selectAllTransmittals: true,
availableModifiers: new Set(),
selectedModifiers: new Set(),
showSelectedOnly: false,
projectFilter: new Set(), // URL-derived; controls what gets SCANNED
visibleProjects: new Set(), // dropdown-derived; controls VISIBILITY of already-scanned data
availableProjects: [], // populated by autoConnectHttpSource from server's ProjectInfo[]
isMultiProject: false, // true when ?projects= is set OR server returns ProjectInfo
FOLDER_TYPE_NAMES: ['issued', 'received', 'mdl', 'incoming'],
ARCHIVE_STAGE_NAME: 'archive',
modules: {}
};
})();
// Archive grouping/sorting helpers — ZDDC parsing comes from window.zddc directly.
(function() {
'use strict';
function isTransmittalFolder(name) {
var parsed = zddc.parseFolder(name);
return !!(parsed && parsed.valid);
}
function groupFilesByTrackingNumber(files) {
const groups = {};
files.forEach(file => {
if (!file.trackingNumber) return;
if (!groups[file.trackingNumber]) {
groups[file.trackingNumber] = { trackingNumber: file.trackingNumber, title: file.title, revisions: {} };
}
if (file.title.length > groups[file.trackingNumber].title.length) {
groups[file.trackingNumber].title = file.title;
}
const revKey = `${file.revision}_${file.status}`;
if (!groups[file.trackingNumber].revisions[revKey]) {
groups[file.trackingNumber].revisions[revKey] = {
revision: file.revision, status: file.status, title: file.title,
hasModifier: file.revision.includes('+'), files: []
};
}
if (file.title.length > groups[file.trackingNumber].revisions[revKey].title.length) {
groups[file.trackingNumber].revisions[revKey].title = file.title;
}
groups[file.trackingNumber].revisions[revKey].files.push(file);
});
return groups;
}
function sortGroupedFiles(groups) {
const field = window.app.sortField || 'trackingNumber';
const direction = window.app.sortDirection === 'desc' ? -1 : 1;
const sorted = Object.values(groups).sort((a, b) => {
let comparison = 0;
if (field === 'trackingNumber') comparison = a.trackingNumber.localeCompare(b.trackingNumber);
else if (field === 'title') comparison = a.title.localeCompare(b.title);
else if (field === 'revisions') {
const aRevs = Object.keys(a.revisions), bRevs = Object.keys(b.revisions);
comparison = zddc.compareRevisions(
aRevs.length > 0 ? aRevs[aRevs.length - 1] : '',
bRevs.length > 0 ? bRevs[bRevs.length - 1] : ''
);
}
return comparison * direction;
});
sorted.forEach(group => {
const revisions = Object.values(group.revisions);
revisions.sort((a, b) => zddc.compareRevisions(b.revision, a.revision));
group.sortedRevisions = revisions;
});
return sorted;
}
window.app.modules.parser = {
isTransmittalFolder,
groupFilesByTrackingNumber,
sortGroupedFiles,
};
})();
(function() {
'use strict';
// Source abstraction — local (File System Access API) and HTTP (Caddy JSON browse)
//
// Three scan modes, decided once at the entry point:
// 1. Multi-project — ?projects=A,B URL param non-empty. The scan root holds project
// folders; for each in the filter, descend into its `archive/` subfolder (case-
// insensitive) and scan from there.
// 2. Project-root — scan root has an `archive/` child (case-insensitive). Descend
// into it and scan from there. Other stage folders (reviewing/staging/mdl/working)
// are not entered.
// 3. In-archive (default) — scan root's children are third-party (grouping) folders.
// Today's behavior, unchanged.
//
// The recursion below the entry point never re-applies the mode check: once we are
// inside the archive folder for a given project, descent is uniform across modes.
//
// Listing skip: at any depth, a directory child whose lowercased name is a member of
// FOLDER_TYPE_NAMES (issued/received/mdl/incoming) AND not currently in
// enabledFolderTypes is skipped entirely — we do not even fetch its listing. Toggling
// a type back on triggers a refresh in app.js.
// Shared utility used by both source implementations
function getDisplayPath(fullPath) {
if (fullPath.length <= 100) {
return fullPath;
}
const parts = fullPath.split('/');
if (parts.length > 3) {
return parts[0] + '/.../' + parts.slice(-2).join('/');
}
return '...' + fullPath.substring(fullPath.length - 80);
}
// True if a directory child should be skipped entirely (don't fetch its listing).
function isHiddenFolderTypeName(rawName) {
const lower = rawName.toLowerCase();
return window.app.FOLDER_TYPE_NAMES.includes(lower)
&& !window.app.enabledFolderTypes.has(lower);
}
// createSource(type, options) returns a source object:
// source.type — 'local' | 'http'
// source.canWrite — boolean
// source.scan(rootIdentifier, callbacks) — Promise; walks tree calling:
// callbacks.onGroupingFolder(folder) folder: { name, path, displayPath, handle? }
// callbacks.onTransmittalFolder(folder) folder: { name, path, displayPath, handle?, url? }
// callbacks.onFile(file) file: full file object (parsed + metadata)
// callbacks.onProgress(message)
// source.fetchFile(fileRef) — Promise<ArrayBuffer>
function createSource(type, options) {
if (type === 'local') {
return createLocalSource();
} else if (type === 'http') {
return createHttpSource(options.baseUrl);
}
throw new Error('Unknown source type: ' + type);
}
// ---------------------------------------------------------------------------
// Local source — wraps File System Access API
// ---------------------------------------------------------------------------
function createLocalSource() {
return {
type: 'local',
canWrite: true,
scan: function(dirHandle, callbacks) {
return scanLocalRoot(dirHandle, dirHandle.name, callbacks);
},
fetchFile: function(fileRef) {
return fileRef.handle.getFile().then(function(f) {
return f.arrayBuffer();
});
}
};
}
async function listLocalEntries(dirHandle, currentPath) {
const entries = [];
try {
for await (const entry of dirHandle.values()) {
entries.push(entry);
}
} catch (err) {
console.warn('Could not read directory ' + currentPath + ':', err);
return null;
}
return entries;
}
function findArchiveInEntries(entries) {
const stage = window.app.ARCHIVE_STAGE_NAME;
for (const entry of entries) {
if (entry.kind === 'directory' && entry.name.toLowerCase() === stage) {
return entry;
}
}
return null;
}
async function scanLocalRoot(dirHandle, rootPath, callbacks) {
callbacks.onProgress('Scanning ' + rootPath + '...');
const entries = await listLocalEntries(dirHandle, rootPath);
if (!entries) return;
// Mode 1 — multi-project (?projects= set)
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
for (const entry of entries) {
if (entry.kind !== 'directory') continue;
if (!window.app.projectFilter.has(entry.name)) continue;
const projPath = rootPath + '/' + entry.name;
const projEntries = await listLocalEntries(entry, projPath);
if (!projEntries) continue;
const archive = findArchiveInEntries(projEntries);
if (!archive) continue;
const archivePath = projPath + '/' + archive.name;
await scanLocalRecursive(archive, archivePath, 0, callbacks);
}
return;
}
// Mode 2 — project-root (scan root has an archive/ child)
const archive = findArchiveInEntries(entries);
if (archive) {
const archivePath = rootPath + '/' + archive.name;
await scanLocalRecursive(archive, archivePath, 0, callbacks);
return;
}
// Mode 3 — in-archive (default)
await processLocalEntries(entries, rootPath, 0, callbacks);
}
async function scanLocalRecursive(dirHandle, currentPath, depth, callbacks) {
if (currentPath.length > 200) {
console.warn('Path too long, skipping deeper scan: ' + currentPath);
return;
}
if (depth > 10) {
console.warn('Directory depth limit reached at: ' + currentPath);
return;
}
callbacks.onProgress('Scanning ' + currentPath + '...');
const entries = await listLocalEntries(dirHandle, currentPath);
if (!entries) return;
await processLocalEntries(entries, currentPath, depth, callbacks);
}
async function processLocalEntries(entries, currentPath, depth, callbacks) {
for (const entry of entries) {
if (entry.kind === 'directory') {
if (isHiddenFolderTypeName(entry.name)) continue;
const subPath = currentPath + '/' + entry.name;
try {
if (window.app.modules.parser.isTransmittalFolder(entry.name)) {
const folder = {
name: entry.name,
path: subPath,
displayPath: getDisplayPath(subPath),
handle: entry
};
callbacks.onTransmittalFolder(folder);
await scanLocalTransmittalFolder(entry, subPath, 0, subPath, callbacks);
} else {
const folder = {
name: entry.name,
path: subPath,
displayPath: entry.name,
handle: entry
};
callbacks.onGroupingFolder(folder);
await scanLocalRecursive(entry, subPath, depth + 1, callbacks);
}
} catch (err) {
console.warn('Could not process directory ' + entry.name + ':', err);
}
} else if (entry.kind === 'file') {
// File directly in a grouping folder — assign to the Outstanding virtual transmittal.
// actualPath records the real containing folder for grouping-folder-scoped filtering.
try {
const file = await entry.getFile();
const parsed = zddc.parseFilename(file.name) || {};
const fullPath = currentPath + '/' + file.name;
const displayPath = fullPath.length > 250
? '...' + fullPath.substring(fullPath.length - 200)
: fullPath;
callbacks.onFile({
id: crypto.randomUUID(),
name: file.name,
path: fullPath,
displayPath: displayPath,
url: null,
size: file.size,
modified: file.lastModified,
handle: entry,
folderPath: '__outstanding__',
actualPath: currentPath,
hasPathError: false,
...parsed
});
} catch (fileErr) {
const fullPath = currentPath + '/' + entry.name;
const displayPath = fullPath.length > 250
? '...' + fullPath.substring(fullPath.length - 200)
: fullPath;
const parsed = zddc.parseFilename(entry.name) || {};
callbacks.onFile({
id: crypto.randomUUID(),
name: entry.name,
path: fullPath,
displayPath: displayPath,
url: null,
size: null,
modified: null,
handle: null,
folderPath: '__outstanding__',
actualPath: currentPath,
hasPathError: true,
pathErrorMessage: fileErr.message || 'File access error',
...parsed
});
console.warn('Could not access file ' + entry.name + ' (path error):', fileErr.message);
}
}
}
}
async function scanLocalTransmittalFolder(dirHandle, folderPath, depth, transmittalPath, callbacks) {
if (depth > 10) {
console.warn('Directory depth limit reached in transmittal folder: ' + folderPath);
return;
}
try {
if (folderPath.length > 240) {
console.warn('Path approaching Windows limit (' + folderPath.length + ' chars): ' + folderPath);
}
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file') {
try {
const file = await entry.getFile();
const parsed = zddc.parseFilename(file.name) || {};
const fullPath = folderPath + '/' + file.name;
const displayPath = fullPath.length > 250
? '...' + fullPath.substring(fullPath.length - 200)
: fullPath;
callbacks.onFile({
id: crypto.randomUUID(),
name: file.name,
path: fullPath,
displayPath: displayPath,
url: null,
size: file.size,
modified: file.lastModified,
handle: entry,
folderPath: transmittalPath,
actualPath: folderPath,
hasPathError: false,
...parsed
});
} catch (fileErr) {
const fullPath = folderPath + '/' + entry.name;
const displayPath = fullPath.length > 250
? '...' + fullPath.substring(fullPath.length - 200)
: fullPath;
const parsed = zddc.parseFilename(entry.name) || {};
callbacks.onFile({
id: crypto.randomUUID(),
name: entry.name,
path: fullPath,
displayPath: displayPath,
url: null,
size: null,
modified: null,
handle: null,
folderPath: transmittalPath,
actualPath: folderPath,
hasPathError: true,
pathErrorMessage: fileErr.message || 'File access error',
...parsed
});
console.warn('Could not access file ' + entry.name + ' (path error):', fileErr.message);
}
} else if (entry.kind === 'directory') {
const subPath = folderPath + '/' + entry.name;
try {
await scanLocalTransmittalFolder(entry, subPath, depth + 1, transmittalPath, callbacks);
} catch (err) {
console.warn('Could not scan subdirectory ' + entry.name + ' in ' + folderPath + ':', err);
}
}
}
} catch (err) {
console.error('Error scanning folder ' + folderPath + ':', err);
}
}
// ---------------------------------------------------------------------------
// HTTP source — uses Caddy JSON browse (Accept: application/json)
// ---------------------------------------------------------------------------
function createHttpSource(baseUrl) {
// Normalise: ensure baseUrl ends with /
const root = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
return {
type: 'http',
canWrite: false,
scan: function(rootUrl, callbacks) {
const scanRoot = (rootUrl && rootUrl !== root) ? rootUrl : root;
return scanHttpRoot(scanRoot, root, callbacks);
},
fetchFile: function(fileRef) {
return fetch(fileRef.url).then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status + ' fetching ' + fileRef.url);
return r.arrayBuffer();
});
}
};
}
async function fetchHttpListing(dirUrl) {
try {
const resp = await fetch(dirUrl, {
headers: { 'Accept': 'application/json' }
});
if (!resp.ok) {
// 403/404 on a sub-path is expected when ACLs deny access or a
// listed dir doesn't exist — log at info level to avoid alarming
// users in the console.
console.info('skip ' + dirUrl + ' (' + resp.status + ')');
return null;
}
const items = await resp.json();
if (!Array.isArray(items)) {
// Server returned 200 but the body wasn't a JSON array — most
// commonly Caddy serving an HTML error page or an index.html
// when file_browse isn't enabled at that path. Silent skip.
return null;
}
return items;
} catch (err) {
// JSON parse failures, network errors, etc. — single concise line.
console.info('skip ' + dirUrl + ': ' + (err.message || err));
return null;
}
}
function rawNameOf(item) {
return item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
}
function findArchiveInItems(items) {
const stage = window.app.ARCHIVE_STAGE_NAME;
for (const item of items) {
if (!item.is_dir) continue;
const name = rawNameOf(item);
if (name.toLowerCase() === stage) return { item: item, name: name };
}
return null;
}
async function scanHttpRoot(scanRootUrl, rootUrl, callbacks) {
// Mode 1 — multi-project (?projects= set). Skip listing scanRootUrl entirely:
// the zddc-server returns a ProjectInfo array there (not a Caddy fileInfo
// listing), so iterating it as if it were a directory listing wouldn't work.
// Project URLs are deterministic — go straight to each one.
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
const tasks = [];
for (const name of window.app.projectFilter) {
if (!name || name.startsWith('.')) continue;
const projUrl = resolveHttpUrl(scanRootUrl, name, true);
tasks.push((async () => {
const projItems = await fetchHttpListing(projUrl);
if (!projItems) return;
const found = findArchiveInItems(projItems);
if (!found) return;
const archiveUrl = resolveHttpUrl(projUrl, found.name, true);
await scanHttpRecursive(archiveUrl, rootUrl, 0, null, callbacks);
})());
}
await Promise.all(tasks);
return;
}
const items = await fetchHttpListing(scanRootUrl);
if (!items) return;
// Mode 2 — project-root (scan root has archive/ child)
const found = findArchiveInItems(items);
if (found) {
const archiveUrl = resolveHttpUrl(scanRootUrl, found.name, true);
await scanHttpRecursive(archiveUrl, rootUrl, 0, null, callbacks);
return;
}
// Mode 3 — in-archive (default)
await processHttpItems(items, scanRootUrl, rootUrl, 0, null, callbacks);
}
async function scanHttpRecursive(dirUrl, rootUrl, depth, transmittalPath, callbacks) {
if (depth > 10) {
console.warn('HTTP directory depth limit reached at: ' + dirUrl);
return;
}
const items = await fetchHttpListing(dirUrl);
if (!items) return;
await processHttpItems(items, dirUrl, rootUrl, depth, transmittalPath, callbacks);
}
async function processHttpItems(items, dirUrl, rootUrl, depth, transmittalPath, callbacks) {
// Collect subdirectory scan promises so siblings run in parallel
const subdirPromises = [];
for (const item of items) {
// Caddy appends "/" to directory names; strip it to get the bare name for matching
const rawName = rawNameOf(item);
// Skip hidden files
if (rawName.startsWith('.')) continue;
const isDir = item.is_dir === true;
const itemUrl = resolveHttpUrl(dirUrl, rawName, isDir);
const logicalPath = urlToLogicalPath(itemUrl, rootUrl);
if (isDir) {
// Skip listings for folder-types that are toggled off — applies at any depth.
if (transmittalPath === null && isHiddenFolderTypeName(rawName)) continue;
if (transmittalPath !== null) {
// Inside a transmittal folder — recurse into subdirectories
subdirPromises.push(
scanHttpRecursive(itemUrl, rootUrl, depth + 1, transmittalPath, callbacks)
);
} else if (window.app.modules.parser.isTransmittalFolder(rawName)) {
const folder = {
name: rawName,
path: logicalPath,
displayPath: getDisplayPath(logicalPath),
handle: null,
url: itemUrl
};
callbacks.onTransmittalFolder(folder);
subdirPromises.push(
scanHttpRecursive(itemUrl, rootUrl, depth + 1, logicalPath, callbacks)
);
} else {
const folder = {
name: rawName,
path: logicalPath,
displayPath: rawName,
handle: null,
url: itemUrl
};
callbacks.onGroupingFolder(folder);
subdirPromises.push(
scanHttpRecursive(itemUrl, rootUrl, depth + 1, null, callbacks)
);
}
} else {
// It's a file
if (transmittalPath === null) {
// File directly in a grouping folder — assign to Outstanding virtual transmittal.
// actualPath records the real containing folder for grouping-folder-scoped filtering.
const dirLogicalPath = urlToLogicalPath(dirUrl, rootUrl);
const parsed = zddc.parseFilename(rawName) || {};
const modified = item.mod_time ? new Date(item.mod_time).getTime() : null;
callbacks.onFile({
id: crypto.randomUUID(),
name: rawName,
path: logicalPath,
displayPath: logicalPath,
url: itemUrl,
size: item.size || null,
modified: modified,
handle: null,
folderPath: '__outstanding__',
actualPath: dirLogicalPath,
hasPathError: false,
...parsed
});
} else {
// Inside a transmittal folder
const parsed = zddc.parseFilename(rawName) || {};
// mod_time is an ISO 8601 string from Go's time.Time.UTC()
const modified = item.mod_time ? new Date(item.mod_time).getTime() : null;
callbacks.onFile({
id: crypto.randomUUID(),
name: rawName,
path: logicalPath,
displayPath: logicalPath,
url: itemUrl,
size: item.size || null,
modified: modified,
handle: null,
folderPath: transmittalPath,
actualPath: logicalPath.substring(0, logicalPath.lastIndexOf('/')),
hasPathError: false,
...parsed
});
}
}
}
// Wait for all sibling subdirectory scans to complete in parallel
if (subdirPromises.length > 0) {
await Promise.all(subdirPromises);
}
}
// Build an absolute URL for an item inside a directory listing URL
function resolveHttpUrl(dirUrl, name, isDir) {
const base = dirUrl.endsWith('/') ? dirUrl : dirUrl + '/';
const encoded = encodeURIComponent(name);
return base + encoded + (isDir ? '/' : '');
}
// Convert an absolute item URL to a logical relative path (for display / filtering)
function urlToLogicalPath(itemUrl, rootUrl) {
const root = rootUrl.endsWith('/') ? rootUrl : rootUrl + '/';
let rel = itemUrl;
if (rel.startsWith(root)) {
rel = rel.substring(root.length);
}
// Decode percent-encoding for display
try { rel = decodeURIComponent(rel); } catch (e) { /* leave encoded */ }
// Strip trailing slash for directories
if (rel.endsWith('/')) rel = rel.slice(0, -1);
return rel;
}
window.app.modules.source = {
getDisplayPath,
createSource
};
})();
(function() {
'use strict';
// SHA-256 hashing and cache management
const HASH_CACHE_FILENAME = '.hashes.json';
// Calculate SHA-256 hash for a file (delegates to shared/hash.js)
async function calculateFileHash(file) {
return zddc.crypto.sha256File(file);
}
// Load hash cache for a directory
async function loadHashCache(dirHandle) {
try {
const cacheHandle = await dirHandle.getFileHandle(HASH_CACHE_FILENAME);
const cacheFile = await cacheHandle.getFile();
const cacheText = await cacheFile.text();
return JSON.parse(cacheText);
} catch (err) {
// Cache doesn't exist or can't be read
return {};
}
}
// Save hash cache for a directory
async function saveHashCache(dirHandle, cache) {
try {
const cacheHandle = await dirHandle.getFileHandle(HASH_CACHE_FILENAME, { create: true });
const writable = await cacheHandle.createWritable();
await writable.write(JSON.stringify(cache, null, 2));
await writable.close();
return true;
} catch (err) {
console.warn('Unable to save hash cache:', err);
return false;
}
}
// Hash files in a directory with caching
async function hashDirectoryFiles(dirHandle, files) {
const cache = await loadHashCache(dirHandle);
const updatedCache = {};
const results = {};
for (const fileInfo of files) {
try {
const file = await fileInfo.handle.getFile();
const cacheKey = file.name;
const lastModified = file.lastModified;
// Check if we have a cached hash
if (cache[cacheKey] && cache[cacheKey].lastModified === lastModified) {
// Use cached hash
results[fileInfo.id] = cache[cacheKey].hash;
updatedCache[cacheKey] = cache[cacheKey];
} else {
// Calculate new hash
const hash = await calculateFileHash(file);
results[fileInfo.id] = hash;
updatedCache[cacheKey] = {
hash: hash,
lastModified: lastModified,
size: file.size
};
}
} catch (err) {
console.error(`Error hashing file ${fileInfo.name}:`, err);
}
}
// Try to save updated cache
await saveHashCache(dirHandle, updatedCache);
return results;
}
// Add hash information to files
async function addHashesToFiles() {
if (!window.app.files.length) return;
// Hash operations require local file handles — not available in HTTP mode
if (window.app.sourceMode === 'http') {
console.log('Hash operations not available in HTTP mode.');
return;
}
window.app.modules.export.showProgress('Calculating file hashes...', 0, window.app.files.length);
try {
// Group files by directory
const filesByDir = {};
window.app.files.forEach(file => {
const dirPath = file.folderPath;
if (!filesByDir[dirPath]) {
filesByDir[dirPath] = {
handle: null,
files: []
};
}
filesByDir[dirPath].files.push(file);
});
// Find directory handles
for (const dirPath in filesByDir) {
const folder = window.app.transmittalFolders.find(f => f.path === dirPath);
if (folder) {
filesByDir[dirPath].handle = folder.handle;
}
}
// Hash files in each directory
let processed = 0;
for (const dirPath in filesByDir) {
const dirInfo = filesByDir[dirPath];
if (dirInfo.handle) {
const hashes = await hashDirectoryFiles(dirInfo.handle, dirInfo.files);
// Update file objects with hashes
dirInfo.files.forEach(file => {
if (hashes[file.id]) {
file.hash = hashes[file.id];
}
});
}
processed += dirInfo.files.length;
window.app.modules.export.showProgress('Calculating file hashes...', processed, window.app.files.length);
}
window.app.modules.export.hideProgress();
} catch (err) {
window.app.modules.export.hideProgress();
console.error('Error calculating hashes:', err);
}
}
// Verify file integrity
async function verifyFileIntegrity(fileId) {
const file = window.app.files.find(f => f.id === fileId);
if (!file || !file.hash) {
alert('No hash available for this file.');
return;
}
if (!file.handle) {
alert('File integrity verification is not available in HTTP mode.');
return;
}
try {
const fileData = await file.handle.getFile();
const currentHash = await calculateFileHash(fileData);
if (currentHash === file.hash) {
alert('File integrity verified. Hash matches.');
} else {
alert('WARNING: File has been modified! Hash does not match.');
}
} catch (err) {
alert('Error verifying file: ' + err.message);
}
}
// Export hash report
function exportHashReport() {
const headers = ['File Path', 'SHA-256 Hash', 'Size', 'Last Modified'];
const rows = [headers];
window.app.filteredFiles.forEach(file => {
if (file.hash) {
rows.push([
file.path,
file.hash,
window.app.modules.export.formatFileSize(file.size),
new Date(file.modified).toISOString()
]);
}
});
window.app.modules.export.downloadFile(window.app.modules.export.rowsToCSV(rows), 'file-hashes.csv', 'text/csv');
}
window.app.modules.hash = {
calculateFileHash,
loadHashCache,
saveHashCache,
hashDirectoryFiles,
addHashesToFiles,
verifyFileIntegrity,
exportHashReport
};
})();
(function() {
'use strict';
// Drag and drop functionality
let draggedFiles = [];
let targetGroupingFolder = null;
// Setup drag and drop
function setupDragAndDrop() {
// Prevent default drag behaviors
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.addEventListener(eventName, preventDefaults, false);
});
// Highlight drop zones
['dragenter', 'dragover'].forEach(eventName => {
document.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
document.addEventListener(eventName, unhighlight, false);
});
// Handle drops on grouping folders (for creating transmittals)
document.getElementById('groupingFoldersList').addEventListener('drop', handleDrop, false);
// Handle drops on the main app area (for adding directories)
document.getElementById('app').addEventListener('drop', handleAppDrop, false);
}
// Prevent default behaviors
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight drop zone
function highlight(e) {
const folderItem = e.target.closest('.folder-item');
if (folderItem && folderItem.parentElement.id === 'groupingFoldersList') {
folderItem.classList.add('drag-over');
}
}
// Remove highlight
function unhighlight(e) {
document.querySelectorAll('.drag-over').forEach(item => {
item.classList.remove('drag-over');
});
}
// Handle directory drop on main app area (for adding directories)
async function handleAppDrop(e) {
// Check if this is a drop on a grouping folder (handled separately)
const folderItem = e.target.closest('.folder-item');
if (folderItem && folderItem.parentElement.id === 'groupingFoldersList') {
return; // Let handleDrop handle this
}
// Check if dataTransfer has directory items
const items = e.dataTransfer.items;
if (!items || items.length === 0) return;
// Process each dropped item
for (let i = 0; i < items.length; i++) {
const item = items[i];
// Check if it's a directory using the File System Access API
if (item.kind === 'file') {
const entry = await item.getAsFileSystemHandle();
if (entry && entry.kind === 'directory') {
// Check if already added
const exists = window.app.directories.some(d => d.name === entry.name);
if (exists) {
continue;
}
// Add to directories
window.app.directories.push({
handle: entry,
name: entry.name,
path: entry.name
});
// Hide empty state if this is the first directory
if (window.app.directories.length === 1) {
window.app.modules.app.hideEmptyState();
}
// Scan the new directory
await window.app.modules.directory.scanDirectory(entry, entry.name);
}
}
}
// Update UI after processing all dropped directories
if (window.app.directories.length > 0) {
window.app.modules.app.updateUI();
}
}
// Handle file drop on grouping folder (for creating transmittals)
async function handleDrop(e) {
const folderItem = e.target.closest('.folder-item');
if (!folderItem) return;
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
// Find the grouping folder
const folderPath = folderItem.getAttribute('data-path');
const groupingFolder = window.app.groupingFolders.find(f => f.path === folderPath);
if (!groupingFolder) return;
targetGroupingFolder = groupingFolder;
draggedFiles = files;
// Show transmittal creation dialog
showTransmittalDialog();
}
// Show transmittal creation dialog
function showTransmittalDialog() {
const modal = document.getElementById('dropModal');
// Generate default transmittal name
const today = new Date();
const dateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD
// Try to extract tracking number from first file
let trackingNumber = 'TRACKING';
let title = 'Transmittal';
if (draggedFiles.length > 0) {
const firstFile = draggedFiles[0];
const parsed = zddc.parseFilename(firstFile.name) || {};
if (parsed.trackingNumber) {
trackingNumber = parsed.trackingNumber;
}
if (parsed.title) {
title = parsed.title;
}
}
const defaultName = `${dateStr}_${trackingNumber} (IFI) - ${title}`;
document.getElementById('transmittalName').value = defaultName;
// Show file preview
updateFilePreview();
modal.classList.remove('hidden');
}
// Update file preview in dialog
function updateFilePreview() {
const tbody = document.getElementById('filesPreviewBody');
const rows = draggedFiles.map(file => {
const parsed = zddc.parseFilename(file.name) || {};
// Generate ZDDC-compliant name
let newName = file.name;
if (parsed.trackingNumber) {
newName = `${parsed.trackingNumber}_${parsed.revision || 'A'} (${parsed.status || 'IFI'}) - ${parsed.title}.${parsed.extension}`;
}
const isValid = !!parsed.trackingNumber;
return `
<tr>
<td>${window.app.modules.app.escapeHtml(file.name)}</td>
<td>
<input type="text"
class="form-input"
value="${window.app.modules.app.escapeHtml(newName)}"
data-original="${window.app.modules.app.escapeHtml(file.name)}"
style="width: 100%;">
</td>
<td style="color: ${isValid ? 'green' : 'red'};">
${isValid ? '✓ Valid' : '✗ Invalid'}
</td>
</tr>
`;
}).join('');
tbody.innerHTML = rows;
}
// Confirm transmittal creation
async function confirmTransmittal() {
const transmittalName = document.getElementById('transmittalName').value.trim();
// Validate transmittal folder name
if (!window.app.modules.parser.isTransmittalFolder(transmittalName)) {
alert('Invalid transmittal folder name. Must follow format: YYYY-MM-DD_TRACKINGNUMBER (STATUS) - TITLE');
return;
}
try {
// Create transmittal folder
const transmittalHandle = await targetGroupingFolder.handle.getDirectoryHandle(transmittalName, { create: true });
// Get file mappings from preview
const fileMappings = [];
const inputs = document.querySelectorAll('#filesPreviewBody input');
inputs.forEach((input, index) => {
fileMappings.push({
originalFile: draggedFiles[index],
newName: input.value.trim()
});
});
// Save files with new names
for (const mapping of fileMappings) {
const fileHandle = await transmittalHandle.getFileHandle(mapping.newName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(mapping.originalFile);
await writable.close();
}
// Close modal
document.getElementById('dropModal').classList.add('hidden');
// Refresh to show new files
await window.app.modules.directory.refreshDirectories();
alert(`Transmittal created successfully with ${fileMappings.length} files.`);
} catch (err) {
console.error('Error creating transmittal:', err);
alert('Error creating transmittal: ' + err.message);
}
}
// Handle drag and drop on table rows (for metadata copy)
function setupTableRowDragDrop() {
document.getElementById('filesTableBody').addEventListener('dragover', (e) => {
const tr = e.target.closest('tr');
if (tr) {
e.preventDefault();
tr.classList.add('drag-over');
}
});
document.getElementById('filesTableBody').addEventListener('dragleave', (e) => {
const tr = e.target.closest('tr');
if (tr) {
tr.classList.remove('drag-over');
}
});
document.getElementById('filesTableBody').addEventListener('drop', async (e) => {
const tr = e.target.closest('tr');
if (!tr) return;
tr.classList.remove('drag-over');
// Get tracking number and title from the row
const trackingNumber = tr.querySelector('td[data-field="trackingNumber"]').textContent;
const title = tr.querySelector('td[data-field="title"]').textContent;
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
// For table row drops, just copy metadata
alert(`Would copy metadata:\nTracking Number: ${trackingNumber}\nTitle: ${title}\n\nTo ${files.length} file(s)`);
});
}
window.app.modules.dragDrop = {
setupDragAndDrop,
showTransmittalDialog,
updateFilePreview,
confirmTransmittal,
setupTableRowDragDrop
};
})();
(function() {
'use strict';
// Directory selection and scanning functionality
// Add directory
async function addDirectory() {
try {
const dirHandle = await window.showDirectoryPicker();
// Check if already added
const exists = window.app.directories.some(d => d.name === dirHandle.name);
if (exists) {
alert('This directory has already been added.');
return;
}
// Add to directories
window.app.directories.push({
handle: dirHandle,
name: dirHandle.name,
path: dirHandle.name // Root path
});
// Hide empty state if this is the first directory
if (window.app.directories.length === 1) {
window.app.modules.app.hideEmptyState();
}
// Scan the new directory
await scanDirectory(dirHandle, dirHandle.name);
window.app.modules.app.updateUI();
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error selecting directory:', err);
alert('Error selecting directory: ' + err.message);
}
}
}
// Scan directory recursively (local mode — delegates to local source in source.js)
async function scanDirectory(dirHandle, path) {
window.app.isScanning = true;
window.app.scanProgress = 'Scanning ' + path + '...';
window.app.modules.app.updateStatusBar();
const source = window.app.modules.source.createSource('local', {});
const callbacks = {
onGroupingFolder: function(folder) {
window.app.groupingFolders.push(folder);
},
onTransmittalFolder: function(folder) {
window.app.transmittalFolders.push(folder);
},
onFile: function(file) {
window.app.files.push(file);
},
onProgress: function(message) {
window.app.scanProgress = message;
window.app.modules.app.updateStatusBar();
}
};
try {
await source.scan(dirHandle, callbacks);
// Only auto-select top-level party folders (shallowest depth).
// Selection is keyed by party NAME so the same-named third-party folder
// appearing under multiple projects is selected/deselected as a unit.
const groupingDepths = window.app.groupingFolders.map(f => f.path.split('/').length);
const minGroupingDepth = groupingDepths.length > 0 ? Math.min(...groupingDepths) : 1;
window.app.groupingFolders.forEach(folder => {
if (folder.path.split('/').length === minGroupingDepth) {
window.app.selectedGroupingFolders.add(folder.name);
}
});
window.app.transmittalFolders.forEach(folder => {
if (!window.app.modules.app.isUnderHiddenFolderType(folder.path)) {
window.app.selectedTransmittalFolders.add(folder.path);
}
});
window.app.modules.app.ensureOutstandingTransmittal();
// Auto-select Outstanding if selectAllTransmittals is active
if (window.app.selectAllTransmittals) {
window.app.selectedTransmittalFolders.add('__outstanding__');
}
window.app.modules.app.collectModifiers();
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters();
if (window.app.modules.presets) {
window.app.modules.presets.init();
}
} catch (err) {
console.error('Error scanning directory:', err);
alert('Error scanning directory: ' + err.message);
} finally {
window.app.isScanning = false;
window.app.scanProgress = '';
window.app.modules.app.updateStatusBar();
}
}
// Refresh all directories
async function refreshDirectories() {
// Clear existing data
window.app.groupingFolders = [];
window.app.transmittalFolders = [];
window.app.files = [];
window.app.filteredFiles = [];
if (window.app.sourceMode === 'http') {
// Re-scan all HTTP sources
const dirs = window.app.directories.slice();
window.app.directories = [];
for (const dir of dirs) {
await window.app.modules.app.addHttpSource(dir.url);
}
} else {
// Re-scan all local directories
for (const dir of window.app.directories) {
await scanDirectory(dir.handle, dir.name);
}
}
window.app.modules.app.updateUI();
}
// Remove directory
function removeDirectory(dirName) {
const index = window.app.directories.findIndex(d => d.name === dirName);
if (index !== -1) {
window.app.directories.splice(index, 1);
// Remove associated folders and files
window.app.groupingFolders = window.app.groupingFolders.filter(f =>
!f.path.startsWith(dirName)
);
window.app.transmittalFolders = window.app.transmittalFolders.filter(f =>
!f.path.startsWith(dirName)
);
window.app.files = window.app.files.filter(f =>
!f.path.startsWith(dirName)
);
// Clean up the Outstanding virtual transmittal if no outstanding files remain
const hasAnyOutstanding = window.app.files.some(f => f.folderPath === '__outstanding__');
if (!hasAnyOutstanding) {
window.app.transmittalFolders = window.app.transmittalFolders.filter(f => f.path !== '__outstanding__');
window.app.selectedTransmittalFolders.delete('__outstanding__');
}
// Show empty state if no directories left
if (window.app.directories.length === 0) {
window.app.modules.app.showEmptyState();
}
window.app.modules.app.updateUI();
}
}
// Request permission for directory
async function requestPermission(dirHandle) {
const options = { mode: 'read' };
// Check current permission state
if ((await dirHandle.queryPermission(options)) === 'granted') {
return true;
}
// Request permission
if ((await dirHandle.requestPermission(options)) === 'granted') {
return true;
}
return false;
}
window.app.modules.directory = {
addDirectory,
scanDirectory,
refreshDirectories,
removeDirectory,
requestPermission
};
})();
(function() {
'use strict';
// Escape a string for use in a RegExp (literal match)
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Build regex pattern at parse time based on anchors
function compilePattern(raw, anchorStart, anchorEnd) {
var src = (anchorStart ? '^' : '') + raw + (anchorEnd ? '$' : '');
try {
return new RegExp(src, 'i');
} catch (e) {
// Invalid regex — escape and retry (always succeeds)
var safe = (anchorStart ? '^' : '') + escapeRegex(raw) + (anchorEnd ? '$' : '');
return new RegExp(safe, 'i');
}
}
// Parse a single token string into a node
function parseToken(token) {
var s = token;
var negate = false;
var anchorStart = false;
var anchorEnd = false;
if (s.charAt(0) === '!') {
negate = true;
s = s.slice(1);
}
if (s.charAt(0) === '^') {
anchorStart = true;
s = s.slice(1);
}
if (s.length > 0 && s.charAt(s.length - 1) === '$') {
anchorEnd = true;
s = s.slice(0, -1);
}
if (s === '') return null;
// bare * (possibly after stripping !) → wildcard-all or wildcard-none
if (s === '*' && !anchorStart && !anchorEnd) {
return negate ? null : { type: 'wildcard-all' };
}
var re = compilePattern(s, anchorStart, anchorEnd);
return { type: negate ? 'no-match' : 'match', re: re };
}
// Parse expression string into AST array
function parse(expression) {
if (!expression || typeof expression !== 'string') return [];
var trimmed = expression.trim();
if (trimmed === '') return [];
if (trimmed === '*') return [{ type: 'wildcard-all' }];
var ast = [];
var i = 0;
var len = trimmed.length;
while (i < len) {
var ch = trimmed.charAt(i);
if (ch === '(') {
var depth = 1;
var j = i + 1;
while (j < len && depth > 0) {
if (trimmed.charAt(j) === '(') depth++;
else if (trimmed.charAt(j) === ')') depth--;
j++;
}
var innerAst = parse(trimmed.slice(i + 1, j - 1));
if (innerAst.length === 1) {
ast.push(innerAst[0]);
} else if (innerAst.length > 1) {
for (var k = 0; k < innerAst.length; k++) ast.push(innerAst[k]);
}
i = j;
} else if (ch === '|') {
ast.push({ type: 'pipe' });
i++;
} else if (ch === ' ') {
i++;
} else {
var j = i;
while (j < len) {
var c = trimmed.charAt(j);
if (c === ' ' || c === '(' || c === '|' || c === ')') break;
j++;
}
var token = trimmed.slice(i, j);
if (token.length > 0) {
var node = parseToken(token);
if (node !== null) ast.push(node);
}
i = j;
}
}
// Group pipes into OR nodes
var hasPipe = false;
var branches = [[]];
for (var l = 0; l < ast.length; l++) {
if (ast[l].type === 'pipe') {
hasPipe = true;
branches.push([]);
} else {
branches[branches.length - 1].push(ast[l]);
}
}
branches = branches.filter(function(b) { return b.length > 0; });
if (!hasPipe) {
return ast.filter(function(n) { return n.type !== 'pipe'; });
}
var orNodes = branches.map(function(branch) {
if (branch.length === 1) return branch[0];
return { type: 'and', nodes: branch };
});
return [{ type: 'or', nodes: orNodes }];
}
// Check if a single node matches the value
function nodeMatches(node, value) {
switch (node.type) {
case 'wildcard-all': return true;
case 'match': return node.re.test(value);
case 'no-match': return !node.re.test(value);
case 'or':
for (var i = 0; i < node.nodes.length; i++) {
if (nodeMatches(node.nodes[i], value)) return true;
}
return false;
case 'and':
for (var i = 0; i < node.nodes.length; i++) {
if (!nodeMatches(node.nodes[i], value)) return false;
}
return true;
default: return false;
}
}
// Evaluate AST against value
function matches(value, ast) {
if (!ast || ast.length === 0) return true;
var v = String(value); // no forced lowercase — regex has 'i' flag
for (var i = 0; i < ast.length; i++) {
if (!nodeMatches(ast[i], v)) return false;
}
return true;
}
if (!window.zddc) {
throw new Error('shared/zddc-filter.js: window.zddc must be loaded first');
}
window.zddc.filter = { parse: parse, matches: matches };
})();
// Filtering functionality
// Apply all filters
function applyFilters() {
// Start with files from selected transmittal folders AND selected grouping folders
let filtered = window.app.files.filter(file => {
// Must have at least one grouping folder selected (if grouping folders exist)
if (window.app.groupingFolders.length > 0 && window.app.selectedGroupingFolders.size === 0) {
return false;
}
// Must have at least one transmittal folder selected
if (window.app.selectedTransmittalFolders.size === 0) {
return false;
}
// Multi-project visibility filter — files under unchecked projects are
// hidden from view (without re-scanning).
if (!window.app.modules.app.pathIsInVisibleProject(file.path)) {
return false;
}
// File must be in a selected transmittal folder
if (!window.app.selectedTransmittalFolders.has(file.folderPath)) {
return false;
}
// Outstanding files: actualPath must be under a selected grouping folder that is
// itself visible (not hidden by folder type toggles).
if (file.folderPath === '__outstanding__') {
if (!window.app.modules.app.outstandingFileIsVisible(file)) return false;
}
// If grouping folders exist and are selected, the file's transmittal folder
// path must contain a path segment matching one of the selected party names.
// Outstanding files are exempt — their grouping scope is enforced by the
// actualPath check above.
if (file.folderPath !== '__outstanding__' && window.app.groupingFolders.length > 0 && window.app.selectedGroupingFolders.size > 0) {
const inSelectedGrouping = file.folderPath.split('/').some(seg =>
window.app.selectedGroupingFolders.has(seg)
);
if (!inSelectedGrouping) {
return false;
}
}
return true;
});
// Apply column filters
filtered = applyColumnFilters(filtered);
// Apply modifier filter
if (window.app.selectedModifiers.size < window.app.availableModifiers.size) {
filtered = filtered.filter(file => window.app.modules.app.filePassesModifierFilter(file));
}
updateResetFiltersBtn();
// Apply selected-only filter
if (window.app.showSelectedOnly) {
filtered = filtered.filter(file => window.app.selectedFiles.has(file.id));
}
window.app.filteredFiles = filtered;
window.app.modules.table.updateFileTable();
window.app.modules.app.updateStatusBar();
window.app.modules.table.updateSelectAllVisibleCheckbox();
}
// Apply column filters using stored ASTs
function applyColumnFilters(files) {
const asts = window.app.columnFilterASTs;
if (asts.trackingNumber && asts.trackingNumber.length > 0) {
files = files.filter(file =>
zddc.filter.matches(file.trackingNumber || '', asts.trackingNumber)
);
}
if (asts.title && asts.title.length > 0) {
files = files.filter(file =>
zddc.filter.matches(file.title || '', asts.title)
);
}
if (asts.revisions && asts.revisions.length > 0) {
files = files.filter(file => {
const revisionText = [
file.revision,
file.status,
file.extension
].join(' ');
return zddc.filter.matches(revisionText, asts.revisions);
});
}
return files;
}
// Clear all filters
function clearFilters() {
window.app.columnFilters = {
trackingNumber: '',
title: '',
revisions: ''
};
window.app.columnFilterASTs = {
trackingNumber: null,
title: null,
revisions: null
};
window.app.groupingFilter = '';
window.app.transmittalFilter = '';
// Clear UI inputs
const groupingFilterEl = document.getElementById('groupingFilter');
groupingFilterEl.value = '';
groupingFilterEl.classList.remove('filter-active');
const transmittalFilterEl = document.getElementById('transmittalFilter');
transmittalFilterEl.value = '';
transmittalFilterEl.classList.remove('filter-active');
document.querySelectorAll('.column-filter').forEach(input => {
input.value = '';
input.classList.remove('filter-active');
});
window.app.modules.app.toggleAllModifiers(true);
updateResetFiltersBtn();
applyFilters();
window.app.modules.urlState.push();
}
// Update reset filters button visibility
function updateResetFiltersBtn() {
// Button is always visible — no show/hide logic needed
}
// Register filtering module
window.app.modules.filtering = {
applyFilters,
applyColumnFilters,
clearFilters,
updateResetFiltersBtn
};
// Table management functionality
(function() {
'use strict';
// FileBlobCache, processedLinks, preview state, and utilities
const fileBlobCache = new Map();
const processedLinks = new WeakSet();
let fileLinkHandlersAttached = false;
let filePreviewWindow = null;
// All extensions previewable in the popup. Image / tiff / zip / text routed
// through #previewContent below; pdf gets a direct iframe; docx/xlsx use
// dedicated lazy-loaded renderers.
const PREVIEW_EXTENSIONS = [
'pdf',
'docx', 'xlsx', 'xls',
...zddc.preview.IMAGE_EXTENSIONS,
...zddc.preview.TIFF_EXTENSIONS,
...zddc.preview.TEXT_EXTENSIONS,
'zip'
];
const loadedLibraries = new Map();
let resizing = null;
// Currently-previewing file (visual highlight in the file table). Survives
// re-renders via applyPreviewHighlight, which is called at the tail of
// updateFileTable. Cleared when the preview popup is closed.
let currentPreviewFileId = null;
let previewWindowWatcher = null;
function setCurrentPreviewFile(fileId) {
currentPreviewFileId = fileId;
applyPreviewHighlight();
}
function applyPreviewHighlight() {
const tbody = document.getElementById('filesTableBody');
if (!tbody) return;
// Clear any prior highlight first.
tbody.querySelectorAll('tr.is-previewing').forEach(el => el.classList.remove('is-previewing'));
tbody.querySelectorAll('.revision-file.is-previewing').forEach(el => el.classList.remove('is-previewing'));
if (!currentPreviewFileId) return;
const checkbox = tbody.querySelector(`input[type="checkbox"][data-file-id="${cssEscape(currentPreviewFileId)}"]`);
if (!checkbox) return;
const wrapper = checkbox.closest('.revision-file');
if (wrapper) wrapper.classList.add('is-previewing');
const row = checkbox.closest('tr');
if (row) row.classList.add('is-previewing');
}
function cssEscape(s) {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(s);
return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c);
}
// Watch the preview popup; clear the highlight when the user closes it so
// the table doesn't keep advertising a preview that's no longer on screen.
function watchPreviewWindow() {
if (previewWindowWatcher) {
clearInterval(previewWindowWatcher);
previewWindowWatcher = null;
}
if (!filePreviewWindow) return;
previewWindowWatcher = setInterval(() => {
if (!filePreviewWindow || filePreviewWindow.closed) {
clearInterval(previewWindowWatcher);
previewWindowWatcher = null;
if (currentPreviewFileId) setCurrentPreviewFile(null);
}
}, 500);
}
/**
* Get or create a blob URL for a file.
* - Local files: reads via File System Access API, caches the blob URL.
* - HTTP files: fetches the remote URL, caches the blob URL.
* Returns a Promise<string> resolving to a blob: URL.
*/
async function getFileBlobUrl(file) {
if (fileBlobCache.has(file.id)) {
return fileBlobCache.get(file.id);
}
let blob;
if (file.handle) {
// Local file via File System Access API
const f = await file.handle.getFile();
blob = f;
} else if (file.url) {
// HTTP file — fetch and convert to blob
const resp = await fetch(file.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + file.url);
blob = await resp.blob();
} else {
throw new Error('File has neither a handle nor a URL');
}
const url = URL.createObjectURL(blob);
fileBlobCache.set(file.id, url);
return url;
}
/**
* Clean up blob URLs for files no longer displayed
*/
function cleanupUnusedBlobUrls() {
const displayedFileIds = new Set(window.app.filteredFiles.map(f => f.id));
for (const [fileId, url] of fileBlobCache.entries()) {
if (!displayedFileIds.has(fileId)) {
URL.revokeObjectURL(url);
fileBlobCache.delete(fileId);
}
}
}
/**
* Revoke all blob URLs and clear cache
*/
function cleanupAllBlobUrls() {
for (const url of fileBlobCache.values()) {
URL.revokeObjectURL(url);
}
fileBlobCache.clear();
}
// Update file table
function updateFileTable() {
const tbody = document.getElementById('filesTableBody');
if (window.app.filteredFiles.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="3" class="empty-table">
No files found matching the current filters.
</td>
</tr>
`;
cleanupUnusedBlobUrls(); // Clean up all blob URLs
return;
}
// Group and sort files
const grouped = window.app.modules.parser.groupFilesByTrackingNumber(window.app.filteredFiles);
const sorted = window.app.modules.parser.sortGroupedFiles(grouped);
// Build table rows
const rows = [];
sorted.forEach(group => {
rows.push(createFileGroupRow(group));
});
tbody.innerHTML = rows.join('');
// Re-apply the preview highlight after every re-render so a file that
// was being previewed when filters changed still shows as previewing if
// it's still in the visible set.
applyPreviewHighlight();
// Clean up blob URLs for files no longer visible
cleanupUnusedBlobUrls();
}
// Create row for a file group
function createFileGroupRow(group) {
// Generate one <tr> per revision; last row gets class group-last for border
const lastIndex = group.sortedRevisions.length - 1;
return group.sortedRevisions.map((revision, i) => {
const titleClass = revision.hasModifier ? 'revision-title-modifier' : 'revision-title-base';
const titleHtml = `<div class="${titleClass}">${window.app.modules.app.escapeHtml(revision.title)}</div>`;
const revisionHtml = createRevisionHtml(group.trackingNumber, revision);
const lastClass = i === lastIndex ? ' group-last' : '';
// First row includes trackingNumber cell with rowspan
if (i === 0) {
return `
<tr class="group-row${lastClass}">
<td data-field="trackingNumber" rowspan="${group.sortedRevisions.length}">${window.app.modules.app.escapeHtml(group.trackingNumber)}</td>
<td data-field="title">${titleHtml}</td>
<td data-field="revisions">${revisionHtml}</td>
</tr>
`;
}
// Subsequent rows omit trackingNumber cell
return `
<tr class="group-row${lastClass}">
<td data-field="title">${titleHtml}</td>
<td data-field="revisions">${revisionHtml}</td>
</tr>
`;
}).join('');
}
// Create HTML for a revision
function createRevisionHtml(trackingNumber, revision) {
const filesHtml = revision.files.map(file =>
createFileHtml(file)
).join(' ');
return `
<div class="revision-group">
<div class="revision-item">
<span class="revision-info">
<span class="revision-id">${window.app.modules.app.escapeHtml(revision.revision)}</span>
<span class="revision-status">(${window.app.modules.app.escapeHtml(revision.status)})</span>
</span>
${filesHtml}
</div>
</div>
`;
}
// Create HTML for a file
function createFileHtml(file) {
const checked = window.app.selectedFiles.has(file.id) ? 'checked' : '';
const fullPath = file.path || file.folderPath + '/' + file.name;
// Handle files with path errors (Windows 260-char limit)
if (file.hasPathError) {
const errorTitle = `⚠️ Cannot access: Microsoft Windows path length limit (260 chars)\n\nPath: ${fullPath}\n\nUse 'subst' to map archive to a drive letter, or shorten folder names.`;
return `
<span class="revision-file">
<input type="checkbox"
data-file-id="${file.id}"
${checked}
onchange="toggleFileSelection('${file.id}')">
<span class="path-error-indicator" title="${window.app.modules.app.escapeHtml(errorTitle)}">⚠️</span>
<span class="file-link-disabled"
title="${window.app.modules.app.escapeHtml(errorTitle)}">
<span class="file-ext">${window.app.modules.app.escapeHtml(file.extension.toUpperCase())}</span>
${file.size != null ? `<span class="file-size">${window.app.modules.export.formatFileSize(file.size)}</span>` : ''}
</span>
</span>
`;
}
return `
<span class="revision-file">
<input type="checkbox"
data-file-id="${file.id}"
${checked}
onchange="toggleFileSelection('${file.id}')">
<a href="#"
class="file-link"
data-file-id="${file.id}"
data-file-name="${window.app.modules.app.escapeHtml(file.name)}"
title="${window.app.modules.app.escapeHtml(fullPath)}">
<span class="file-ext">${window.app.modules.app.escapeHtml(file.extension.toUpperCase())}</span>
${file.size != null ? `<span class="file-size">${window.app.modules.export.formatFileSize(file.size)}</span>` : ''}
</a>
</span>
`;
}
// Toggle file selection
function toggleFileSelection(fileId) {
if (window.app.selectedFiles.has(fileId)) {
window.app.selectedFiles.delete(fileId);
} else {
window.app.selectedFiles.add(fileId);
}
window.app.modules.app.updateStatusBar();
updateSelectAllVisibleCheckbox();
}
// Toggle selection of all visible files based on checkbox state
function toggleSelectAllVisible(selectAll) {
window.app.filteredFiles.forEach(file => {
if (selectAll) {
window.app.selectedFiles.add(file.id);
} else {
window.app.selectedFiles.delete(file.id);
}
});
updateFileTable();
window.app.modules.app.updateStatusBar();
updateSelectAllVisibleCheckbox();
}
// Update the select all visible checkbox to reflect current state
function updateSelectAllVisibleCheckbox() {
const checkbox = document.getElementById('selectAllVisibleCheckbox');
if (!checkbox) return;
const visibleCount = window.app.filteredFiles.length;
if (visibleCount === 0) {
checkbox.checked = false;
checkbox.indeterminate = false;
return;
}
const selectedVisibleCount = window.app.filteredFiles.filter(f =>
window.app.selectedFiles.has(f.id)
).length;
if (selectedVisibleCount === 0) {
checkbox.checked = false;
checkbox.indeterminate = false;
} else if (selectedVisibleCount === visibleCount) {
checkbox.checked = true;
checkbox.indeterminate = false;
} else {
checkbox.checked = false;
checkbox.indeterminate = true;
}
}
/**
* Memory-efficient blob URL management
*
* fileBlobCache: Maps file IDs to blob URLs for reuse
* processedLinks: WeakSet tracks DOM elements that already have blob URLs
* - Automatically garbage collected when DOM elements are removed
* - Prevents redundant async operations on mouseover
*/
/**
* Lazily load a script from CDN. Returns a promise that resolves when loaded.
* Caches the promise so subsequent calls return immediately.
*/
function loadLibrary(url) {
if (loadedLibraries.has(url)) return loadedLibraries.get(url);
const promise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = () => reject(new Error(`Failed to load library: ${url}`));
document.head.appendChild(script);
});
loadedLibraries.set(url, promise);
return promise;
}
/**
* Check if file preview mode is enabled
*/
function isFilePreviewEnabled() {
const toggle = document.getElementById('filePreviewToggle');
return toggle && toggle.checked;
}
/**
* Show file preview in a separate popup window
* Supports PDF (iframe), DOCX (docx-preview), XLSX/XLS (SheetJS)
*/
async function showFilePreview(file) {
const ext = file.extension.toLowerCase();
try {
const url = await getFileBlobUrl(file);
// Mirror the parent window's theme in the popup
const parentTheme = document.documentElement.getAttribute('data-theme') || '';
const themeAttr = parentTheme ? ` data-theme="${parentTheme}"` : '';
// Base HTML shell for the preview window
const previewHtml = `
<!DOCTYPE html>
<html${themeAttr}>
<head>
<title>${window.app.modules.app.escapeHtml(file.name)} - Preview</title>
<style>
:root {
--bg: #ffffff;
--bg-secondary: #f5f5f5;
--bg-hover: #e8e8e8;
--text: #212529;
--text-muted: #666666;
--border: #dddddd;
--primary: #2a5a8a;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--border: #3e3e42;
--primary: #4a90c4;
}
}
[data-theme="dark"] {
--bg: #1e1e1e;
--bg-secondary: #252526;
--bg-hover: #2d2d30;
--text: #d4d4d4;
--text-muted: #9d9d9d;
--border: #3e3e42;
--primary: #4a90c4;
}
[data-theme="light"] {
--bg: #ffffff;
--bg-secondary: #f5f5f5;
--bg-hover: #e8e8e8;
--text: #212529;
--text-muted: #666666;
--border: #dddddd;
--primary: #2a5a8a;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
display: flex;
flex-direction: column;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
}
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.toolbar h1 {
flex: 1;
font-size: 0.95rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.btn {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
cursor: pointer;
}
.btn:hover { background: var(--bg-hover); }
iframe {
flex: 1;
width: 100%;
border: none;
}
#previewContent {
flex: 1;
overflow: auto;
background: var(--bg);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 1.1rem;
}
/* docx-preview container */
.docx-wrapper { padding: 1rem; }
/* Image preview */
img.preview-image {
max-width: 100%;
max-height: 100%;
display: block;
margin: auto;
object-fit: contain;
}
/* Text preview */
pre.preview-text {
padding: 1rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
white-space: pre-wrap;
word-wrap: break-word;
color: var(--text);
background: var(--bg);
}
/* xlsx table styling */
.xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
.xlsx-table th, .xlsx-table td {
border: 1px solid var(--border);
padding: 0.35rem 0.5rem;
text-align: left;
white-space: nowrap;
color: var(--text);
}
.xlsx-table th { background: var(--bg-secondary); font-weight: 600; position: sticky; top: 0; }
.xlsx-table tr:nth-child(even) { background: var(--bg-secondary); }
.xlsx-table tr:hover { background: var(--bg-hover); }
.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); background: var(--bg-secondary); }
.sheet-tab {
padding: 0.4rem 1rem;
cursor: pointer;
border: 1px solid transparent;
border-bottom: none;
font-size: 0.85rem;
background: transparent;
color: var(--text);
}
.sheet-tab:hover { background: var(--bg-hover); }
.sheet-tab.active {
background: var(--bg);
border-color: var(--border);
border-bottom-color: var(--bg);
margin-bottom: -1px;
font-weight: 500;
}
</style>
</head>
<body>
<div class="toolbar">
<h1>${window.app.modules.app.escapeHtml(file.name)}</h1>
<button class="btn" onclick="downloadFile()">Download</button>
</div>
${ext === 'pdf' ? '<iframe src="' + url + '"></iframe>' : '<div id="previewContent"><div class="loading">Loading preview...</div></div>'}
<script>
var blobUrl = "${url}";
var fileName = "${window.app.modules.app.escapeHtml(file.name).replace(/"/g, '\\"')}";
function downloadFile() {
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
<\/script>
</body>
</html>`;
// Open or reuse the preview window
if (filePreviewWindow && !filePreviewWindow.closed) {
filePreviewWindow.document.open();
filePreviewWindow.document.write(previewHtml);
filePreviewWindow.document.close();
filePreviewWindow.focus();
} else {
const width = Math.round(screen.width * 0.6);
const height = Math.round(screen.height * 0.8);
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
filePreviewWindow = window.open('', 'filePreview',
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`);
if (!filePreviewWindow) {
window.open(url, '_blank');
return;
}
filePreviewWindow.document.write(previewHtml);
filePreviewWindow.document.close();
filePreviewWindow.focus();
}
// For non-PDF types, render content into the preview window
if (ext === 'docx') {
await renderDocxInWindow(file);
} else if (ext === 'xlsx' || ext === 'xls') {
await renderXlsxInWindow(file);
} else if (zddc.preview.isTiff(ext)) {
await renderTiffInWindow(file);
} else if (zddc.preview.isZip(ext)) {
await renderZipInWindow(file);
} else if (zddc.preview.isImage(ext)) {
renderImageInWindow(file, url);
} else if (zddc.preview.isText(ext)) {
await renderTextInWindow(file);
}
} catch (err) {
console.error('Error loading file preview:', err);
alert(`Error loading preview: ${err.message}`);
}
}
/**
* Render a DOCX file in the preview window using docx-preview library
*/
async function renderDocxInWindow(file) {
const container = filePreviewWindow.document.getElementById('previewContent');
if (!container) return;
try {
await loadLibrary('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js');
await loadLibrary('https://cdn.jsdelivr.net/npm/docx-preview@latest/dist/docx-preview.min.js');
const arrayBuffer = await (file.handle
? file.handle.getFile().then(f => f.arrayBuffer())
: fetch(file.url).then(r => r.arrayBuffer()));
container.innerHTML = '';
await window.docx.renderAsync(arrayBuffer, container);
} catch (err) {
console.error('Error rendering DOCX:', err);
container.innerHTML = `<div class="loading">Error rendering DOCX: ${err.message}<br>Click Download to view in Word.</div>`;
}
}
/**
* Render an XLSX/XLS file in the preview window using SheetJS
*/
async function renderXlsxInWindow(file) {
const container = filePreviewWindow.document.getElementById('previewContent');
if (!container) return;
try {
await loadLibrary('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
const arrayBuffer = await (file.handle
? file.handle.getFile().then(f => f.arrayBuffer())
: fetch(file.url).then(r => r.arrayBuffer()));
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
container.innerHTML = '';
// Build sheet tabs if multiple sheets
if (workbook.SheetNames.length > 1) {
const tabs = filePreviewWindow.document.createElement('div');
tabs.className = 'sheet-tabs';
workbook.SheetNames.forEach((name, i) => {
const tab = filePreviewWindow.document.createElement('button');
tab.className = 'sheet-tab' + (i === 0 ? ' active' : '');
tab.textContent = name;
tab.onclick = () => {
tabs.querySelectorAll('.sheet-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
renderSheet(workbook, name, tableContainer);
};
tabs.appendChild(tab);
});
container.appendChild(tabs);
}
const tableContainer = filePreviewWindow.document.createElement('div');
tableContainer.style.flex = '1';
tableContainer.style.overflow = 'auto';
container.appendChild(tableContainer);
renderSheet(workbook, workbook.SheetNames[0], tableContainer);
} catch (err) {
console.error('Error rendering XLSX:', err);
container.innerHTML = `<div class="loading">Error rendering spreadsheet: ${err.message}<br>Click Download to view in Excel.</div>`;
}
}
/**
* Render a single sheet as an HTML table
*/
function renderSheet(workbook, sheetName, container) {
const sheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(sheet, { editable: false });
container.innerHTML = html;
// Apply styling to the generated table
const table = container.querySelector('table');
if (table) table.className = 'xlsx-table';
}
async function _getFileArrayBuffer(file) {
if (file.handle) {
const f = await file.handle.getFile();
return f.arrayBuffer();
}
if (file.url) {
const r = await fetch(file.url);
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.arrayBuffer();
}
throw new Error('No file source available');
}
/**
* Render an image (non-tiff) directly using the popup's <img> element.
* The browser handles decoding for jpg/jpeg/png/gif/webp/bmp/svg/ico natively.
*/
function renderImageInWindow(file, url) {
const container = filePreviewWindow.document.getElementById('previewContent');
if (!container) return;
container.innerHTML = '';
const img = filePreviewWindow.document.createElement('img');
img.className = 'preview-image';
img.src = url;
img.alt = file.name || '';
container.appendChild(img);
}
/**
* Render a TIFF using the shared zddc.preview.renderTiff helper (UTIF.js).
*/
async function renderTiffInWindow(file) {
const container = filePreviewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const arrayBuffer = await _getFileArrayBuffer(file);
await zddc.preview.renderTiff(filePreviewWindow.document, container, arrayBuffer, {
fileName: file.name
});
} catch (err) {
console.error('Error rendering TIFF:', err);
container.innerHTML = `<div class="loading">Error rendering TIFF: ${err.message}<br>Click Download to view in another application.</div>`;
}
}
/**
* Render a ZIP listing using the shared zddc.preview.renderZipListing helper.
*/
async function renderZipInWindow(file) {
const container = filePreviewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const arrayBuffer = await _getFileArrayBuffer(file);
await zddc.preview.renderZipListing(filePreviewWindow.document, container, arrayBuffer, {
fileName: file.name
});
} catch (err) {
console.error('Error rendering ZIP listing:', err);
container.innerHTML = `<div class="loading">Error reading ZIP: ${err.message}</div>`;
}
}
/**
* Render a text file as preformatted text. Truncates very large files to
* keep the popup responsive — users can Download to see the full file.
*/
async function renderTextInWindow(file) {
const container = filePreviewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const fileObj = file.handle ? await file.handle.getFile() : await fetch(file.url).then(r => r.blob());
let text = await fileObj.text();
const MAX = 200000;
if (text.length > MAX) text = text.substring(0, MAX) + '\n\n... (truncated, ' + (text.length - MAX) + ' more chars — Download for full file)';
container.innerHTML = '';
const pre = filePreviewWindow.document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
container.appendChild(pre);
} catch (err) {
console.error('Error rendering text file:', err);
container.innerHTML = `<div class="loading">Error reading file: ${err.message}</div>`;
}
}
/**
* Setup event delegation for file links
* Left-click: Download file (or preview if PDF and preview mode enabled)
* Right-click: Allow "Open in new tab" with blob URL
*/
function setupFileLinkHandlers() {
if (fileLinkHandlersAttached) return;
const table = document.getElementById('filesTable');
if (!table) {
console.warn('Files table not found');
return;
}
// Handle clicks - download file or show preview
table.addEventListener('click', async (e) => {
const link = e.target.closest('.file-link');
if (!link) return;
e.preventDefault();
e.stopPropagation();
const fileId = link.getAttribute('data-file-id');
const fileName = link.getAttribute('data-file-name');
if (!fileId || !fileName) {
console.error('Invalid link data');
return;
}
const file = window.app.files.find(f => f.id === fileId);
if (!file) {
console.error(`File not found: ${fileId}`);
alert('File not found. Please refresh and try again.');
return;
}
// Check if file preview is enabled and file type is previewable
if (isFilePreviewEnabled() && PREVIEW_EXTENSIONS.includes(file.extension.toLowerCase())) {
await showFilePreview(file);
setCurrentPreviewFile(file.id);
watchPreviewWindow();
return;
}
try {
if (!file.handle && file.url) {
// HTTP mode: open the file URL directly in a new tab
window.open(file.url, '_blank');
} else {
// Local mode: create blob URL and trigger download
const url = await getFileBlobUrl(file);
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = fileName;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
}
} catch (err) {
console.error('Error opening file:', err);
alert(`Error opening file: ${err.message}`);
}
}, true); // Use capture phase
// Handle mouseover - pre-load URL for fast right-click / middle-click
table.addEventListener('mouseover', async (e) => {
const link = e.target.closest('.file-link');
if (!link) return;
// Skip if already processed (prevents redundant operations)
if (processedLinks.has(link)) return;
const fileId = link.getAttribute('data-file-id');
if (!fileId) return;
const file = window.app.files.find(f => f.id === fileId);
if (!file) {
console.warn(`File not found for pre-load: ${fileId}`);
return;
}
try {
if (!file.handle && file.url) {
// HTTP mode: set href directly — no async needed
link.href = file.url;
link.target = '_blank';
processedLinks.add(link);
} else {
// Local mode: pre-load blob URL asynchronously
const url = await getFileBlobUrl(file);
link.href = url;
link.target = '_blank';
processedLinks.add(link);
}
} catch (err) {
console.error('Error pre-loading file link:', err);
// Don't mark as processed so it can retry
}
}, true); // Use capture phase
// Handle context menu - ensure blob URL is set (fallback if mouseover didn't fire)
table.addEventListener('contextmenu', async (e) => {
const link = e.target.closest('.file-link');
if (!link) return;
// If already processed, blob URL is set - allow context menu to work
if (processedLinks.has(link)) return;
const fileId = link.getAttribute('data-file-id');
if (!fileId) return;
const file = window.app.files.find(f => f.id === fileId);
if (!file) {
console.warn(`File not found for context menu: ${fileId}`);
return;
}
try {
// Get blob URL and set it as href synchronously as possible
const url = await getFileBlobUrl(file);
link.href = url;
link.target = '_blank';
// Mark as processed
processedLinks.add(link);
} catch (err) {
console.error('Error preparing file for context menu:', err);
// Don't mark as processed so it can retry
}
}, true); // Use capture phase
fileLinkHandlersAttached = true;
}
// Get MIME type from extension
function getMimeType(extension) {
const ext = extension.toLowerCase();
const mimeTypes = {
// Documents
'pdf': 'application/pdf',
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt': 'application/vnd.ms-powerpoint',
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
// Text
'txt': 'text/plain',
'csv': 'text/csv',
'html': 'text/html',
'htm': 'text/html',
'xml': 'text/xml',
'json': 'application/json',
// Code
'js': 'text/javascript',
'css': 'text/css',
'py': 'text/plain',
'java': 'text/plain',
'cpp': 'text/plain',
'c': 'text/plain',
'h': 'text/plain',
// Images
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'bmp': 'image/bmp',
'svg': 'image/svg+xml',
'webp': 'image/webp',
'ico': 'image/x-icon',
// Archives
'zip': 'application/zip',
'rar': 'application/x-rar-compressed',
'7z': 'application/x-7z-compressed',
'tar': 'application/x-tar',
'gz': 'application/gzip',
// CAD
'dwg': 'application/acad',
'dxf': 'application/dxf',
'dwf': 'model/vnd.dwf',
'dgn': 'application/x-dgn',
// Other
'mp4': 'video/mp4',
'mp3': 'audio/mpeg',
'wav': 'audio/wav',
'avi': 'video/x-msvideo',
'mov': 'video/quicktime',
'md': 'text/markdown',
'log': 'text/plain',
'ini': 'text/plain',
'cfg': 'text/plain',
'conf': 'text/plain',
'yaml': 'text/yaml',
'yml': 'text/yaml'
};
return mimeTypes[ext] || 'application/octet-stream';
}
// Sort table
function sortTable(field) {
if (window.app.sortField === field) {
// Toggle direction
window.app.sortDirection = window.app.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
// New field, default to ascending
window.app.sortField = field;
window.app.sortDirection = 'asc';
}
updateSortIndicators();
window.app.modules.filtering.applyFilters(); // Re-apply filters which will trigger table update
window.app.modules.urlState.push();
}
// Update sort indicators
function updateSortIndicators() {
// Remove all sort indicators
document.querySelectorAll('th[data-sort]').forEach(th => {
th.removeAttribute('data-sort');
});
// Add current sort indicator
const th = document.querySelector(`th[data-field="${window.app.sortField}"]`);
if (th) {
th.setAttribute('data-sort', window.app.sortDirection);
}
}
// Column resize functionality
function initializeColumnResize() {
const handles = document.querySelectorAll('.resize-handle');
handles.forEach(handle => {
handle.addEventListener('mousedown', startResize);
});
document.addEventListener('mousemove', doResize);
document.addEventListener('mouseup', stopResize);
}
function startResize(e) {
const th = e.target.parentElement;
resizing = {
th: th,
startX: e.clientX,
startWidth: th.offsetWidth
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}
function doResize(e) {
if (! resizing) return;
const diff = e.clientX - resizing.startX;
const newWidth = Math.max(100, resizing.startWidth + diff);
resizing.th.style.width = newWidth + 'px';
// Update corresponding column
const field = resizing.th.getAttribute('data-field');
const cells = document.querySelectorAll(`td[data-field="${field}"]`);
cells.forEach(cell => {
cell.style.width = newWidth + 'px';
});
}
function stopResize() {
if (resizing) {
document.body.style.cursor = '';
document.body.style.userSelect = '';
resizing = null;
}
}
// Toggle all files (Ctrl+A shortcut handler)
// wrapper around toggleSelectAllVisible for keyboard shortcuts
function toggleSelectAll() {
toggleSelectAllVisible(true);
}
/**
* Clean up resources when page unloads
*/
window.addEventListener('beforeunload', () => {
cleanupAllBlobUrls();
});
window.app.modules.table = {
updateFileTable,
toggleFileSelection,
toggleSelectAllVisible,
updateSelectAllVisibleCheckbox,
setupFileLinkHandlers,
updateSortIndicators,
sortTable,
initializeColumnResize
};
})();
(function() {
'use strict';
// Export functionality
// Escape a single value for RFC-4180 CSV
function csvCell(value) {
const str = String(value == null ? '' : value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
// Convert an array of row arrays to a CSV string
function rowsToCSV(rows) {
return rows.map(row => row.map(csvCell).join(',')).join('\n');
}
// Export selected files to CSV
function exportCSV() {
if (window.app.selectedFiles.size === 0) {
alert('No files selected for export.');
return;
}
const headers = ['Tracking Number', 'Title', 'Revision', 'Status', 'Extension', 'Size', 'Size (bytes)', 'Path', 'Modified'];
const rows = [headers];
// Add data rows for selected files only
window.app.files.forEach(file => {
if (!window.app.selectedFiles.has(file.id)) return;
rows.push([
file.trackingNumber || '',
file.title || '',
file.revision || '',
file.status || '',
file.extension || '',
formatFileSize(file.size),
file.size != null ? file.size : '',
file.path,
file.modified ? new Date(file.modified).toLocaleString() : '—'
]);
});
downloadFile(rowsToCSV(rows), 'archive-export.csv', 'text/csv');
}
// Download selected files as ZIP
async function downloadSelected() {
if (window.app.selectedFiles.size === 0) {
alert('No files selected for download.');
return;
}
// Check if JSZip is loaded
if (typeof JSZip === 'undefined') {
// Dynamically load JSZip
await loadJSZip();
}
const zip = new JSZip();
const selectedFiles = [];
// Get selected file objects
window.app.files.forEach(file => {
if (window.app.selectedFiles.has(file.id)) {
selectedFiles.push(file);
}
});
// Show progress
showProgress('Preparing ZIP file...', 0, selectedFiles.length);
try {
// Add files to ZIP
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
showProgress(`Adding ${file.name}...`, i + 1, selectedFiles.length);
try {
let arrayBuffer;
if (file.handle) {
// Local mode: read via File System Access API
const fileData = await file.handle.getFile();
arrayBuffer = await fileData.arrayBuffer();
} else if (file.url) {
// HTTP mode: fetch from server
const resp = await fetch(file.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
arrayBuffer = await resp.arrayBuffer();
} else {
throw new Error('No file handle or URL available');
}
// Create folder structure in ZIP
const relativePath = file.path.substring(file.path.indexOf('/') + 1); // Remove root directory
zip.file(relativePath, arrayBuffer);
} catch (err) {
console.error(`Error adding file ${file.name}:`, err);
}
}
showProgress('Generating ZIP...', selectedFiles.length, selectedFiles.length);
// Generate ZIP
const blob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
});
// Download
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
downloadBlob(blob, `archive-${timestamp}.zip`);
hideProgress();
} catch (err) {
hideProgress();
console.error('Error creating ZIP:', err);
alert('Error creating ZIP file: ' + err.message);
}
}
// Load JSZip library dynamically
function loadJSZip() {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// Show progress indicator
function showProgress(message, current, total) {
let progressDiv = document.getElementById('progressIndicator');
if (!progressDiv) {
progressDiv = document.createElement('div');
progressDiv.id = 'progressIndicator';
progressDiv.className = 'progress-indicator';
document.body.appendChild(progressDiv);
}
const percentage = Math.round((current / total) * 100);
progressDiv.innerHTML =
'<div class="progress-indicator__message">' + window.app.modules.app.escapeHtml(message) + '</div>' +
'<div class="progress-indicator__track">' +
'<div class="progress-indicator__fill" style="width:' + percentage + '%"></div>' +
'</div>' +
'<div class="progress-indicator__label">' + current + ' / ' + total + '</div>';
}
// Hide progress indicator
function hideProgress() {
const progressDiv = document.getElementById('progressIndicator');
if (progressDiv) {
progressDiv.remove();
}
}
// Download file utility
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
downloadBlob(blob, filename);
}
// Download blob utility
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Format file size
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Export to HTML report
function exportHTMLReport() {
// Group files by tracking number
const grouped = window.app.modules.parser.groupFilesByTrackingNumber(window.app.filteredFiles);
const sorted = window.app.modules.parser.sortGroupedFiles(grouped);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Archive Report - ${new Date().toLocaleDateString()}</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; font-weight: bold; }
tr:nth-child(even) { background-color: #f9f9f9; }
.revision { font-family: monospace; }
.status { color: #666; font-size: 0.9em; }
@media print {
body { margin: 0; }
h1 { font-size: 18pt; }
table { font-size: 10pt; }
}
</style>
</head>
<body>
<h1>Archive Report</h1>
<p>Generated: ${new Date().toLocaleString()}</p>
<p>Total Files: ${window.app.filteredFiles.length}</p>
<table>
<thead>
<tr>
<th>Tracking Number</th>
<th>Title</th>
<th>Revisions</th>
</tr>
</thead>
<tbody>
${sorted.map(group => `
<tr>
<td>${window.app.modules.app.escapeHtml(group.trackingNumber)}</td>
<td>${window.app.modules.app.escapeHtml(group.title)}</td>
<td>
${group.sortedRevisions.map(rev => `
<div>
<span class="revision">${window.app.modules.app.escapeHtml(rev.revision)}</span>
<span class="status">(${window.app.modules.app.escapeHtml(rev.status)})</span>
${rev.files.map(f => f.extension.toUpperCase()).join(', ')}
</div>
`).join('')}
</td>
</tr>
`).join('')}
</tbody>
</table>
</body>
</html>`;
downloadFile(html, 'archive-report.html', 'text/html');
}
window.app.modules.export = {
csvCell,
rowsToCSV,
exportCSV,
downloadSelected,
loadJSZip,
showProgress,
hideProgress,
downloadFile,
downloadBlob,
formatFileSize,
exportHTMLReport
};
})();
(function() {
'use strict';
// Project-picker dropdown for the archive browser.
//
// In multi-project mode (HTTP source against zddc-server, OR ?projects=
// present in the URL), this dropdown lets the user toggle which projects
// are scanned. Toggling a checkbox updates window.app.projectFilter, pushes
// the new ?projects= state to the URL, and triggers a re-scan.
//
// In single-project mode the dropdown is hidden — only one project is ever
// in scope, so picking is meaningless.
let isOpen = false;
// The set of project names currently shown in the dropdown.
function getKnownProjects() {
if (window.app.availableProjects && window.app.availableProjects.length > 0) {
return window.app.availableProjects.slice();
}
// Fall back to whatever is in the URL filter — useful when the server's
// ProjectInfo endpoint isn't reachable but ?projects= names the set.
return Array.from(window.app.projectFilter || []);
}
// Visibility-only filter: change visibleProjects, push URL state, re-render
// UI. No rescan — already-scanned data stays in memory. URL is updated via
// history.replaceState (same mechanism as every other UI control).
function applyVisibility(names) {
window.app.visibleProjects = new Set(names);
window.app.modules.urlState.push();
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters();
renderDropdown();
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderDropdown() {
var dropdown = document.getElementById('presetDropdown');
if (!dropdown) return;
var selected = new Set(window.app.visibleProjects || []);
var known = getKnownProjects().slice().sort();
var projectsHtml = known.map(name => {
var checked = selected.has(name) ? ' checked' : '';
var n = escapeHtml(name);
return '<div class="preset-project-item">'
+ '<label class="preset-project-label">'
+ '<input type="checkbox" class="preset-checkbox" data-name="' + n + '"' + checked + '>'
+ ' ' + n
+ '</label>'
+ '</div>';
}).join('');
if (!projectsHtml) {
projectsHtml = '<div class="preset-no-presets"><i>No projects available</i></div>';
}
dropdown.innerHTML =
'<div class="preset-section-bottom">'
+ '<div class="preset-section-label">Projects:</div>'
+ '<div class="preset-projects-list">' + projectsHtml + '</div>'
+ '</div>';
}
function toggleDropdown() {
var dropdown = document.getElementById('presetDropdown');
if (isOpen) { closeDropdown(); return; }
isOpen = true;
if (dropdown) dropdown.classList.remove('hidden');
renderDropdown();
}
function closeDropdown() {
isOpen = false;
var dropdown = document.getElementById('presetDropdown');
if (dropdown) dropdown.classList.add('hidden');
}
function setupDropdownDelegation() {
var dropdown = document.getElementById('presetDropdown');
if (!dropdown) return;
dropdown.addEventListener('click', function(e) {
e.stopPropagation();
var checkbox = e.target.closest('.preset-checkbox');
if (!checkbox) return;
var projectName = checkbox.getAttribute('data-name');
if (!projectName) return;
var sel = new Set(window.app.visibleProjects || []);
if (checkbox.checked) sel.add(projectName);
else sel.delete(projectName);
applyVisibility(Array.from(sel));
});
}
function setupOutsideClickHandler() {
document.addEventListener('click', function(e) {
var section = document.getElementById('presetSection');
var dropdown = document.getElementById('presetDropdown');
if (isOpen && section && dropdown && !section.contains(e.target)) {
closeDropdown();
}
});
}
function init() {
var section = document.getElementById('presetSection');
if (!section) return;
// Hide the dropdown entirely outside multi-project mode.
if (!window.app.isMultiProject) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
var btn = document.getElementById('presetBtn');
if (!btn || btn.dataset.presetInit) return;
btn.dataset.presetInit = '1';
btn.title = 'Project picker';
btn.textContent = '▾ Projects';
btn.addEventListener('click', function(e) {
e.stopPropagation();
toggleDropdown();
});
setupDropdownDelegation();
setupOutsideClickHandler();
}
window.app.modules.presets = {
init: init,
toggleDropdown: toggleDropdown,
closeDropdown: closeDropdown,
// No-op kept so existing callers (events.js after grouping-folder click)
// don't need to null-check; preset dirty state was removed with the
// saved-presets feature.
checkDirty: function() {}
};
})();
(function() {
'use strict';
// URL state sync module for ZDDC Archive
// Default values for URL params
var DEFAULT_SORT_FIELD = 'trackingNumber';
var DEFAULT_SORT_DIRECTION = 'asc';
var DEFAULT_ENABLED_TYPES = ['issued', 'received'];
// Map URL param names to state paths
var PARAM_MAP = {
sort: 'sortField',
dir: 'sortDirection',
tn: 'columnFilters.trackingNumber',
ti: 'columnFilters.title',
rv: 'columnFilters.revisions',
types: 'enabledFolderTypes',
gf: 'groupingFilter',
tf: 'transmittalFilter',
projects: 'projectFilter',
show: 'visibleProjects'
};
// Serialize current state to URL query string
function serialize() {
var params = new URLSearchParams();
// Sort field
if (window.app.sortField !== DEFAULT_SORT_FIELD) {
params.set('sort', window.app.sortField);
}
// Sort direction
if (window.app.sortDirection !== DEFAULT_SORT_DIRECTION) {
params.set('dir', window.app.sortDirection);
}
// Column filters
if (window.app.columnFilters.trackingNumber !== '') {
params.set('tn', window.app.columnFilters.trackingNumber);
}
if (window.app.columnFilters.title !== '') {
params.set('ti', window.app.columnFilters.title);
}
if (window.app.columnFilters.revisions !== '') {
params.set('rv', window.app.columnFilters.revisions);
}
// Folder types (only if different from default [issued, received])
var enabledTypes = Array.from(window.app.enabledFolderTypes).sort();
var defaultTypes = DEFAULT_ENABLED_TYPES.slice().sort();
if (JSON.stringify(enabledTypes) !== JSON.stringify(defaultTypes)) {
params.set('types', enabledTypes.join(','));
}
// Grouping filter
if (window.app.groupingFilter !== '') {
params.set('gf', window.app.groupingFilter);
}
// Transmittal filter
if (window.app.transmittalFilter !== '') {
params.set('tf', window.app.transmittalFilter);
}
// Project filter — always preserved if set (for shareable URLs)
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
params.set('projects', Array.from(window.app.projectFilter).join(','));
}
// Visibility filter (project picker). Emit only when it's a strict subset
// of projectFilter — the common "everything visible" case keeps URLs clean.
if (window.app.visibleProjects && window.app.projectFilter
&& window.app.projectFilter.size > 0) {
var pfSize = window.app.projectFilter.size;
var vp = Array.from(window.app.visibleProjects).filter(function(n) {
return window.app.projectFilter.has(n);
});
if (vp.length < pfSize) {
params.set('show', vp.slice().sort().join(','));
}
}
// Build query string
var qs = params.toString();
return qs ? '?' + qs : '';
}
// Push state to URL without triggering popstate
function push() {
var result = serialize();
if (result === location.search) {
return;
}
try {
history.replaceState(null, '', location.pathname + result);
} catch (e) {
// Silently swallow errors (e.g., file:// protocol restrictions)
}
}
// Restore state from URL query string
function restore() {
var params = new URLSearchParams(location.search);
// Restore sort field
if (params.has('sort')) {
var sortValue = params.get('sort');
if (sortValue === 'trackingNumber' || sortValue === 'title') {
window.app.sortField = sortValue;
}
}
// Restore sort direction
if (params.has('dir')) {
var dirValue = params.get('dir');
if (dirValue === 'asc' || dirValue === 'desc') {
window.app.sortDirection = dirValue;
}
}
// Restore column filters with AST parsing
if (params.has('tn')) {
var tnValue = params.get('tn');
window.app.columnFilters.trackingNumber = tnValue;
window.app.columnFilterASTs.trackingNumber = zddc.filter.parse(tnValue);
}
if (params.has('ti')) {
var tiValue = params.get('ti');
window.app.columnFilters.title = tiValue;
window.app.columnFilterASTs.title = zddc.filter.parse(tiValue);
}
if (params.has('rv')) {
var rvValue = params.get('rv');
window.app.columnFilters.revisions = rvValue;
window.app.columnFilterASTs.revisions = zddc.filter.parse(rvValue);
}
// Restore folder types
if (params.has('types')) {
var typesValue = params.get('types');
var typeValues = typesValue.split(',').map(function(t) { return t.trim(); });
// Validate against app.FOLDER_TYPE_NAMES
var validTypes = typeValues.filter(function(t) {
return window.app.FOLDER_TYPE_NAMES.indexOf(t) !== -1;
});
window.app.enabledFolderTypes = new Set(validTypes);
}
// Restore grouping filter
if (params.has('gf')) {
window.app.groupingFilter = params.get('gf');
}
// Restore transmittal filter
if (params.has('tf')) {
window.app.transmittalFilter = params.get('tf');
}
// Restore project filter
if (params.has('projects')) {
var projValue = params.get('projects');
var projNames = projValue.split(',').map(function(p) { return p.trim(); }).filter(Boolean);
window.app.projectFilter = new Set(projNames);
}
// Restore visibility filter. autoConnectHttpSource will intersect against
// projectFilter / availableProjects after the project list resolves, so
// dropping bogus names is handled there. We just parse here.
if (params.has('show')) {
var showValue = params.get('show');
var showNames = showValue.split(',').map(function(p) { return p.trim(); }).filter(Boolean);
window.app.visibleProjects = new Set(showNames);
}
// Update DOM inputs to reflect restored values
updateFilterInputs();
}
// Update DOM filter inputs to match restored state
function updateFilterInputs() {
// Column filter inputs
document.querySelectorAll('.column-filter[data-filter-field]').forEach(function(input) {
var field = input.getAttribute('data-filter-field');
var filterValue = window.app.columnFilters[field] || '';
input.value = filterValue;
if (filterValue !== '') {
input.classList.add('filter-active');
} else {
input.classList.remove('filter-active');
}
});
// Grouping filter
var groupingFilterEl = document.getElementById('groupingFilter');
if (groupingFilterEl) {
groupingFilterEl.value = window.app.groupingFilter;
if (window.app.groupingFilter !== '') {
groupingFilterEl.classList.add('filter-active');
} else {
groupingFilterEl.classList.remove('filter-active');
}
}
// Transmittal filter
var transmittalFilterEl = document.getElementById('transmittalFilter');
if (transmittalFilterEl) {
transmittalFilterEl.value = window.app.transmittalFilter;
if (window.app.transmittalFilter !== '') {
transmittalFilterEl.classList.add('filter-active');
} else {
transmittalFilterEl.classList.remove('filter-active');
}
}
}
// Register module
window.app.modules.urlState = {
serialize: serialize,
push: push,
restore: restore
};
})();
(function() {
'use strict';
// Event handling
// Set up all event listeners
function setupEventListeners() {
// Header buttons
document.getElementById('addDirectoryBtn').addEventListener('click', () => window.app.modules.directory.addDirectory());
document.getElementById('refreshHeaderBtn').addEventListener('click', () => window.app.modules.directory.refreshDirectories());
// Content area buttons
document.getElementById('filterSelectedBtn').addEventListener('click', () => window.app.modules.app.toggleFilterSelected());
document.getElementById('downloadSelectedBtn').addEventListener('click', () => window.app.modules.export.downloadSelected());
document.getElementById('exportCsvBtn').addEventListener('click', () => window.app.modules.export.exportCSV());
// Search and filter inputs
document.getElementById('groupingFilter').addEventListener('input', (e) => {
window.app.groupingFilter = e.target.value;
e.target.classList.toggle('filter-active', e.target.value.length > 0);
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters();
window.app.modules.urlState.push();
});
document.getElementById('transmittalFilter').addEventListener('input', (e) => {
window.app.transmittalFilter = e.target.value;
e.target.classList.toggle('filter-active', e.target.value.length > 0);
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters(); // Re-filter files when transmittal filter changes
window.app.modules.urlState.push();
});
// Select All Grouping Folders checkbox
document.getElementById('selectAllGroupingCheckbox').addEventListener('change', (e) => {
window.app.selectAllGroupingFolders = e.target.checked;
window.app.modules.app.renderGroupingFolders();
window.app.modules.app.renderTransmittalFolders();
window.app.modules.filtering.applyFilters();
});
// Folder type toggle bar — global click delegation
document.addEventListener('click', (e) => {
const btn = e.target.closest('.folder-type-toggle');
if (btn) {
const type = btn.getAttribute('data-type');
if (type) window.app.modules.app.toggleFolderType(type);
}
});
// Select All Transmittals checkbox
document.getElementById('selectAllTransmittalsCheckbox').addEventListener('change', (e) => {
window.app.selectAllTransmittals = e.target.checked;
window.app.modules.app.renderTransmittalFolders();
window.app.modules.filtering.applyFilters();
});
// Modifier filter dropdown
document.getElementById('modifierFilterBtn').addEventListener('click', () => window.app.modules.app.toggleModifierDropdown());
document.getElementById('modifierSelectAll').addEventListener('change', (e) => {
window.app.modules.app.toggleAllModifiers(e.target.checked);
});
// Close modifier dropdown when clicking outside
document.addEventListener('click', (e) => {
const container = document.querySelector('.modifier-filter-container');
const dropdown = document.getElementById('modifierFilterDropdown');
if (container && dropdown && !container.contains(e.target)) {
dropdown.classList.add('hidden');
}
});
// Select all visible files checkbox
document.getElementById('selectAllVisibleCheckbox').addEventListener('change', (e) => {
e.stopPropagation();
window.app.modules.table.toggleSelectAllVisible(e.target.checked);
});
// Reset filters button
document.getElementById('resetFiltersBtn').addEventListener('click', () => window.app.modules.filtering.clearFilters());
// Column filters — delegated from thead
const thead = document.querySelector('thead');
if (thead) {
thead.addEventListener('input', (e) => {
if (e.target.matches('.column-filter[data-filter-field]')) {
const field = e.target.getAttribute('data-filter-field');
const raw = e.target.value.trim();
window.app.columnFilters[field] = raw;
window.app.columnFilterASTs[field] = zddc.filter.parse(raw);
// Add/remove filter-active class based on non-empty value
if (raw) {
e.target.classList.add('filter-active');
} else {
e.target.classList.remove('filter-active');
}
window.app.modules.filtering.applyFilters();
window.app.modules.urlState.push();
}
});
thead.addEventListener('keydown', (e) => {
if (!e.target.matches('.column-filter[data-filter-field]')) return;
if (e.key === 'Escape') {
e.target.value = '';
e.target.classList.remove('filter-active');
const field = e.target.getAttribute('data-filter-field');
window.app.columnFilters[field] = '';
window.app.columnFilterASTs[field] = null;
window.app.modules.filtering.applyFilters();
window.app.modules.urlState.push();
e.preventDefault();
} else if (e.key === 'Enter') {
e.preventDefault();
const inputs = Array.from(thead.querySelectorAll('.column-filter'));
const idx = inputs.indexOf(e.target);
if (idx !== -1) {
inputs[(idx + 1) % inputs.length].focus();
}
}
});
thead.addEventListener('click', (e) => {
if (e.target.matches('.column-filter')) {
e.stopPropagation();
}
});
}
// Table sorting
document.querySelectorAll('.sortable').forEach(th => {
th.querySelector('.th-content').addEventListener('click', () => {
const field = th.getAttribute('data-field');
window.app.modules.table.sortTable(field);
});
});
// Initialize column resize
window.app.modules.table.initializeColumnResize();
// Modal close buttons
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', closeModal);
});
// Modal backdrop clicks
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
backdrop.addEventListener('click', closeModal);
});
// Drop modal buttons
const dropModal = document.getElementById('dropModal');
dropModal.querySelector('.modal-cancel').addEventListener('click', closeModal);
dropModal.querySelector('.modal-confirm').addEventListener('click', () => window.app.modules.dragDrop.confirmTransmittal());
// Drag and drop (local mode only — requires write access)
if (window.app.sourceMode === 'local') {
window.app.modules.dragDrop.setupDragAndDrop();
}
// Multi-select for folders
setupFolderMultiSelect();
// Date group toggle handlers
setupDateGroupToggles();
// Grouping section collapse toggle
setupGroupingToggle();
// Resizable panes
setupResizablePanes();
// Keyboard shortcuts
document.addEventListener('keydown', handleKeyboardShortcuts);
}
// Handle grouping filter
function handleGroupingFilter(e) {
window.app.groupingFilter = e.target.value;
window.app.modules.app.renderGroupingFolders();
// Re-render transmittal folders as they depend on grouping selection
window.app.modules.app.renderTransmittalFolders();
// Re-filter files based on updated folder selections
window.app.modules.filtering.applyFilters();
}
// Handle transmittal filter
function handleTransmittalFilter(e) {
window.app.transmittalFilter = e.target.value;
window.app.modules.app.renderTransmittalFolders();
// Re-filter files based on updated folder selections
window.app.modules.filtering.applyFilters();
}
// Close modal
function closeModal(e) {
const modal = e.target.closest('.modal');
if (modal) {
modal.classList.add('hidden');
}
}
// Handle keyboard shortcuts
function handleKeyboardShortcuts(e) {
// Escape closes modals
if (e.key === 'Escape') {
document.querySelectorAll('.modal:not(.hidden)').forEach(modal => {
modal.classList.add('hidden');
});
}
// Ctrl+A selects all visible files
if (e.ctrlKey && e.key === 'a' && e.target.tagName !== 'INPUT') {
e.preventDefault();
toggleSelectAll();
}
// F5 refreshes
if (e.key === 'F5') {
e.preventDefault();
window.app.modules.directory.refreshDirectories();
}
}
// Utility: Debounce function
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Multi-select handling for folder lists
function setupFolderMultiSelect() {
let lastSelectedGroupingIndex = -1;
let lastSelectedTransmittalIndex = -1;
// Handle grouping folders
const groupingList = document.getElementById('groupingFoldersList');
groupingList.addEventListener('click', (e) => {
const result = handleFolderClick(e, window.app.selectedGroupingFolders, lastSelectedGroupingIndex);
if (result !== undefined) {
lastSelectedGroupingIndex = result;
// Turn off "Select All" mode when user manually selects
if (window.app.selectAllGroupingFolders) {
window.app.selectAllGroupingFolders = false;
document.getElementById('selectAllGroupingCheckbox').checked = false;
}
// Update selection state first
window.app.modules.app.updateFolderSelectionState('groupingFoldersList');
// Then update transmittal folder list based on new selection
window.app.modules.app.renderTransmittalFolders();
window.app.modules.filtering.applyFilters(); // Re-filter files
// Check presets dirty state
if (window.app.modules.presets) {
window.app.modules.presets.checkDirty();
}
// Reset transmittal index since list may have changed
lastSelectedTransmittalIndex = -1;
}
});
// Handle transmittal folders
const transmittalList = document.getElementById('transmittalFoldersList');
transmittalList.addEventListener('click', (e) => {
const result = handleFolderClick(e, window.app.selectedTransmittalFolders, lastSelectedTransmittalIndex);
if (result !== undefined) {
lastSelectedTransmittalIndex = result;
// Turn off "Select All" mode when user manually selects
if (window.app.selectAllTransmittals) {
window.app.selectAllTransmittals = false;
document.getElementById('selectAllTransmittalsCheckbox').checked = false;
}
// Update selection state without rebuilding DOM
window.app.modules.app.updateFolderSelectionState('transmittalFoldersList');
window.app.modules.filtering.applyFilters(); // Update file display
}
});
// Handle Ctrl+A for folder lists
groupingList.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'a') {
e.preventDefault();
selectAllVisibleFolders('grouping');
}
});
transmittalList.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'a') {
e.preventDefault();
selectAllVisibleFolders('transmittal');
}
});
// Make lists focusable
groupingList.setAttribute('tabindex', '0');
transmittalList.setAttribute('tabindex', '0');
}
/**
* Handle folder click with multi-select support (Shift/Ctrl)
* @param {Event} e - Click event
* @param {Set} selectedSet - Set of selected folder paths
* @param {number} lastIndex - Index of last clicked item
* @returns {number|undefined} Current index if valid click, undefined otherwise
*/
function handleFolderClick(e, selectedSet, lastIndex) {
const folderItem = e.target.closest('.folder-item');
if (!folderItem) return undefined;
const path = folderItem.getAttribute('data-path');
if (!path) return undefined;
const container = folderItem.parentElement;
const items = Array.from(container.children);
const currentIndex = items.indexOf(folderItem);
if (e.shiftKey && lastIndex !== -1 && lastIndex < items.length) {
// Shift+click: select range from last to current
e.preventDefault();
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
if (!e.ctrlKey) {
selectedSet.clear();
}
for (let i = start; i <= end; i++) {
const itemPath = items[i]?.getAttribute('data-path');
if (itemPath) {
selectedSet.add(itemPath);
}
}
} else if (e.ctrlKey || e.metaKey) {
// Ctrl+click: toggle individual selection
e.preventDefault();
if (selectedSet.has(path)) {
selectedSet.delete(path);
} else {
selectedSet.add(path);
}
} else {
// Regular click: clear and select single item
selectedSet.clear();
selectedSet.add(path);
}
return currentIndex;
}
/**
* Toggle expand/collapse state of a grouping folder
* @param {string} path - Folder path to toggle
* @param {boolean} recursive - If true, also toggle all descendants
*/
function toggleGroupingFolder(path, recursive) {
const isCurrentlyCollapsed = window.app.collapsedGroupingFolders.has(path);
if (recursive) {
// Get all descendant folder paths
const descendants = window.app.groupingFolders
.filter(f => f.path.startsWith(path + '/'))
.map(f => f.path);
if (isCurrentlyCollapsed) {
// Expand this folder and all descendants
window.app.collapsedGroupingFolders.delete(path);
descendants.forEach(p => window.app.collapsedGroupingFolders.delete(p));
} else {
// Collapse this folder and all descendants
window.app.collapsedGroupingFolders.add(path);
descendants.forEach(p => window.app.collapsedGroupingFolders.add(p));
}
} else {
// Just toggle this folder
if (isCurrentlyCollapsed) {
window.app.collapsedGroupingFolders.delete(path);
} else {
window.app.collapsedGroupingFolders.add(path);
}
}
window.app.modules.app.renderGroupingFolders();
}
// Select all visible folders
function selectAllVisibleFolders(folderType) {
const container = folderType === 'grouping' ?
document.getElementById('groupingFoldersList') :
document.getElementById('transmittalFoldersList');
const selectedSet = folderType === 'grouping' ?
window.app.selectedGroupingFolders :
window.app.selectedTransmittalFolders;
selectedSet.clear();
const items = container.querySelectorAll('.folder-item');
items.forEach(item => {
const path = item.getAttribute('data-path');
if (path) {
selectedSet.add(path);
}
});
if (folderType === 'grouping') {
// Update UI to reflect grouping changes
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters();
} else {
// For transmittal folders, just update selection state
window.app.modules.app.updateFolderSelectionState('transmittalFoldersList');
window.app.modules.filtering.applyFilters();
}
}
// Setup date group toggle handlers
function setupDateGroupToggles() {
// Toggle all dates button
const toggleAllBtn = document.getElementById('toggleAllDatesBtn');
if (toggleAllBtn) {
toggleAllBtn.addEventListener('click', toggleAllDateGroups);
}
// Individual date group headers (using event delegation)
const transmittalList = document.getElementById('transmittalFoldersList');
transmittalList.addEventListener('click', (e) => {
const header = e.target.closest('.date-group-header');
if (header) {
const date = header.getAttribute('data-date');
if (date) {
toggleDateGroup(date);
}
}
});
}
// Toggle a single date group
function toggleDateGroup(date) {
if (window.app.collapsedDateGroups.has(date)) {
window.app.collapsedDateGroups.delete(date);
} else {
window.app.collapsedDateGroups.add(date);
}
window.app.modules.app.renderTransmittalFolders();
updateToggleAllIcon();
}
// Toggle all date groups
function toggleAllDateGroups() {
const headers = document.querySelectorAll('.date-group-header');
const allDates = Array.from(headers).map(h => h.getAttribute('data-date')).filter(Boolean);
// If all are collapsed, expand all. Otherwise, collapse all.
const allCollapsed = allDates.length > 0 && allDates.every(date => window.app.collapsedDateGroups.has(date));
if (allCollapsed) {
// Expand all
window.app.collapsedDateGroups.clear();
} else {
// Collapse all
allDates.forEach(date => window.app.collapsedDateGroups.add(date));
}
window.app.modules.app.renderTransmittalFolders();
updateToggleAllIcon();
}
// Update the toggle all icon based on current state
function updateToggleAllIcon() {
const icon = document.getElementById('toggleAllDatesIcon');
if (!icon) return;
const headers = document.querySelectorAll('.date-group-header');
const allDates = Array.from(headers).map(h => h.getAttribute('data-date')).filter(Boolean);
const allCollapsed = allDates.length > 0 && allDates.every(date => window.app.collapsedDateGroups.has(date));
icon.textContent = allCollapsed ? '▶' : '▼';
}
// Setup grouping section collapse toggle
function setupGroupingToggle() {
const toggleBtn = document.getElementById('toggleGroupingBtn');
const groupingSection = document.getElementById('groupingSection');
const icon = document.getElementById('toggleGroupingIcon');
if (toggleBtn && groupingSection && icon) {
toggleBtn.addEventListener('click', () => {
groupingSection.classList.toggle('collapsed');
icon.textContent = groupingSection.classList.contains('collapsed') ? '▶' : '▼';
});
}
}
// Setup resizable panes
function setupResizablePanes() {
// Resize nav sections (vertical divider between grouping and transmittal)
const navSectionsHandle = document.querySelector('[data-resize="nav-sections"]');
if (navSectionsHandle) {
let isResizing = false;
let startY = 0;
let startHeight = 0;
let groupingSection = null;
navSectionsHandle.addEventListener('mousedown', (e) => {
isResizing = true;
startY = e.clientY;
groupingSection = document.getElementById('groupingSection');
startHeight = groupingSection.offsetHeight;
navSectionsHandle.classList.add('resizing');
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const deltaY = e.clientY - startY;
const newHeight = startHeight + deltaY;
// Set min/max heights
if (newHeight >= 100 && newHeight <= window.innerHeight - 250) {
groupingSection.style.flex = 'none';
groupingSection.style.height = newHeight + 'px';
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
navSectionsHandle.classList.remove('resizing');
}
});
}
// Resize nav pane (horizontal divider between nav and content)
const navPaneHandle = document.querySelector('[data-resize="nav-pane"]');
if (navPaneHandle) {
let isResizing = false;
let startX = 0;
let startWidth = 0;
let navPane = null;
navPaneHandle.addEventListener('mousedown', (e) => {
isResizing = true;
startX = e.clientX;
navPane = document.getElementById('navigationPane');
startWidth = navPane.offsetWidth;
navPaneHandle.classList.add('resizing');
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const deltaX = e.clientX - startX;
const newWidth = startWidth + deltaX;
// Set min/max widths
if (newWidth >= 200 && newWidth <= window.innerWidth - 400) {
navPane.style.width = newWidth + 'px';
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
navPaneHandle.classList.remove('resizing');
}
});
}
}
window.app.modules.events = {
setupEventListeners,
handleFolderClick,
toggleGroupingFolder,
selectAllVisibleFolders,
setupDateGroupToggles,
toggleDateGroup,
toggleAllDateGroups,
updateToggleAllIcon,
setupGroupingToggle,
setupResizablePanes
};
})();
(function() {
'use strict';
// window.app is initialized in init.js. Reference shape (read-only docs):
// directories[], groupingFolders[], transmittalFolders[], files[],
// filteredFiles[], selectedFiles:Set, sourceMode ('local'|'http'),
// isScanning, scanProgress,
// columnFilters {trackingNumber,title,revisions}, columnFilterASTs {...},
// groupingFilter, transmittalFilter,
// enabledFolderTypes:Set('issued','received'),
// sortField ('trackingNumber'), sortDirection ('asc'|'desc'),
// selectedGroupingFolders:Set, selectedTransmittalFolders:Set,
// collapsedDateGroups:Set, collapsedGroupingFolders:Set,
// selectAllGroupingFolders:bool, selectAllTransmittals:bool,
// availableModifiers:Set, selectedModifiers:Set, showSelectedOnly:bool
// Parse search terms from filter string
function parseSearchTerms(filter) {
if (!filter || !filter.trim()) return [];
return filter.trim().toLowerCase().split(/\s+/);
}
// Check if text matches all search terms (AND logic)
function matchesSearchTerms(text, terms) {
if (!terms || terms.length === 0) return true;
return terms.every(term => text.includes(term));
}
// Initialize application
function initApp() {
// Detect source mode from protocol
window.app.sourceMode = (location.protocol === 'file:') ? 'local' : 'http';
if (window.app.sourceMode === 'local') {
// Check File System Access API support (local mode only)
if (!('showDirectoryPicker' in window)) {
showUnsupportedBrowserMessage();
return;
}
}
// Set up event listeners
window.app.modules.events.setupEventListeners();
// Set up file link handlers (event delegation)
window.app.modules.table.setupFileLinkHandlers();
// Apply source-mode-specific UI adjustments
applySourceModeUI();
// Restore filter/sort state from URL query string
window.app.modules.urlState.restore();
// Initialize UI
updateUI();
// Show initial sort indicator
window.app.modules.table.updateSortIndicators();
if (window.app.sourceMode === 'http') {
// Auto-connect to the server in HTTP mode
autoConnectHttpSource();
} else {
// Show empty state if no directories (local mode)
if (window.app.directories.length === 0) {
showEmptyState();
}
}
}
// Apply UI differences based on source mode
function applySourceModeUI() {
// "Add Local Directory" button is always visible in both modes —
// in HTTP mode the user can augment the online archive with local directories.
}
// Auto-connect to the HTTP server
// Derives the base URL from the current page's location
async function autoConnectHttpSource() {
var href = window.location.href;
// Strip query string and fragment
href = href.split('?')[0].split('#')[0];
// Strip the filename to get the directory
var lastSlash = href.lastIndexOf('/');
var baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
// Multi-project mode is opt-in via ?projects= in the URL.
// ?projects= absent → not multi-project; scan whatever the URL
// points to (single-project or in-archive
// mode). The server's project list, if any,
// stays out of view.
// ?projects= empty → multi-project; include every project the
// server says the user can access.
// ?projects=A,B → multi-project; include only the listed
// projects (intersected with server access).
// The archive never sees projects beyond this scope — the visibility
// dropdown only narrows what's already in availableProjects.
var urlParams = new URLSearchParams(location.search);
var projectsParamPresent = urlParams.has('projects');
if (projectsParamPresent) {
window.app.isMultiProject = true;
// Fetch the server's ACL-filtered project list so we can drop any
// listed names the user doesn't actually have access to (and so
// the empty-projects= "include everything" mode has a list to use).
var serverNames = null;
try {
var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } });
if (resp.ok) {
var serverProjects = await resp.json();
if (Array.isArray(serverProjects) && serverProjects.length > 0
&& serverProjects[0] && typeof serverProjects[0].name === 'string') {
serverNames = new Set(serverProjects.map(function(p) { return p.name; }));
}
}
} catch (e) {
// Plain Caddy or proxy-stripped — trust the URL list as-is.
}
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
// Listed names: intersect with server access, warn about misses.
if (serverNames) {
var accessible = new Set();
var missing = [];
window.app.projectFilter.forEach(function(p) {
if (serverNames.has(p)) accessible.add(p);
else missing.push(p);
});
window.app.projectFilter = accessible;
if (missing.length > 0) showProjectWarning(missing);
}
window.app.availableProjects = Array.from(window.app.projectFilter).sort();
} else if (serverNames) {
// Empty ?projects= — scan everything the user can access.
window.app.projectFilter = new Set(serverNames);
window.app.availableProjects = Array.from(serverNames).sort();
}
// else: ?projects= empty AND no server list — leave projectFilter
// empty; source.js will fall through to in-archive mode.
}
// visibleProjects: default to projectFilter (everything visible), or
// honor an explicit ?show= from the URL (intersected with projectFilter
// to drop names that aren't in scope). An empty ?show= means "hide
// everything" — distinct from "no ?show= at all".
var showInUrl = urlParams.has('show');
if (showInUrl) {
var inScope = new Set();
(window.app.visibleProjects || new Set()).forEach(function(n) {
if (window.app.projectFilter.has(n)) inScope.add(n);
});
window.app.visibleProjects = inScope;
} else {
window.app.visibleProjects = new Set(window.app.projectFilter);
}
await addHttpSource(baseUrl);
}
// Add an HTTP source root (analogous to addDirectory() for local mode)
async function addHttpSource(baseUrl) {
// Derive a display name from the URL path
var urlPath = baseUrl.replace(/\/$/, '');
var rootName = urlPath.substring(urlPath.lastIndexOf('/') + 1) || urlPath;
// Check if already added
var exists = window.app.directories.some(function(d) { return d.url === baseUrl; });
if (exists) return;
window.app.directories.push({
handle: null,
name: rootName,
path: rootName,
url: baseUrl
});
if (window.app.directories.length === 1) {
hideEmptyState();
}
await scanHttpSource(baseUrl, rootName);
updateUI();
}
// Scan an HTTP source root
async function scanHttpSource(baseUrl, rootName) {
window.app.isScanning = true;
window.app.scanProgress = 'Connecting to server...';
updateStatusBar();
var source = window.app.modules.source.createSource('http', { baseUrl: baseUrl });
var fileCount = 0;
var callbacks = {
onGroupingFolder: function(folder) {
window.app.groupingFolders.push(folder);
},
onTransmittalFolder: function(folder) {
window.app.transmittalFolders.push(folder);
},
onFile: function(file) {
window.app.files.push(file);
fileCount++;
// Throttled progress update — don't update DOM on every file
if (fileCount % 10 === 0) {
window.app.scanProgress = 'Scanning\u2026 ' + fileCount + ' files found';
updateStatusBar();
}
},
onProgress: function() { /* no-op: parallel scan — spinner is enough */ }
};
try {
await source.scan(baseUrl, callbacks);
// Auto-select top-level party folders (shallowest depth). Keyed by
// party NAME so duplicate third-party folders across projects merge.
var groupingDepths = window.app.groupingFolders.map(function(f) { return f.path.split('/').length; });
var minGroupingDepth = groupingDepths.length > 0 ? Math.min.apply(null, groupingDepths) : 1;
window.app.groupingFolders.forEach(function(folder) {
if (folder.path.split('/').length === minGroupingDepth) {
window.app.selectedGroupingFolders.add(folder.name);
}
});
window.app.transmittalFolders.forEach(function(folder) {
if (!isUnderHiddenFolderType(folder.path)) {
window.app.selectedTransmittalFolders.add(folder.path);
}
});
ensureOutstandingTransmittal();
// Auto-select Outstanding if selectAllTransmittals is active
if (window.app.selectAllTransmittals) {
window.app.selectedTransmittalFolders.add('__outstanding__');
}
collectModifiers();
updateUI();
window.app.modules.filtering.applyFilters();
if (window.app.modules.presets) {
window.app.modules.presets.init();
}
} catch (err) {
console.error('Error scanning HTTP source:', err);
showHttpErrorState(err.message);
} finally {
window.app.isScanning = false;
window.app.scanProgress = '';
updateStatusBar();
}
}
// Ensure the Outstanding virtual transmittal exists if there are any outstanding files.
// Called after each scan completes. Idempotent — safe to call multiple times.
function ensureOutstandingTransmittal() {
const hasOutstanding = window.app.files.some(f => f.folderPath === '__outstanding__');
const alreadyExists = window.app.transmittalFolders.some(f => f.path === '__outstanding__');
if (hasOutstanding && !alreadyExists) {
window.app.transmittalFolders.push({
name: 'Outstanding',
path: '__outstanding__',
displayPath: 'Outstanding',
handle: null,
url: null,
isVirtual: true
});
}
}
// Show error state when HTTP server is unreachable
function showHttpErrorState(message) {
var el = document.getElementById('noDirectoryMessage');
if (!el) return;
var content = el.querySelector('.empty-state-content');
if (content) {
content.innerHTML =
'<h2>Could not connect to server</h2>' +
'<p>The archive browser could not retrieve the directory listing from the server.</p>' +
'<p><strong>Error:</strong> ' + escapeHtml(message || 'Unknown error') + '</p>' +
'<p>Ensure the server is running, CORS is not blocking the request, and Caddy\'s file browsing is enabled.</p>';
}
el.classList.remove('hidden');
}
// Show a warning banner listing projects in the URL filter that the user cannot access
function showProjectWarning(missingProjects) {
var el = document.getElementById('projectWarningBanner');
if (!el || missingProjects.length === 0) return;
var list = missingProjects.map(function(p) { return escapeHtml(p); }).join(', ');
el.querySelector('.project-warning-text').innerHTML =
'This link includes projects you don\'t have access to: <strong>' + list + '</strong>';
el.classList.remove('hidden');
}
function dismissProjectWarning() {
var el = document.getElementById('projectWarningBanner');
if (el) el.classList.add('hidden');
}
// Show unsupported browser message
function showUnsupportedBrowserMessage() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="empty-state">
<div class="empty-state-content">
<h2>Browser Not Supported</h2>
<p>This application requires a Chromium-based browser (Chrome, Edge, Brave) with File System Access API support.</p>
<p>Please use one of these browsers to access the Archive Browser.</p>
</div>
</div>
`;
}
// Show empty state
function showEmptyState() {
document.getElementById('noDirectoryMessage').classList.remove('hidden');
document.querySelector('.main-container').style.display = 'none';
// Keep header visible
document.querySelector('.app-header').style.display = '';
var refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) { refreshBtn.classList.add('hidden'); }
}
// Hide empty state
function hideEmptyState() {
document.getElementById('noDirectoryMessage').classList.add('hidden');
document.querySelector('.main-container').style.display = '';
var refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) { refreshBtn.classList.remove('hidden'); }
}
// Update UI based on current state
function updateUI() {
renderFolderTypeBar();
renderFolderLists();
window.app.modules.table.updateFileTable();
updateStatusBar();
}
// Render folder lists (rebuilds DOM)
function renderFolderLists() {
renderGroupingFolders();
renderTransmittalFolders();
}
// Check if a folder path is under a hidden folder type
// Returns true if any path segment is a known folder type that is NOT currently enabled
function isUnderHiddenFolderType(path) {
const parts = path.toLowerCase().split('/');
return parts.some(part =>
window.app.FOLDER_TYPE_NAMES.includes(part) && !window.app.enabledFolderTypes.has(part)
);
}
// Get filtered grouping folders (single source of truth for filtering logic)
function getFilteredGroupingFolders() {
const filter = window.app.groupingFilter;
return window.app.groupingFolders.filter(folder => {
if (isUnderHiddenFolderType(folder.path)) {
return false;
}
if (!filter) return true;
const terms = parseSearchTerms(filter);
return matchesSearchTerms(folder.name.toLowerCase(), terms);
});
}
// Render grouping folders as a flat list of unique party names. Same-named
// third-party folders across multiple projects collapse to one row.
// selectedGroupingFolders is a Set of party NAMES (not paths) so toggling
// affects every project occurrence at once.
function renderGroupingFolders() {
const container = document.getElementById('groupingFoldersList');
// Get filtered grouping folders (uses shared filtering logic)
const filteredFolders = getFilteredGroupingFolders();
// Only show top-level party folders (the shallowest depth among all grouping folders)
const allDepths = window.app.groupingFolders.map(f => f.path.split('/').length);
const minDepth = allDepths.length > 0 ? Math.min(...allDepths) : 1;
const partyFolders = filteredFolders.filter(f => f.path.split('/').length === minDepth);
// Dedupe by name (keep first occurrence per name). In multi-project mode,
// skip parties whose every occurrence is under a hidden project — if at
// least one occurrence is in a visible project, the party stays.
const seen = new Set();
const uniqueParties = [];
for (const f of partyFolders) {
if (seen.has(f.name)) continue;
if (window.app.isMultiProject) {
const hasVisible = partyFolders.some(p =>
p.name === f.name && pathIsInVisibleProject(p.path)
);
if (!hasVisible) continue;
}
seen.add(f.name);
uniqueParties.push(f);
}
uniqueParties.sort((a, b) => a.name.localeCompare(b.name));
const partyNames = new Set(uniqueParties.map(f => f.name));
// If "Select All" mode is active, auto-select all visible party names.
if (window.app.selectAllGroupingFolders) {
window.app.selectedGroupingFolders.clear();
uniqueParties.forEach(f => window.app.selectedGroupingFolders.add(f.name));
} else {
// Remove selections for names that are no longer visible.
for (const selectedName of window.app.selectedGroupingFolders) {
if (!partyNames.has(selectedName)) {
window.app.selectedGroupingFolders.delete(selectedName);
}
}
}
// Sync checkbox state
const checkbox = document.getElementById('selectAllGroupingCheckbox');
if (checkbox) checkbox.checked = window.app.selectAllGroupingFolders;
if (uniqueParties.length === 0 && window.app.groupingFilter) {
container.innerHTML = '<div class="folder-list-empty">No parties match your filter</div>';
updateFolderSelectionState('groupingFoldersList');
return;
}
container.innerHTML = uniqueParties.map(folder => `
<div class="folder-item ${window.app.selectedGroupingFolders.has(folder.name) ? 'selected' : ''}"
data-path="${escapeHtml(folder.name)}"
data-folder-type="grouping">
<span class="folder-item-name" title="${escapeHtml(folder.name)}">${escapeHtml(folder.name)}</span>
</div>
`).join('');
updateFolderSelectionState('groupingFoldersList');
}
// Render the global folder type toggle bar
function renderFolderTypeBar() {
const bar = document.getElementById('folderTypeBar');
if (!bar) return;
const FOLDER_TYPE_LABELS = { mdl: 'MDL', incoming: 'Incoming', issued: 'Issued', received: 'Received' };
bar.innerHTML = window.app.FOLDER_TYPE_NAMES.map(type => {
const active = window.app.enabledFolderTypes.has(type);
const label = FOLDER_TYPE_LABELS[type] || (type.charAt(0).toUpperCase() + type.slice(1));
return `<button class="folder-type-toggle ${active ? 'active' : ''}"
data-type="${type}"
title="Toggle ${label} folders">${label}</button>`;
}).join('');
}
// Toggle a folder type on/off globally.
// Off->on triggers a refresh because source.js skips listings for disabled folder types
// entirely (no listing fetched), so newly-enabled types need a rescan to surface their data.
function toggleFolderType(type) {
const wasEnabled = window.app.enabledFolderTypes.has(type);
if (wasEnabled) {
window.app.enabledFolderTypes.delete(type);
} else {
window.app.enabledFolderTypes.add(type);
}
renderFolderTypeBar();
renderGroupingFolders();
renderTransmittalFolders();
window.app.modules.filtering.applyFilters();
window.app.modules.urlState.push();
if (!wasEnabled && window.app.directories.length > 0) {
window.app.modules.directory.refreshDirectories();
}
}
// In multi-project mode, returns true if the path contains a segment matching
// a checked project in the picker. Single-project mode always returns true
// (no project segment to match against).
function pathIsInVisibleProject(path) {
if (!window.app.isMultiProject) return true;
if (!window.app.visibleProjects || window.app.visibleProjects.size === 0) return false;
return path.split('/').some(seg => window.app.visibleProjects.has(seg));
}
// Returns true if an outstanding file's actualPath has a path segment matching
// any selected party name and is not under a hidden folder type. Segment-equality
// (not prefix) so the same party name selected across projects matches all
// occurrences regardless of project ID prefix.
function outstandingFileIsVisible(file) {
const selectedGrouping = window.app.selectedGroupingFolders;
if (selectedGrouping.size === 0) return false;
if (isUnderHiddenFolderType(file.actualPath)) return false;
if (!pathIsInVisibleProject(file.actualPath)) return false;
return file.actualPath.split('/').some(seg => selectedGrouping.has(seg));
}
// Returns true if any outstanding (non-transmittal) files exist under the currently
// selected and visible grouping folders.
function hasVisibleOutstandingFiles() {
return window.app.files.some(function(f) {
if (f.folderPath !== '__outstanding__') return false;
return outstandingFileIsVisible(f);
});
}
// Returns true if the transmittal folder's path satisfies all three cascade
// layers: project visibility, party selection (any path segment matches a
// selected party name), and folder-type enablement (no segment is a
// folder-type marker that's currently disabled, regardless of where in the
// path it sits). Segment-equality matching means a party "BM" selected
// matches every "<...>/BM/<...>" path regardless of the prefix; and the
// folder-type check covers BOTH the canonical "<party>/Issued/<txn>" layout
// AND nested layouts like "<party>/<sub>/Issued/<txn>" — a deeper folder-
// type marker still triggers the cascade.
function transmittalIsUnderVisibleParty(folder) {
if (!pathIsInVisibleProject(folder.path)) return false;
if (isUnderHiddenFolderType(folder.path)) return false;
return folder.path.split('/').some(seg =>
window.app.selectedGroupingFolders.has(seg)
);
}
// Render transmittal folders (rebuilds DOM)
function renderTransmittalFolders() {
const container = document.getElementById('transmittalFoldersList');
const filter = window.app.transmittalFilter;
// Filter transmittal folders based on grouping selection and name filter
const filteredFolders = window.app.transmittalFolders.filter(folder => {
// Outstanding virtual transmittal: include if there are visible outstanding files
if (folder.path === '__outstanding__') {
if (!hasVisibleOutstandingFiles()) return false;
// Apply name filter to "Outstanding" label too
if (filter && filter.trim()) {
const terms = parseSearchTerms(filter.trim());
if (!matchesSearchTerms('outstanding', terms)) return false;
}
return true;
}
// Check name filter
let matchesFilter = true;
if (filter && filter.trim()) {
const terms = parseSearchTerms(filter.trim());
const folderText = folder.name.toLowerCase();
matchesFilter = matchesSearchTerms(folderText, terms);
}
// If no grouping folders exist at all, show all transmittal folders (flat structure)
if (window.app.groupingFolders.length === 0) {
return matchesFilter;
}
// If grouping folders exist but none are selected, show nothing
if (window.app.selectedGroupingFolders.size === 0) {
return false;
}
// Check party + folder type visibility
return matchesFilter && transmittalIsUnderVisibleParty(folder);
});
// Sort regular transmittal folders by date (newest first); Outstanding handled separately
const regularFolders = filteredFolders.filter(f => f.path !== '__outstanding__');
regularFolders.sort((a, b) => b.name.localeCompare(a.name));
const showOutstanding = filteredFolders.some(f => f.path === '__outstanding__');
// Build set of visible folder paths (for Select All and deselection logic)
const filteredPaths = new Set(filteredFolders.map(f => f.path));
// If "Select All" mode is active, auto-select all visible transmittal folders
if (window.app.selectAllTransmittals) {
window.app.selectedTransmittalFolders.clear();
filteredFolders.forEach(f => window.app.selectedTransmittalFolders.add(f.path));
} else {
// Remove selections for folders that are now filtered out
for (const selectedPath of window.app.selectedTransmittalFolders) {
if (!filteredPaths.has(selectedPath)) {
window.app.selectedTransmittalFolders.delete(selectedPath);
}
}
}
// Sync checkbox state
const checkbox = document.getElementById('selectAllTransmittalsCheckbox');
if (checkbox) checkbox.checked = window.app.selectAllTransmittals;
// Group regular folders by date
const foldersByDate = new Map();
regularFolders.forEach(folder => {
const match = folder.name.match(/^(\d{4}-\d{2}-\d{2})/);
const date = match ? match[1] : 'Unknown';
if (!foldersByDate.has(date)) {
foldersByDate.set(date, []);
}
foldersByDate.get(date).push(folder);
});
// Build HTML
let html = '';
// Outstanding virtual transmittal — pinned at top
if (showOutstanding) {
const isSelected = window.app.selectedTransmittalFolders.has('__outstanding__');
html += `
<div class="folder-item outstanding-transmittal ${isSelected ? 'selected' : ''}"
data-path="__outstanding__"
data-folder-type="transmittal"
title="Files in non-transmittal folders under selected grouping folders">
<div class="transmittal-folder-content">
<div class="transmittal-first-line outstanding-label">⋯ Outstanding</div>
</div>
</div>
`;
}
// Regular date-grouped folders
for (const [date, folders] of foldersByDate) {
const isCollapsed = window.app.collapsedDateGroups.has(date);
const folderCount = folders.length;
html += `
<div class="date-group-header" data-date="${escapeHtml(date)}">
<span class="date-group-toggle">${isCollapsed ? '▶' : '▼'}</span>
<span class="date-group-date">${escapeHtml(date)}</span>
<span class="date-group-count">(${folderCount})</span>
</div>
`;
if (!isCollapsed) {
for (const folder of folders) {
const match = folder.name.match(/^\d{4}-\d{2}-\d{2}_([^_\s]+)\s*\(([^)]+)\)\s*-\s*(.+)$/);
let firstLine = folder.name;
let secondLine = '';
if (match) {
const [, tracking, status, title] = match;
firstLine = `${tracking}${status}`;
secondLine = title;
}
html += `
<div class="folder-item ${window.app.selectedTransmittalFolders.has(folder.path) ? 'selected' : ''}"
data-path="${escapeHtml(folder.path)}"
data-folder-type="transmittal"
title="${escapeHtml(folder.path)}">
<div class="transmittal-folder-content">
<div class="transmittal-first-line">${escapeHtml(firstLine)}</div>
${secondLine ? `<div class="transmittal-second-line">${escapeHtml(secondLine)}</div>` : ''}
</div>
</div>
`;
}
}
}
if (filteredFolders.length === 0 && window.app.transmittalFilter) {
container.innerHTML = '<div class="folder-list-empty">No folders match your filter</div>';
updateFolderSelectionState('transmittalFoldersList');
window.app.modules.events.updateToggleAllIcon();
return;
}
container.innerHTML = html;
// Ensure selection state is visually reflected after DOM rebuild
updateFolderSelectionState('transmittalFoldersList');
// Update the toggle all icon to reflect current state
window.app.modules.events.updateToggleAllIcon();
}
// Update status bar
function updateStatusBar() {
const fileCountEl = document.getElementById('fileCount');
const selectedCountEl = document.getElementById('selectedCount');
// Before any directory is loaded, show a hint instead of "0 files"
if (window.app.directories.length === 0 && !window.app.isScanning) {
fileCountEl.textContent = 'Select a directory to begin';
selectedCountEl.textContent = '';
document.getElementById('scanStatus').textContent = '';
var spinner2 = document.getElementById('scanSpinner');
if (spinner2) spinner2.classList.add('hidden');
document.getElementById('downloadSelectedBtn').disabled = true;
document.getElementById('exportCsvBtn').disabled = true;
return;
}
// Count unique tracking numbers
const trackingNumbers = new Set(window.app.filteredFiles.map(f => f.trackingNumber));
const trackingCount = trackingNumbers.size;
const fileCount = window.app.filteredFiles.length;
// Count files with path errors
const pathErrorCount = window.app.filteredFiles.filter(f => f.hasPathError).length;
// Format: "X tracking numbers, Y files" + optional path error warning
let countText = `${trackingCount} tracking number${trackingCount !== 1 ? 's' : ''}, ${fileCount} file${fileCount !== 1 ? 's' : ''}`;
if (pathErrorCount > 0) {
countText += ` (⚠️ ${pathErrorCount} inaccessible)`;
}
fileCountEl.textContent = countText;
selectedCountEl.textContent = `${window.app.selectedFiles.size} selected`;
document.getElementById('scanStatus').textContent = window.app.scanProgress;
var spinner = document.getElementById('scanSpinner');
if (spinner) { spinner.classList.toggle('hidden', !window.app.isScanning); }
// Disable action buttons when nothing is selected
const noneSelected = window.app.selectedFiles.size === 0;
document.getElementById('downloadSelectedBtn').disabled = noneSelected;
document.getElementById('exportCsvBtn').disabled = noneSelected;
}
// Escape HTML for safe insertion
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Update folder selection visual state without rebuilding DOM
* This is more efficient than re-rendering when only selection changes
* @param {string} containerId - 'groupingFoldersList' or 'transmittalFoldersList'
*/
function updateFolderSelectionState(containerId) {
const container = document.getElementById(containerId);
if (!container) {
console.warn(`Container not found: ${containerId}`);
return;
}
const selectedSet = containerId === 'groupingFoldersList' ?
window.app.selectedGroupingFolders :
window.app.selectedTransmittalFolders;
// Update selected class on existing elements
container.querySelectorAll('.folder-item').forEach(item => {
const path = item.getAttribute('data-path');
if (path) {
item.classList.toggle('selected', selectedSet.has(path));
}
});
}
// Extract modifier type from revision string (e.g., "2+B1" -> "+B", "2" -> "base")
function getModifierType(revision) {
if (!revision) return 'base';
const match = revision.match(/\+([A-Za-z])/);
return match ? '+' + match[1].toUpperCase() : 'base';
}
// Collect all unique modifiers from files
function collectModifiers() {
window.app.availableModifiers.clear();
window.app.files.forEach(file => {
const modType = getModifierType(file.revision);
window.app.availableModifiers.add(modType);
});
// Default selection: 'base' (un-modified revisions) plus '+C' (comment
// markups against base). Other modifier types (+B, +D, …) are
// available in the dropdown but hidden by default — users opt them in
// via the Modifiers dropdown when they want to see scratch / draft /
// hold-style markups. Falls back to selecting whatever is available
// when neither default exists, so the table never goes empty out of
// the gate.
const defaults = ['base', '+C'];
const selected = new Set();
defaults.forEach(d => {
if (window.app.availableModifiers.has(d)) selected.add(d);
});
if (selected.size === 0) {
window.app.availableModifiers.forEach(m => selected.add(m));
}
window.app.selectedModifiers = selected;
// Update the dropdown UI
renderModifierDropdown();
}
// Render the modifier dropdown options
function renderModifierDropdown() {
const list = document.getElementById('modifierFilterList');
if (!list) return;
// Sort modifiers: "base" first, then alphabetically
const sorted = Array.from(window.app.availableModifiers).sort((a, b) => {
if (a === 'base') return -1;
if (b === 'base') return 1;
return a.localeCompare(b);
});
let html = '';
sorted.forEach(mod => {
const checked = window.app.selectedModifiers.has(mod) ? 'checked' : '';
const label = mod === 'base' ? 'Base (no modifier)' : mod;
const labelClass = mod === 'base' ? 'modifier-base' : 'modifier-type';
html += `
<div class="modifier-filter-item">
<label>
<input type="checkbox"
data-modifier="${mod}"
${checked}
onchange="toggleModifierFilter('${mod}')">
<span class="${labelClass}">${label}</span>
</label>
</div>
`;
});
list.innerHTML = html;
updateModifierSelectAll();
updateModifierButtonLabel();
}
// Toggle a specific modifier filter
function toggleModifierFilter(mod) {
if (window.app.selectedModifiers.has(mod)) {
window.app.selectedModifiers.delete(mod);
} else {
window.app.selectedModifiers.add(mod);
}
updateModifierSelectAll();
updateModifierButtonLabel();
window.app.modules.filtering.applyFilters();
}
// Toggle all modifiers
function toggleAllModifiers(selectAll) {
if (selectAll) {
window.app.selectedModifiers = new Set(window.app.availableModifiers);
} else {
window.app.selectedModifiers.clear();
}
renderModifierDropdown();
window.app.modules.filtering.applyFilters();
}
// Update the "Select All" checkbox state
function updateModifierSelectAll() {
const selectAllCheckbox = document.getElementById('modifierSelectAll');
if (selectAllCheckbox) {
selectAllCheckbox.checked = window.app.selectedModifiers.size === window.app.availableModifiers.size;
selectAllCheckbox.indeterminate = window.app.selectedModifiers.size > 0 &&
window.app.selectedModifiers.size < window.app.availableModifiers.size;
}
}
// Update button label to show filter status
function updateModifierButtonLabel() {
const btn = document.getElementById('modifierFilterBtn');
if (!btn) return;
const total = window.app.availableModifiers.size;
const selected = window.app.selectedModifiers.size;
if (selected === total) {
btn.textContent = 'Modifiers ▼';
} else if (selected === 0) {
btn.textContent = 'Modifiers (none) ▼';
} else {
btn.textContent = `Modifiers (${selected}/${total}) ▼`;
}
}
// Toggle modifier dropdown visibility
function toggleModifierDropdown() {
const dropdown = document.getElementById('modifierFilterDropdown');
dropdown.classList.toggle('hidden');
}
// Update the Folders icon button state based on active visibility toggles
function updateFolderVisibilityBtnLabel() {
// replaced by renderFolderTypeBar()
}
// Check if a file passes the modifier filter
function filePassesModifierFilter(file) {
const modType = getModifierType(file.revision);
return window.app.selectedModifiers.has(modType);
}
// Toggle filter to show only selected files
function toggleFilterSelected() {
window.app.showSelectedOnly = !window.app.showSelectedOnly;
// Update button visual state and label
const btn = document.getElementById('filterSelectedBtn');
if (window.app.showSelectedOnly) {
btn.classList.add('btn-active');
btn.textContent = 'Show All';
} else {
btn.classList.remove('btn-active');
btn.textContent = 'Filter Selected';
}
window.app.modules.filtering.applyFilters();
}
// Register with module system
window.app.modules.app = {
updateUI,
updateStatusBar,
escapeHtml,
updateFolderSelectionState,
getModifierType,
collectModifiers,
renderModifierDropdown,
toggleModifierFilter,
toggleAllModifiers,
updateModifierSelectAll,
updateModifierButtonLabel,
toggleModifierDropdown,
updateFolderVisibilityBtnLabel,
filePassesModifierFilter,
toggleFilterSelected,
isUnderHiddenFolderType,
ensureOutstandingTransmittal,
showHttpErrorState,
showUnsupportedBrowserMessage,
showProjectWarning,
dismissProjectWarning,
showEmptyState,
hideEmptyState,
addHttpSource,
scanHttpSource,
renderGroupingFolders,
renderTransmittalFolders,
renderFolderTypeBar,
toggleFolderType,
outstandingFileIsVisible,
hasVisibleOutstandingFiles,
transmittalIsUnderVisibleParty,
pathIsInVisibleProject,
renderFolderLists,
getFilteredGroupingFolders,
showProjectWarning,
dismissProjectWarning,
};
// Expose key functions on window for inline HTML handlers
window.initApp = initApp;
window.toggleFileSelection = function(id) { window.app.modules.table.toggleFileSelection(id); };
window.sortTable = function(f) { window.app.modules.table.sortTable(f); };
window.confirmTransmittal = function() { window.app.modules.dragDrop.confirmTransmittal(); };
window.toggleModifierFilter = toggleModifierFilter;
window.toggleFilterSelected = toggleFilterSelected;
window.toggleFolderType = toggleFolderType;
window.toggleGroupingFolder = function(p, r) { window.app.modules.events.toggleGroupingFolder(p, r); };
window.toggleDateGroup = function(d) { window.app.modules.events.toggleDateGroup(d); };
window.toggleAllDateGroups = function() { window.app.modules.events.toggleAllDateGroups(); };
window.selectAllVisibleFolders = function(t) { window.app.modules.events.selectAllVisibleFolders(t); };
window.removeDirectory = function(n) { window.app.modules.directory.removeDirectory(n); };
window.dismissProjectWarning = dismissProjectWarning;
window.verifyFileIntegrity = function(id) { window.app.modules.hash.verifyFileIntegrity(id); };
window.showProjectWarning = showProjectWarning;
window.dismissProjectWarning = dismissProjectWarning;
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', initApp);
})();
/**
* ZDDC shared help panel — open/close logic.
* Works with all four tools regardless of their module pattern.
* Expects: #help-btn, #help-panel, #help-panel-close in the DOM.
*/
(function () {
'use strict';
function init() {
var helpBtn = document.getElementById('help-btn');
var panel = document.getElementById('help-panel');
var closeBtn = document.getElementById('help-panel-close');
if (!helpBtn || !panel) { return; }
function isOpen() { return !panel.hidden; }
function openPanel() {
panel.hidden = false;
document.body.classList.add('help-open');
}
function closePanel() {
panel.hidden = true;
document.body.classList.remove('help-open');
}
helpBtn.addEventListener('click', function () {
if (isOpen()) { closePanel(); } else { openPanel(); }
});
if (closeBtn) {
closeBtn.addEventListener('click', closePanel);
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen()) { closePanel(); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}());
</script>
</body>
</html>