Add ./freshen-channel <tool> <channel> at the repo root for the
"drag alpha/beta forward to current stable" workflow. The script
uses a temporary git worktree at the latest <tool>-v* tag so the
main worktree's HEAD is never touched — no checkout, no stash, no
race against in-progress dev. Build runs inside the worktree, the
resulting <tool>_<channel>.html is copied back into the main
repo's website/releases/, worktree is removed.
The on-page label of a freshened build is `<channel> · <today> ·
<stable-tag-sha>` — the SHA pins which stable was the source, so
anyone debugging can `git checkout <sha>` to reproduce.
Smoke-tested:
./freshen-channel archive alpha → archive_alpha.html with
"alpha · 2026-04-27 · ea385b5"
./freshen-channel transmittal beta → transmittal_beta.html with
"beta · 2026-04-27 · ea385b5"
./freshen-channel foobar alpha → usage error
./freshen-channel archive stable → usage error
AGENTS.md gains a "Channel discipline (MUST rules)" subsection
codifying the protocol the build system can't enforce:
1. Stable doesn't regress — files are immutable; bump for fixes.
2. No backports — bump and let users update pins.
3. Alpha/beta are mutable — never pin in production.
4. Stale-channel rule — after every stable release, freshen alpha
and beta so neither is older than current stable. NOT optional.
5. Hotfix path — direct stable cut allowed, no beta soak required;
freshen alpha + beta after.
6. Beta soak (recommended) — a few days exposure before promoting.
Plus a "Freshen helper" subsection documenting the script.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7800 lines
260 KiB
HTML
7800 lines
260 KiB
HTML
<!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>
|
||
<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;
|
||
}
|
||
|
||
/* ── 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);
|
||
}
|
||
|
||
.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">
|
||
<div class="header-title-group">
|
||
<span class="app-header__title">ZDDC Archive</span>
|
||
<span class="build-timestamp"><span style="color:red;font-weight:bold">alpha · 2026-04-27 · ea385b5</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 -->
|
||
<label class="preview-toggle-label" title="Preview PDF, Word, and Excel files in a popup window instead of downloading">
|
||
<input type="checkbox" id="filePreviewToggle">
|
||
<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">×</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">×</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 (▶) 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 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(),
|
||
FOLDER_TYPE_NAMES: ['issued', 'received', 'mdl', 'incoming'],
|
||
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)
|
||
//
|
||
// 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);
|
||
}
|
||
|
||
// 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 scanLocalRecursive(dirHandle, dirHandle.name, 0, callbacks);
|
||
},
|
||
|
||
fetchFile: function(fileRef) {
|
||
return fileRef.handle.getFile().then(function(f) {
|
||
return f.arrayBuffer();
|
||
});
|
||
}
|
||
};
|
||
}
|
||
|
||
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 = [];
|
||
try {
|
||
for await (const entry of dirHandle.values()) {
|
||
entries.push(entry);
|
||
}
|
||
} catch (err) {
|
||
console.warn('Could not read directory ' + currentPath + ':', err);
|
||
return;
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
if (entry.kind === 'directory') {
|
||
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) || {};
|
||
|
||
if (!parsed.trackingNumber) {
|
||
console.warn('File does not match ZDDC naming convention: ' + 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) || {};
|
||
|
||
if (!parsed.trackingNumber) {
|
||
console.warn('File does not match ZDDC naming convention: ' + 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 scanHttpRecursive(scanRoot, root, 0, null, 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 scanHttpRecursive(dirUrl, rootUrl, depth, transmittalPath, callbacks) {
|
||
if (depth > 10) {
|
||
console.warn('HTTP directory depth limit reached at: ' + dirUrl);
|
||
return;
|
||
}
|
||
|
||
let items;
|
||
try {
|
||
// Caddy returns JSON when the Accept header requests it
|
||
const resp = await fetch(dirUrl, {
|
||
headers: { 'Accept': 'application/json' }
|
||
});
|
||
if (!resp.ok) {
|
||
throw new Error('HTTP ' + resp.status + ' listing ' + dirUrl);
|
||
}
|
||
// Caddy encodes listing.Items directly — a bare JSON array of fileInfo objects
|
||
// fileInfo fields: name (dirs have trailing "/"), size, url, mod_time, mode, is_dir, is_symlink
|
||
items = await resp.json();
|
||
if (!Array.isArray(items)) {
|
||
throw new Error('Unexpected response format (expected JSON array)');
|
||
}
|
||
} catch (err) {
|
||
console.warn('Could not fetch directory listing for ' + dirUrl + ':', err);
|
||
return;
|
||
}
|
||
|
||
// 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 = item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
|
||
|
||
// Skip hidden files
|
||
if (rawName.startsWith('.')) continue;
|
||
|
||
const isDir = item.is_dir === true;
|
||
// Project filter: at root depth, skip directories not in the allowed set
|
||
if (depth === 0 && isDir && window.app.projectFilter && window.app.projectFilter.size > 0) {
|
||
if (!window.app.projectFilter.has(rawName)) continue;
|
||
}
|
||
|
||
const itemUrl = resolveHttpUrl(dirUrl, rawName, isDir);
|
||
const logicalPath = urlToLogicalPath(itemUrl, rootUrl);
|
||
|
||
if (isDir) {
|
||
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) || {};
|
||
if (!parsed.trackingNumber) {
|
||
console.warn('File does not match ZDDC naming convention: ' + 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) || {};
|
||
if (!parsed.trackingNumber) {
|
||
console.warn('File does not match ZDDC naming convention: ' + 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)
|
||
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.path);
|
||
}
|
||
});
|
||
|
||
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;
|
||
}
|
||
|
||
// 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 must be within one.
|
||
// 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 = Array.from(window.app.selectedGroupingFolders).some(groupingPath =>
|
||
file.folderPath.startsWith(groupingPath + '/')
|
||
);
|
||
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;
|
||
const PREVIEW_EXTENSIONS = ['pdf', 'docx', 'xlsx', 'xls'];
|
||
const loadedLibraries = new Map();
|
||
let resizing = null;
|
||
|
||
|
||
/**
|
||
* 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('');
|
||
|
||
// 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; }
|
||
/* 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);
|
||
}
|
||
|
||
} 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';
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
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';
|
||
// Party presets for archive browser — IIFE module
|
||
|
||
// State (module scope, NOT on window.app)
|
||
let presets = [];
|
||
let activePresetName = null;
|
||
let isOpen = false;
|
||
let isNamingMode = false;
|
||
|
||
// Get localStorage key based on source mode and directory
|
||
function getStorageKey() {
|
||
if (window.app.sourceMode === 'http' && window.app.directories.length > 0) {
|
||
var u = window.app.directories[0].url || '';
|
||
return 'zddc-presets:http:' + u;
|
||
} else if (window.app.sourceMode === 'local' && window.app.directories.length > 0) {
|
||
return 'zddc-presets:local:' + window.app.directories[0].name;
|
||
}
|
||
return 'zddc-presets:default';
|
||
}
|
||
|
||
// Load presets from localStorage
|
||
function loadFromStorage() {
|
||
try {
|
||
var stored = localStorage.getItem(getStorageKey());
|
||
if (stored) {
|
||
var parsed = JSON.parse(stored);
|
||
if (parsed && Array.isArray(parsed.presets)) {
|
||
presets = parsed.presets;
|
||
} else {
|
||
presets = [];
|
||
}
|
||
} else {
|
||
presets = [];
|
||
}
|
||
} catch (e) {
|
||
presets = [];
|
||
}
|
||
}
|
||
|
||
// Save presets to localStorage
|
||
function saveToStorage() {
|
||
try {
|
||
localStorage.setItem(getStorageKey(), JSON.stringify({ presets: presets }));
|
||
} catch (e) {
|
||
// Silently fail on storage errors
|
||
}
|
||
}
|
||
|
||
// Load a preset by name
|
||
function loadPreset(name) {
|
||
var preset = presets.find(p => p.name === name);
|
||
if (!preset) return;
|
||
|
||
// Filter paths to only include folders that exist in groupingFolders
|
||
var validPaths = preset.paths.filter(p =>
|
||
window.app.groupingFolders.some(f => f.path === p)
|
||
);
|
||
|
||
window.app.selectedGroupingFolders = new Set(validPaths);
|
||
window.app.selectAllGroupingFolders = false;
|
||
|
||
var checkbox = document.getElementById('selectAllGroupingCheckbox');
|
||
if (checkbox) checkbox.checked = false;
|
||
|
||
activePresetName = name;
|
||
|
||
// Trigger UI updates
|
||
window.app.modules.app.updateFolderSelectionState('groupingFoldersList');
|
||
window.app.modules.app.renderTransmittalFolders();
|
||
window.app.modules.filtering.applyFilters();
|
||
|
||
renderButton();
|
||
renderDropdown();
|
||
}
|
||
|
||
// Save current selection as a preset
|
||
function savePreset(name) {
|
||
// Build paths array from current selection
|
||
var paths = Array.from(window.app.selectedGroupingFolders);
|
||
|
||
// Upsert preset
|
||
var existingIndex = presets.findIndex(p => p.name === name);
|
||
if (existingIndex >= 0) {
|
||
presets[existingIndex] = { name: name, paths: paths };
|
||
} else {
|
||
presets.push({ name: name, paths: paths });
|
||
}
|
||
|
||
saveToStorage();
|
||
activePresetName = name;
|
||
isNamingMode = false;
|
||
|
||
renderButton();
|
||
renderDropdown();
|
||
}
|
||
|
||
// Delete a preset by name
|
||
function deletePreset(name) {
|
||
presets = presets.filter(p => p.name !== name);
|
||
if (activePresetName === name) {
|
||
activePresetName = null;
|
||
}
|
||
saveToStorage();
|
||
renderButton();
|
||
renderDropdown();
|
||
}
|
||
|
||
// Check if current selection differs from active preset
|
||
function checkDirty() {
|
||
if (activePresetName === null) return;
|
||
|
||
var preset = presets.find(p => p.name === activePresetName);
|
||
if (!preset) return;
|
||
|
||
var currentPaths = new Set(window.app.selectedGroupingFolders);
|
||
var presetPaths = new Set(preset.paths || []);
|
||
|
||
// Compare sets
|
||
var dirty = currentPaths.size !== presetPaths.size ||
|
||
!Array.from(currentPaths).every(p => presetPaths.has(p));
|
||
|
||
if (dirty) {
|
||
renderButton();
|
||
}
|
||
}
|
||
|
||
// Get minimum depth of grouping folders (for top-level Only)
|
||
function getMinDepth() {
|
||
if (window.app.groupingFolders.length === 0) return 1;
|
||
return Math.min.apply(null, window.app.groupingFolders.map(f => f.path.split('/').length));
|
||
}
|
||
|
||
// Render the preset button label
|
||
function renderButton() {
|
||
var btn = document.getElementById('presetBtn');
|
||
if (!btn) return;
|
||
|
||
if (activePresetName !== null) {
|
||
// Check if dirty
|
||
var preset = presets.find(p => p.name === activePresetName);
|
||
var dirty = false;
|
||
if (preset) {
|
||
var currentPaths = new Set(window.app.selectedGroupingFolders);
|
||
var presetPaths = new Set(preset.paths || []);
|
||
dirty = currentPaths.size !== presetPaths.size ||
|
||
!Array.from(currentPaths).every(p => presetPaths.has(p));
|
||
}
|
||
btn.textContent = '▾ ' + activePresetName + (dirty ? '*' : '');
|
||
} else {
|
||
btn.textContent = '▾ Presets';
|
||
}
|
||
}
|
||
|
||
// Escape HTML for safe insertion
|
||
function escapeHtml(text) {
|
||
var div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Render the dropdown panel
|
||
function renderDropdown() {
|
||
var dropdown = document.getElementById('presetDropdown');
|
||
if (!dropdown) return;
|
||
|
||
var minDepth = getMinDepth();
|
||
|
||
// Build presets list HTML
|
||
var presetsHtml = '';
|
||
if (presets.length === 0) {
|
||
presetsHtml = '<div class="preset-no-presets"><i>No saved presets</i></div>';
|
||
} else {
|
||
presetsHtml = presets.map(preset => {
|
||
var escapedName = escapeHtml(preset.name);
|
||
return (
|
||
'<div class="preset-item" data-name="' + escapedName + '">' +
|
||
'<span>' + escapedName + '</span>' +
|
||
'<button class="preset-delete" data-name="' + escapedName + '">×</button>' +
|
||
'</div>'
|
||
);
|
||
}).join('');
|
||
}
|
||
|
||
// Build project checkboxes HTML
|
||
var projectsHtml = '';
|
||
window.app.groupingFolders.forEach(folder => {
|
||
// Only include top-level folders (minDepth)
|
||
var pathParts = folder.path.split('/');
|
||
if (pathParts.length !== minDepth) return;
|
||
|
||
var isSelected = window.app.selectedGroupingFolders.has(folder.path);
|
||
var escapedPath = escapeHtml(folder.path);
|
||
var escapedName = escapeHtml(folder.name);
|
||
|
||
projectsHtml += (
|
||
'<div class="preset-project-item">' +
|
||
'<label class="preset-project-label">' +
|
||
'<input type="checkbox" class="preset-checkbox" data-path="' + escapedPath + '"' +
|
||
(isSelected ? ' checked' : '') + '>' +
|
||
' ' + escapedName +
|
||
'</label>' +
|
||
'</div>'
|
||
);
|
||
});
|
||
|
||
// Footer HTML
|
||
var footerHtml = '';
|
||
if (activePresetName !== null) {
|
||
// Check if dirty
|
||
var preset = presets.find(p => p.name === activePresetName);
|
||
var dirty = false;
|
||
if (preset) {
|
||
var currentPaths = new Set(window.app.selectedGroupingFolders);
|
||
var presetPaths = new Set(preset.paths || []);
|
||
dirty = currentPaths.size !== presetPaths.size ||
|
||
!Array.from(currentPaths).every(p => presetPaths.has(p));
|
||
}
|
||
|
||
if (isNamingMode) {
|
||
footerHtml = (
|
||
'<div class="preset-footer-naming">' +
|
||
'<input type="text" class="preset-name-input" placeholder="Preset name" autoFocus>' +
|
||
'<button class="preset-confirm-name btn btn-sm">✓</button>' +
|
||
'<button class="preset-cancel-name btn btn-sm">✗</button>' +
|
||
'</div>'
|
||
);
|
||
} else if (dirty) {
|
||
footerHtml = (
|
||
'<div class="preset-footer-actions">' +
|
||
'<button class="preset-update-btn btn btn-primary btn-sm">Update "' + escapeHtml(activePresetName) + '"</button>' +
|
||
'<button class="preset-save-new-btn btn btn-secondary btn-sm">Save as New</button>' +
|
||
'</div>'
|
||
);
|
||
} else {
|
||
footerHtml = (
|
||
'<div class="preset-footer-actions">' +
|
||
'<button class="preset-save-new-btn btn btn-primary btn-sm">Save as New</button>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
} else {
|
||
// No active preset — disabled if nothing selected
|
||
var selectedCount = window.app.selectedGroupingFolders.size;
|
||
var disabledAttr = selectedCount === 0 ? ' disabled' : '';
|
||
footerHtml = (
|
||
'<div class="preset-footer-actions">' +
|
||
'<button class="preset-save-btn btn btn-primary btn-sm' + (selectedCount === 0 ? ' btn-disabled' : '') + '" ' +
|
||
'data-disabled="' + (selectedCount === 0 ? 'true' : 'false') + '">Save as Preset</button>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
dropdown.innerHTML = (
|
||
'<div class="preset-section-top">' +
|
||
'<div class="preset-section-label">Saved Presets:</div>' +
|
||
'<div class="preset-list">' + presetsHtml + '</div>' +
|
||
'</div>' +
|
||
'<div class="preset-divider"></div>' +
|
||
'<div class="preset-section-bottom">' +
|
||
'<div class="preset-section-label">Projects:</div>' +
|
||
'<div class="preset-projects-list">' + projectsHtml + '</div>' +
|
||
'</div>' +
|
||
footerHtml
|
||
);
|
||
}
|
||
|
||
// Toggle dropdown visibility
|
||
function toggleDropdown() {
|
||
var dropdown = document.getElementById('presetDropdown');
|
||
if (isOpen) {
|
||
closeDropdown();
|
||
} else {
|
||
isOpen = true;
|
||
if (dropdown) dropdown.classList.remove('hidden');
|
||
renderDropdown();
|
||
}
|
||
}
|
||
|
||
// Close dropdown
|
||
function closeDropdown() {
|
||
isOpen = false;
|
||
var dropdown = document.getElementById('presetDropdown');
|
||
if (dropdown) dropdown.classList.add('hidden');
|
||
isNamingMode = false;
|
||
}
|
||
|
||
// Set up event delegation on dropdown
|
||
function setupDropdownDelegation() {
|
||
var dropdown = document.getElementById('presetDropdown');
|
||
if (!dropdown) return;
|
||
|
||
dropdown.addEventListener('click', function(e) {
|
||
// Close on clicks inside dropdown
|
||
e.stopPropagation();
|
||
|
||
// Preset item click — load preset (do NOT close dropdown)
|
||
var presetItem = e.target.closest('.preset-item');
|
||
if (presetItem && !e.target.classList.contains('preset-delete')) {
|
||
var name = presetItem.getAttribute('data-name');
|
||
if (name) loadPreset(name);
|
||
return;
|
||
}
|
||
|
||
// Delete button
|
||
var deleteBtn = e.target.closest('.preset-delete');
|
||
if (deleteBtn) {
|
||
e.stopPropagation();
|
||
var name = deleteBtn.getAttribute('data-name');
|
||
if (name) deletePreset(name);
|
||
return;
|
||
}
|
||
|
||
// Checkbox click
|
||
var checkbox = e.target.closest('.preset-checkbox');
|
||
if (checkbox) {
|
||
var path = checkbox.getAttribute('data-path');
|
||
if (path) {
|
||
if (checkbox.checked) {
|
||
window.app.selectedGroupingFolders.add(path);
|
||
} else {
|
||
window.app.selectedGroupingFolders.delete(path);
|
||
}
|
||
window.app.modules.app.updateFolderSelectionState('groupingFoldersList');
|
||
window.app.modules.app.renderTransmittalFolders();
|
||
window.app.modules.filtering.applyFilters();
|
||
checkDirty();
|
||
renderButton();
|
||
renderDropdown(); // Re-render to update checkbox states and footer
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Save button (not in naming mode)
|
||
var saveBtn = e.target.closest('.preset-save-btn');
|
||
if (saveBtn && !isNamingMode) {
|
||
if (saveBtn.getAttribute('data-disabled') !== 'true') {
|
||
isNamingMode = true;
|
||
renderDropdown();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Update button — save current selection as active preset
|
||
var updateBtn = e.target.closest('.preset-update-btn');
|
||
if (updateBtn) {
|
||
if (activePresetName) savePreset(activePresetName);
|
||
return;
|
||
}
|
||
|
||
// Save as New button
|
||
var saveNewBtn = e.target.closest('.preset-save-new-btn');
|
||
if (saveNewBtn) {
|
||
isNamingMode = true;
|
||
renderDropdown();
|
||
return;
|
||
}
|
||
|
||
// Confirm name input
|
||
var confirmBtn = e.target.closest('.preset-confirm-name');
|
||
if (confirmBtn) {
|
||
var input = dropdown.querySelector('.preset-name-input');
|
||
if (input && input.value.trim()) {
|
||
savePreset(input.value.trim());
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Cancel name input
|
||
var cancelBtn = e.target.closest('.preset-cancel-name');
|
||
if (cancelBtn) {
|
||
isNamingMode = false;
|
||
renderDropdown();
|
||
return;
|
||
}
|
||
});
|
||
|
||
// Keydown on name input
|
||
dropdown.addEventListener('keydown', function(e) {
|
||
var input = e.target.closest('.preset-name-input');
|
||
if (!input) return;
|
||
|
||
if (e.key === 'Enter') {
|
||
e.stopPropagation();
|
||
if (input.value.trim()) {
|
||
savePreset(input.value.trim());
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
e.stopPropagation();
|
||
isNamingMode = false;
|
||
renderDropdown();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Handle outside click to close dropdown
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Initialize presets module — called after first scan completes
|
||
function init() {
|
||
// Idempotent: skip if button listener already attached
|
||
var btn = document.getElementById('presetBtn');
|
||
if (!btn || btn.dataset.presetInit) return;
|
||
btn.dataset.presetInit = '1';
|
||
|
||
btn.addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
toggleDropdown();
|
||
});
|
||
|
||
setupDropdownDelegation();
|
||
setupOutsideClickHandler();
|
||
loadFromStorage();
|
||
renderButton();
|
||
}
|
||
|
||
// Register module
|
||
window.app.modules.presets = {
|
||
init: init,
|
||
loadPreset: loadPreset,
|
||
savePreset: savePreset,
|
||
deletePreset: deletePreset,
|
||
checkDirty: checkDirty,
|
||
renderButton: renderButton,
|
||
toggleDropdown: toggleDropdown,
|
||
closeDropdown: closeDropdown
|
||
};
|
||
|
||
})();
|
||
|
||
(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'
|
||
};
|
||
|
||
// 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(','));
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// 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 + '/';
|
||
|
||
// Check for projects that are in the URL filter but not accessible on the server
|
||
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
|
||
try {
|
||
var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } });
|
||
if (resp.ok) {
|
||
var serverProjects = await resp.json();
|
||
var accessibleNames = new Set(serverProjects.map(function(p) { return p.name; }));
|
||
var missing = Array.from(window.app.projectFilter).filter(function(p) {
|
||
return !accessibleNames.has(p);
|
||
});
|
||
if (missing.length > 0) {
|
||
showProjectWarning(missing);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// Silently ignore — server may not support the project list API
|
||
}
|
||
}
|
||
|
||
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)
|
||
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.path);
|
||
}
|
||
});
|
||
|
||
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 party names (depth 1 only)
|
||
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);
|
||
|
||
// Sort alphabetically
|
||
partyFolders.sort((a, b) => a.path.localeCompare(b.path));
|
||
|
||
// Build set of paths for quick lookup
|
||
const partyPaths = new Set(partyFolders.map(f => f.path));
|
||
|
||
// If "Select All" mode is active, auto-select all visible party folders
|
||
if (window.app.selectAllGroupingFolders) {
|
||
window.app.selectedGroupingFolders.clear();
|
||
partyFolders.forEach(f => window.app.selectedGroupingFolders.add(f.path));
|
||
} else {
|
||
// Remove selections for folders that are no longer visible
|
||
for (const selectedPath of window.app.selectedGroupingFolders) {
|
||
if (!partyPaths.has(selectedPath)) {
|
||
window.app.selectedGroupingFolders.delete(selectedPath);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Sync checkbox state
|
||
const checkbox = document.getElementById('selectAllGroupingCheckbox');
|
||
if (checkbox) checkbox.checked = window.app.selectAllGroupingFolders;
|
||
|
||
if (partyFolders.length === 0 && window.app.groupingFilter) {
|
||
container.innerHTML = '<div class="folder-list-empty">No parties match your filter</div>';
|
||
updateFolderSelectionState('groupingFoldersList');
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = partyFolders.map(folder => `
|
||
<div class="folder-item ${window.app.selectedGroupingFolders.has(folder.path) ? 'selected' : ''}"
|
||
data-path="${escapeHtml(folder.path)}"
|
||
data-folder-type="grouping">
|
||
<span class="folder-item-name" title="${escapeHtml(folder.path)}">${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
|
||
function toggleFolderType(type) {
|
||
if (window.app.enabledFolderTypes.has(type)) {
|
||
window.app.enabledFolderTypes.delete(type);
|
||
} else {
|
||
window.app.enabledFolderTypes.add(type);
|
||
}
|
||
renderFolderTypeBar();
|
||
renderGroupingFolders();
|
||
renderTransmittalFolders();
|
||
window.app.modules.filtering.applyFilters();
|
||
window.app.modules.urlState.push();
|
||
}
|
||
|
||
// Returns true if an outstanding file's actualPath is under a selected grouping folder
|
||
// that is itself visible (not hidden by folder type toggles).
|
||
function outstandingFileIsVisible(file) {
|
||
const selectedGrouping = window.app.selectedGroupingFolders;
|
||
if (selectedGrouping.size === 0) return false;
|
||
// The actualPath must not be under a hidden folder type
|
||
if (isUnderHiddenFolderType(file.actualPath)) return false;
|
||
// The actualPath must be at or under one of the selected grouping folder paths
|
||
return Array.from(selectedGrouping).some(function(gPath) {
|
||
return file.actualPath === gPath || file.actualPath.startsWith(gPath + '/');
|
||
});
|
||
}
|
||
|
||
// 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 a transmittal folder is under a selected party and an enabled folder type.
|
||
// Handles both HTTP paths (party at depth 0) and local paths (party at depth 1+ due to root dir prefix).
|
||
function transmittalIsUnderVisibleParty(folder) {
|
||
const parts = folder.path.split('/');
|
||
|
||
// Find which segment is the party (the one that matches a selected grouping folder path prefix).
|
||
// The party path is the selected grouping folder path, so check prefix matches.
|
||
for (const partyPath of window.app.selectedGroupingFolders) {
|
||
const partyParts = partyPath.split('/');
|
||
const partyDepth = partyParts.length; // e.g. 1 for HTTP ("ACME"), 2 for local ("RootDir/ACME")
|
||
|
||
// Check that folder path starts with partyPath
|
||
if (!folder.path.startsWith(partyPath + '/') && folder.path !== partyPath) continue;
|
||
|
||
// The segment immediately after partyPath is either a folder type or the transmittal itself
|
||
const remainder = folder.path.substring(partyPath.length + 1); // e.g. "Issued/2025-01-01_..." or "2025-01-01_..."
|
||
const remainderParts = remainder.split('/');
|
||
|
||
if (remainderParts.length >= 2) {
|
||
// There's a folder type segment before the transmittal
|
||
const folderType = remainderParts[0].toLowerCase();
|
||
if (window.app.FOLDER_TYPE_NAMES.includes(folderType)) {
|
||
// Must be an enabled type
|
||
return window.app.enabledFolderTypes.has(folderType);
|
||
}
|
||
// Unknown folder type — treat as visible
|
||
return true;
|
||
}
|
||
|
||
// Transmittal is directly under the party (no folder type level) — always show
|
||
return true;
|
||
}
|
||
|
||
// Party not selected
|
||
return false;
|
||
}
|
||
|
||
// 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);
|
||
});
|
||
|
||
// Select all by default
|
||
window.app.selectedModifiers = new Set(window.app.availableModifiers);
|
||
|
||
// 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,
|
||
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>
|