diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index 1985456..c11b4f2 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -814,23 +814,75 @@ body.help-open .app-header { with tool-local .toast classes; the old classifier rules can stay alongside until this file is concatenated above them in the build. */ -.zddc-toast { +/* Toast STACK — bottom-right, newest at the bottom. The container is + click-through (pointer-events:none) so the gaps don't block the page; each + toast + button re-enables pointer events. */ +.zddc-toasts { position: fixed; - bottom: 2rem; - right: 2rem; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9000; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + max-height: calc(100vh - 3rem); + overflow-y: auto; + pointer-events: none; +} + +/* "Clear all" — shown above the stack when 2+ toasts are present. */ +.zddc-toasts__clear { + pointer-events: auto; + align-self: flex-end; background: var(--bg); color: var(--text); - padding: 0.875rem 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.2rem 0.6rem; + font-size: 0.78rem; + cursor: pointer; +} +.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); } + +.zddc-toast { + position: relative; + pointer-events: auto; + background: var(--bg); + color: var(--text); + padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */ border-radius: var(--radius); border: 1px solid var(--border); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 9000; - max-width: 400px; + max-width: 420px; font-size: 0.875rem; - cursor: pointer; animation: zddc-toast-in 0.3s ease-out; } +/* Message text — selectable + copyable; long/multi-line errors wrap. */ +.zddc-toast__msg { + user-select: text; + -webkit-user-select: text; + cursor: text; + white-space: pre-wrap; + word-break: break-word; +} + +/* Per-toast dismiss. */ +.zddc-toast__close { + position: absolute; + top: 0.2rem; + right: 0.35rem; + border: none; + background: transparent; + color: var(--text-muted, #888); + font-size: 1.15rem; + line-height: 1; + cursor: pointer; + padding: 0 0.15rem; +} +.zddc-toast__close:hover { color: var(--text); } + .zddc-toast--success { border-left: 4px solid var(--success); } .zddc-toast--error { border-left: 4px solid var(--danger); } .zddc-toast--info { border-left: 4px solid var(--info); } @@ -2665,7 +2717,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.27-beta · 2026-06-09 15:30:13 · 237c353 + v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
@@ -5037,74 +5089,122 @@ X.B(E,Y);return E}return J}()) }()); // shared/toast.js — non-blocking notification helper available to every -// tool via window.zddc.toast(msg, level, opts). Originated as classifier's -// local showToast (classifier/js/excel.js); promoted here so tools that -// today use alert() or silent console.error can switch to a uniform -// non-blocking surface. +// tool via window.zddc.toast(msg, level, opts). +// +// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY — +// they stay until the user dismisses them (per-toast × or a "Clear all" +// button) so the message can be read, selected, and copied while +// troubleshooting. info/success toasts auto-dismiss. The message text is +// always selectable. // // Usage: -// window.zddc.toast('Saved.', 'success'); -// window.zddc.toast('Could not load: ' + err.message, 'error'); +// window.zddc.toast('Saved.', 'success'); // auto-dismiss +// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky // window.zddc.toast('Note', 'info', { durationMs: 3000 }); +// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky // // Levels: 'info' (default) | 'success' | 'warning' | 'error'. -// Each tool may also expose app.notify(msg, level) as a thin wrapper — -// see ARCHITECTURE.md for the convention. (function () { 'use strict'; if (!window.zddc) window.zddc = {}; - // Don't overwrite if a tool defined its own first. if (typeof window.zddc.toast === 'function') return; var DEFAULT_DURATION_MS = 5000; var FADE_MS = 300; + // Levels that persist until the user dismisses them (troubleshooting). + var STICKY = { error: true, warning: true }; + + function container() { + var c = document.getElementById('zddc-toasts'); + if (c) return c; + c = document.createElement('div'); + c.id = 'zddc-toasts'; + c.className = 'zddc-toasts'; + document.body.appendChild(c); + return c; + } + + // Show/hide a "Clear all" control when 2+ toasts are stacked. + function refreshClearAll(c) { + var bar = c.querySelector('.zddc-toasts__clear'); + var count = c.querySelectorAll('.zddc-toast').length; + if (count >= 2) { + if (!bar) { + bar = document.createElement('button'); + bar.type = 'button'; + bar.className = 'zddc-toasts__clear'; + bar.textContent = 'Clear all'; + bar.addEventListener('click', function () { + var all = c.querySelectorAll('.zddc-toast'); + for (var i = 0; i < all.length; i++) dismiss(all[i]); + }); + c.insertBefore(bar, c.firstChild); + } + } else if (bar) { + bar.remove(); + } + } + + function dismiss(el) { + if (el._dismissed) return; + el._dismissed = true; + if (el._timer) clearTimeout(el._timer); + el.classList.add('zddc-toast--fade'); + setTimeout(function () { + if (el.parentNode) el.parentNode.removeChild(el); + refreshClearAll(container()); + }, FADE_MS); + } function toast(message, level, opts) { opts = opts || {}; var lvl = (level === 'success' || level === 'error' || level === 'warning') ? level : 'info'; - - // Single-toast policy: dismiss any existing toast immediately - // so the new one is always the most recent. Matches the - // classifier's prior behavior and avoids stack-of-toasts UX. - var existing = document.querySelector('.zddc-toast'); - if (existing) existing.remove(); + var c = container(); var el = document.createElement('div'); el.className = 'zddc-toast zddc-toast--' + lvl; // ARIA: errors get assertive (interrupts SR queue), others polite. el.setAttribute('role', lvl === 'error' ? 'alert' : 'status'); el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite'); - el.textContent = message == null ? '' : String(message); - document.body.appendChild(el); - var dur = typeof opts.durationMs === 'number' ? - opts.durationMs : DEFAULT_DURATION_MS; - var timer = setTimeout(function () { - el.classList.add('zddc-toast--fade'); - setTimeout(function () { - if (el.parentNode) el.parentNode.removeChild(el); - }, FADE_MS); - }, dur); + // Selectable, copyable message text (its own element so clicking to + // select doesn't dismiss the toast — only the × does). + var msg = document.createElement('span'); + msg.className = 'zddc-toast__msg'; + msg.textContent = message == null ? '' : String(message); + el.appendChild(msg); - // Click-to-dismiss. Useful for sticky errors the user wants gone. - el.addEventListener('click', function () { - clearTimeout(timer); - if (el.parentNode) el.parentNode.removeChild(el); - }); + var close = document.createElement('button'); + close.type = 'button'; + close.className = 'zddc-toast__close'; + close.setAttribute('aria-label', 'Dismiss'); + close.textContent = '×'; + close.addEventListener('click', function () { dismiss(el); }); + el.appendChild(close); + c.appendChild(el); + + // Sticky (error/warning, or opts.durationMs === 0) persists; otherwise + // auto-dismiss after the (overridable) duration. + var sticky = opts.durationMs === 0 || + (typeof opts.durationMs !== 'number' && STICKY[lvl]); + if (!sticky) { + var dur = typeof opts.durationMs === 'number' + ? opts.durationMs : DEFAULT_DURATION_MS; + el._timer = setTimeout(function () { dismiss(el); }, dur); + } + + refreshClearAll(c); return el; } window.zddc.toast = toast; - // Route window.alert() calls into the toast helper. Every tool has - // accumulated some `alert(...)` sites for error reporting; rather - // than touch each one, intercept globally so they're non-blocking - // and ARIA-announced consistently. Native alert is preserved on - // window.alertNative for the rare case where a truly modal block - // is needed (e.g. before navigating away with unsaved changes). + // Route window.alert() into the toast helper (non-blocking, ARIA-announced, + // consistent). Native alert preserved on window.alertNative. alert() maps + // to an error toast (sticky) since that's its usual purpose. if (typeof window.alert === 'function' && !window.alertNative) { window.alertNative = window.alert.bind(window); window.alert = function (msg) { diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index f1aa614..a4d5837 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -814,23 +814,75 @@ body.help-open .app-header { with tool-local .toast classes; the old classifier rules can stay alongside until this file is concatenated above them in the build. */ -.zddc-toast { +/* Toast STACK — bottom-right, newest at the bottom. The container is + click-through (pointer-events:none) so the gaps don't block the page; each + toast + button re-enables pointer events. */ +.zddc-toasts { position: fixed; - bottom: 2rem; - right: 2rem; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9000; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + max-height: calc(100vh - 3rem); + overflow-y: auto; + pointer-events: none; +} + +/* "Clear all" — shown above the stack when 2+ toasts are present. */ +.zddc-toasts__clear { + pointer-events: auto; + align-self: flex-end; background: var(--bg); color: var(--text); - padding: 0.875rem 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.2rem 0.6rem; + font-size: 0.78rem; + cursor: pointer; +} +.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); } + +.zddc-toast { + position: relative; + pointer-events: auto; + background: var(--bg); + color: var(--text); + padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */ border-radius: var(--radius); border: 1px solid var(--border); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 9000; - max-width: 400px; + max-width: 420px; font-size: 0.875rem; - cursor: pointer; animation: zddc-toast-in 0.3s ease-out; } +/* Message text — selectable + copyable; long/multi-line errors wrap. */ +.zddc-toast__msg { + user-select: text; + -webkit-user-select: text; + cursor: text; + white-space: pre-wrap; + word-break: break-word; +} + +/* Per-toast dismiss. */ +.zddc-toast__close { + position: absolute; + top: 0.2rem; + right: 0.35rem; + border: none; + background: transparent; + color: var(--text-muted, #888); + font-size: 1.15rem; + line-height: 1; + cursor: pointer; + padding: 0 0.15rem; +} +.zddc-toast__close:hover { color: var(--text); } + .zddc-toast--success { border-left: 4px solid var(--success); } .zddc-toast--error { border-left: 4px solid var(--danger); } .zddc-toast--info { border-left: 4px solid var(--info); } @@ -2772,7 +2824,7 @@ li.CodeMirror-hint-active {
ZDDC Browse - v0.0.27-beta · 2026-06-09 15:30:14 · 237c353 + v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
@@ -5173,74 +5225,122 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr }()); // shared/toast.js — non-blocking notification helper available to every -// tool via window.zddc.toast(msg, level, opts). Originated as classifier's -// local showToast (classifier/js/excel.js); promoted here so tools that -// today use alert() or silent console.error can switch to a uniform -// non-blocking surface. +// tool via window.zddc.toast(msg, level, opts). +// +// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY — +// they stay until the user dismisses them (per-toast × or a "Clear all" +// button) so the message can be read, selected, and copied while +// troubleshooting. info/success toasts auto-dismiss. The message text is +// always selectable. // // Usage: -// window.zddc.toast('Saved.', 'success'); -// window.zddc.toast('Could not load: ' + err.message, 'error'); +// window.zddc.toast('Saved.', 'success'); // auto-dismiss +// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky // window.zddc.toast('Note', 'info', { durationMs: 3000 }); +// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky // // Levels: 'info' (default) | 'success' | 'warning' | 'error'. -// Each tool may also expose app.notify(msg, level) as a thin wrapper — -// see ARCHITECTURE.md for the convention. (function () { 'use strict'; if (!window.zddc) window.zddc = {}; - // Don't overwrite if a tool defined its own first. if (typeof window.zddc.toast === 'function') return; var DEFAULT_DURATION_MS = 5000; var FADE_MS = 300; + // Levels that persist until the user dismisses them (troubleshooting). + var STICKY = { error: true, warning: true }; + + function container() { + var c = document.getElementById('zddc-toasts'); + if (c) return c; + c = document.createElement('div'); + c.id = 'zddc-toasts'; + c.className = 'zddc-toasts'; + document.body.appendChild(c); + return c; + } + + // Show/hide a "Clear all" control when 2+ toasts are stacked. + function refreshClearAll(c) { + var bar = c.querySelector('.zddc-toasts__clear'); + var count = c.querySelectorAll('.zddc-toast').length; + if (count >= 2) { + if (!bar) { + bar = document.createElement('button'); + bar.type = 'button'; + bar.className = 'zddc-toasts__clear'; + bar.textContent = 'Clear all'; + bar.addEventListener('click', function () { + var all = c.querySelectorAll('.zddc-toast'); + for (var i = 0; i < all.length; i++) dismiss(all[i]); + }); + c.insertBefore(bar, c.firstChild); + } + } else if (bar) { + bar.remove(); + } + } + + function dismiss(el) { + if (el._dismissed) return; + el._dismissed = true; + if (el._timer) clearTimeout(el._timer); + el.classList.add('zddc-toast--fade'); + setTimeout(function () { + if (el.parentNode) el.parentNode.removeChild(el); + refreshClearAll(container()); + }, FADE_MS); + } function toast(message, level, opts) { opts = opts || {}; var lvl = (level === 'success' || level === 'error' || level === 'warning') ? level : 'info'; - - // Single-toast policy: dismiss any existing toast immediately - // so the new one is always the most recent. Matches the - // classifier's prior behavior and avoids stack-of-toasts UX. - var existing = document.querySelector('.zddc-toast'); - if (existing) existing.remove(); + var c = container(); var el = document.createElement('div'); el.className = 'zddc-toast zddc-toast--' + lvl; // ARIA: errors get assertive (interrupts SR queue), others polite. el.setAttribute('role', lvl === 'error' ? 'alert' : 'status'); el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite'); - el.textContent = message == null ? '' : String(message); - document.body.appendChild(el); - var dur = typeof opts.durationMs === 'number' ? - opts.durationMs : DEFAULT_DURATION_MS; - var timer = setTimeout(function () { - el.classList.add('zddc-toast--fade'); - setTimeout(function () { - if (el.parentNode) el.parentNode.removeChild(el); - }, FADE_MS); - }, dur); + // Selectable, copyable message text (its own element so clicking to + // select doesn't dismiss the toast — only the × does). + var msg = document.createElement('span'); + msg.className = 'zddc-toast__msg'; + msg.textContent = message == null ? '' : String(message); + el.appendChild(msg); - // Click-to-dismiss. Useful for sticky errors the user wants gone. - el.addEventListener('click', function () { - clearTimeout(timer); - if (el.parentNode) el.parentNode.removeChild(el); - }); + var close = document.createElement('button'); + close.type = 'button'; + close.className = 'zddc-toast__close'; + close.setAttribute('aria-label', 'Dismiss'); + close.textContent = '×'; + close.addEventListener('click', function () { dismiss(el); }); + el.appendChild(close); + c.appendChild(el); + + // Sticky (error/warning, or opts.durationMs === 0) persists; otherwise + // auto-dismiss after the (overridable) duration. + var sticky = opts.durationMs === 0 || + (typeof opts.durationMs !== 'number' && STICKY[lvl]); + if (!sticky) { + var dur = typeof opts.durationMs === 'number' + ? opts.durationMs : DEFAULT_DURATION_MS; + el._timer = setTimeout(function () { dismiss(el); }, dur); + } + + refreshClearAll(c); return el; } window.zddc.toast = toast; - // Route window.alert() calls into the toast helper. Every tool has - // accumulated some `alert(...)` sites for error reporting; rather - // than touch each one, intercept globally so they're non-blocking - // and ARIA-announced consistently. Native alert is preserved on - // window.alertNative for the rare case where a truly modal block - // is needed (e.g. before navigating away with unsaved changes). + // Route window.alert() into the toast helper (non-blocking, ARIA-announced, + // consistent). Native alert preserved on window.alertNative. alert() maps + // to an error toast (sticky) since that's its usual purpose. if (typeof window.alert === 'function' && !window.alertNative) { window.alertNative = window.alert.bind(window); window.alert = function (msg) { diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index 825dcda..c3dd32b 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -814,23 +814,75 @@ body.help-open .app-header { with tool-local .toast classes; the old classifier rules can stay alongside until this file is concatenated above them in the build. */ -.zddc-toast { +/* Toast STACK — bottom-right, newest at the bottom. The container is + click-through (pointer-events:none) so the gaps don't block the page; each + toast + button re-enables pointer events. */ +.zddc-toasts { position: fixed; - bottom: 2rem; - right: 2rem; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9000; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + max-height: calc(100vh - 3rem); + overflow-y: auto; + pointer-events: none; +} + +/* "Clear all" — shown above the stack when 2+ toasts are present. */ +.zddc-toasts__clear { + pointer-events: auto; + align-self: flex-end; background: var(--bg); color: var(--text); - padding: 0.875rem 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.2rem 0.6rem; + font-size: 0.78rem; + cursor: pointer; +} +.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); } + +.zddc-toast { + position: relative; + pointer-events: auto; + background: var(--bg); + color: var(--text); + padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */ border-radius: var(--radius); border: 1px solid var(--border); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 9000; - max-width: 400px; + max-width: 420px; font-size: 0.875rem; - cursor: pointer; animation: zddc-toast-in 0.3s ease-out; } +/* Message text — selectable + copyable; long/multi-line errors wrap. */ +.zddc-toast__msg { + user-select: text; + -webkit-user-select: text; + cursor: text; + white-space: pre-wrap; + word-break: break-word; +} + +/* Per-toast dismiss. */ +.zddc-toast__close { + position: absolute; + top: 0.2rem; + right: 0.35rem; + border: none; + background: transparent; + color: var(--text-muted, #888); + font-size: 1.15rem; + line-height: 1; + cursor: pointer; + padding: 0 0.15rem; +} +.zddc-toast__close:hover { color: var(--text); } + .zddc-toast--success { border-left: 4px solid var(--success); } .zddc-toast--error { border-left: 4px solid var(--danger); } .zddc-toast--info { border-left: 4px solid var(--info); } @@ -1194,6 +1246,8 @@ body.is-elevated::after { } .folder-tree-pane.collapsed .pane-header-controls, +.folder-tree-pane.collapsed .classify-filters, +.folder-tree-pane.collapsed .tree-filter, .folder-tree-pane.collapsed .folder-tree, .folder-tree-pane.collapsed .pane-header h3 { display: none; @@ -1259,11 +1313,30 @@ body.is-elevated::after { .pane-header-controls { display: flex; - flex-direction: column; - gap: 0.5rem; - align-items: flex-end; + flex-wrap: wrap; + gap: 0.3rem 0.75rem; + align-items: center; + justify-content: flex-end; } +/* Classify-mode filter row, laid out as a toolbar under the pane header. */ +.tree-toolbar { + display: flex; flex-wrap: wrap; align-items: center; + gap: 0.2rem 0.7rem; padding: 0.3rem 1rem; + border-bottom: 1px solid var(--border); +} +.tree-toolbar__label { color: var(--text-muted); font-size: 0.8rem; font-weight: 600; } +.classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; } + +/* Live filter box above a file tree. */ +.tree-filter { + width: 100%; box-sizing: border-box; margin: 0.25rem 0; + padding: 0.25rem 0.5rem; font: inherit; font-size: 0.85rem; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg); color: var(--text); +} +.tree-filter:focus { outline: none; border-color: var(--primary); } + .folder-stats, .file-stats { display: flex; @@ -1293,34 +1366,46 @@ body.is-elevated::after { background-color: var(--bg-hover); } -/* Counts read "direct+total". The direct number stays solid (immediate info); - the "+total" subtree count is muted and pulses while its subtree is still - being scanned, then goes solid once final. */ +/* Counts read "direct+total". Completed numbers are blue — var(--primary), + which is theme-aware (medium blue in light, lighter blue in dark). The + direct number is always completed (known the moment the folder is read). + The "+total" subtree count stays muted grey + pulses while still scanning, + then turns blue once final. Once the row is fully scanned (both numbers + blue) the folders/files labels turn blue too (.folder-count.done). */ +.folder-count .ct-direct, .folder-count .ct-total { - color: var(--text-secondary, #6b7280); + color: var(--primary); } .folder-count .ct-total.pending { color: var(--text-muted, #9aa0a6); font-style: italic; animation: scan-pulse 1.2s ease-in-out infinite; } +.folder-count.done .ct-label { + color: var(--primary); +} @keyframes scan-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } } -/* Live scan status line under the tree-pane header. */ -.scan-status { - padding: 0.25rem 0.6rem; +/* Page footer — hosts the live scan status. */ +.app-footer { + flex-shrink: 0; + display: flex; + align-items: center; + padding: 0.2rem 0.75rem; + border-top: 1px solid var(--border, #e2e2e2); + background: var(--bg-secondary, #f5f5f5); font-size: 0.75rem; color: var(--text-muted, #8a8a8a); - border-bottom: 1px solid var(--border, #e2e2e2); + min-height: 1.4em; +} +.scan-status { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - min-height: 1.1em; } -.scan-status:empty { display: none; } .scan-status.scanning { color: var(--primary, #2868c8); } .folder-item.selected { @@ -1371,6 +1456,248 @@ body.is-elevated::after { margin-left: 1.5rem; } +/* ── Welcome screen (intro + tutorial) ─────────────────────────────────── */ +.empty-state--overlay { overflow-y: auto; } +.welcome { max-width: 900px; padding: 1.5rem 0.5rem 2.5rem; } +.welcome__title { font-size: 2.6rem; line-height: 1.1; margin: 0 0 0.6rem; } +.welcome__lede { + font-size: 1.2rem; line-height: 1.55; color: var(--text); + margin: 0 auto 2rem; max-width: 62ch; +} +.welcome__methods { + display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; + margin: 1.75rem 0 0; text-align: left; +} +@media (max-width: 780px) { .welcome__methods { grid-template-columns: 1fr; } } +.method { + border: 1px solid var(--border); border-radius: var(--radius); + padding: 1rem 1.15rem; background: var(--bg); +} +.method--primary { border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); } +.method__title { font-size: 1.1rem; margin: 0 0 0.5rem; } +.method__tag { + display: inline-block; font-size: 0.68rem; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.04em; color: var(--primary); + margin-left: 0.4rem; vertical-align: middle; +} +.method__tag--warn { color: var(--warning); } +.method__what { font-size: 0.95rem; color: var(--text-muted); margin: 0 0 0.7rem; } +.method__steps { margin: 0; padding-left: 1.25rem; font-size: 0.95rem; line-height: 1.6; } +.method__steps li { margin: 0.35rem 0; } +.method__steps code { + background: var(--bg-secondary); padding: 0.05rem 0.35rem; + border-radius: 4px; font-size: 0.85em; +} +.welcome__note { font-size: 0.9rem; color: var(--text-muted); margin-top: 1.5rem; } + +/* ── Workspaces (welcome manager) ──────────────────────────────────────── */ +.workspaces { text-align: left; margin: 1.5rem 0 0.5rem; } +.ws-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.ws-head h2 { margin: 0; font-size: 1.4rem; } +.ws-list { display: flex; flex-direction: column; gap: 0.4rem; } +.ws-empty { color: var(--text-muted); font-size: 0.85rem; padding: 0.75rem; border: 1px dashed var(--border); border-radius: var(--radius); } +.ws-row { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.6rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg); +} +.ws-row__main { flex: 1; min-width: 0; } +.ws-row__name { font-weight: 600; } +.ws-row__meta { font-size: 0.78rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ws-row__actions { display: flex; gap: 0.3rem; flex-shrink: 0; } +.ws-or { font-size: 0.82rem; color: var(--text-muted); margin: 1rem 0 0.5rem; } + +/* ── Workflow mode switch (header) ─────────────────────────────────────── */ +.mode-switch { + display: inline-flex; + margin-left: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} +.mode-btn { + border: none; + background: var(--bg); + color: var(--text-muted); + padding: 0.3rem 0.7rem; + font-size: 0.8rem; + cursor: pointer; +} +.mode-btn + .mode-btn { border-left: 1px solid var(--border); } +.mode-btn.active { + background: var(--primary); + color: var(--bg); + font-weight: 600; +} + +/* ── Target pane (Classify & Copy) ─────────────────────────────────────── */ +.target-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} +.target-pane[hidden], .spreadsheet-pane[hidden] { display: none; } + +.target-tabs { display: flex; gap: 0.25rem; } +.target-tab { + border: 1px solid var(--border); + border-bottom: none; + background: var(--bg-secondary); + color: var(--text-muted); + padding: 0.3rem 0.8rem; + font-size: 0.85rem; + border-radius: var(--radius) var(--radius) 0 0; + cursor: pointer; +} +.target-tab.active { + background: var(--bg); + color: var(--primary); + font-weight: 600; +} + +.target-body { flex: 1; overflow: hidden; } +.target-panel { height: 100%; display: flex; flex-direction: column; } +.target-panel[hidden] { display: none; } +.target-panel__toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.target-hint { font-size: 0.75rem; color: var(--text-muted); } + +.target-tree { flex: 1; overflow: auto; padding: 0.5rem 0.75rem; } +.target-empty { color: var(--text-muted); font-size: 0.85rem; padding: 1rem 0.25rem; } + +/* tree nodes */ +.tnode { margin: 0.1rem 0; } +.tnode__children { margin-left: 1.25rem; border-left: 1px dashed var(--border); padding-left: 0.5rem; } +.tnode__row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.2rem 0.3rem; + border-radius: var(--radius); +} +.tnode__row:hover { background: var(--bg-hover); } +.tnode__toggle { + border: none; background: none; cursor: pointer; + color: var(--text-muted); width: 1.1em; font-size: 0.8rem; padding: 0; +} +.tnode__icon { font-size: 0.85rem; } +.tnode__name { flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tnode--leaf > .tnode__row > .tnode__name { color: var(--primary); font-weight: 600; } +.tnode--party > .tnode__row > .tnode__name { font-weight: 700; } +.tnode--bin > .tnode__row > .tnode__name { color: var(--primary); } +.tnode__badge { + background: var(--primary); color: var(--bg); + border-radius: 999px; padding: 0 0.4rem; font-size: 0.7rem; font-weight: 600; +} +/* Node CRUD controls sit to the right of the level name, revealed on hover. */ +.tnode__actions { display: inline-flex; gap: 0.1rem; margin-left: 0.3rem; flex: 0 0 auto; opacity: 0; transition: opacity 0.12s; } +.tnode__row:hover .tnode__actions, .tslot__row:hover .tnode__actions { opacity: 1; } +.tnode__act { + border: 1px solid var(--border); background: var(--bg); + border-radius: var(--radius); cursor: pointer; + font-size: 0.72rem; padding: 0.05rem 0.35rem; color: var(--text); +} +.tnode__act:hover { background: var(--bg-hover); } + +/* placed files under a node */ +.tnode__files { margin: 0.1rem 0 0.2rem 1.6rem; } +.tfile { display: flex; align-items: baseline; gap: 0.4rem; font-size: 0.75rem; padding: 0.05rem 0; } +.tfile__orig { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 14rem; } +.tfile__arrow { color: var(--text-muted); } +.tfile__name { color: var(--text); } +.tfile--err .tfile__name { color: var(--danger); } +.tfile--err::before { content: "⚠"; color: var(--danger); } + +/* transmittal slots + bin form */ +.tslot { margin: 0.15rem 0 0.15rem 1.1rem; } +.tslot__row { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0.3rem; } +.tslot__name { font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em; } +.tnode--bin { margin-left: 1.1rem; } +.binform { + display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; + margin: 0.2rem 0 0.3rem 1.1rem; padding: 0.4rem; background: var(--bg-secondary); + border: 1px solid var(--border); border-radius: var(--radius); +} +.binform input, .binform select { + font-size: 0.78rem; padding: 0.2rem 0.3rem; + border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg); color: var(--text); +} +.binform__seq { width: 7rem; } +.binform__title { width: 11rem; } + +/* drop-target affordance */ +.tnode__row.drop-hover, .tslot.drop-hover { outline: 2px dashed var(--primary); outline-offset: -2px; background: var(--primary-light); } + +/* ── Source-tree file rows (classify mode) ─────────────────────────────── */ +.file-item { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.15rem 0.5rem; + cursor: grab; + border-radius: var(--radius); + font-size: 0.85rem; + user-select: none; +} +.file-item:hover { background: var(--bg-hover); } +.file-item:active { cursor: grabbing; } +.file-item.match-highlight { background: var(--primary-light); outline: 1px solid var(--primary); } +.folder-item[draggable="true"] { cursor: grab; } +.file-icon { color: var(--text-muted); } +.file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* classification state dot */ +.cl-dot { + width: 0.55rem; height: 0.55rem; border-radius: 999px; flex-shrink: 0; + border: 1px solid var(--border); background: transparent; +} +.cl-dot--none { background: transparent; } +.cl-dot--tracking, +.cl-dot--transmittal { background: var(--warning); border-color: var(--warning); } +.cl-dot--partial { background: var(--warning); border-color: var(--warning); } +.cl-dot--done { background: var(--success); border-color: var(--success); } +.cl-dot--excluded { background: var(--text-muted); border-color: var(--text-muted); opacity: 0.6; } +.file-item.excluded .file-name { text-decoration: line-through; color: var(--text-muted); } + +/* placed-file row in the target pane is clickable (reveal in source) */ +.tfile { cursor: pointer; } +.tfile:hover .tfile__orig { text-decoration: underline; } /* click row (not the name input) → preview */ +input.tfile__name { + flex: 1 1 auto; min-width: 10rem; font: inherit; color: var(--text); + border: 1px solid transparent; background: transparent; border-radius: 3px; padding: 0 0.2rem; +} +input.tfile__name:hover { border-color: var(--border); } +input.tfile__name:focus { border-color: var(--primary); background: var(--bg); outline: none; } + +/* cross-tree reveal flash */ +.reveal-flash, .match-highlight { animation: cl-flash 1.5s ease-out; } +@keyframes cl-flash { + 0%, 40% { background: var(--primary-light); outline: 2px solid var(--primary); outline-offset: -2px; } + 100% { background: transparent; outline-color: transparent; } +} + +/* exclude/include context menu */ +.cl-menu { + position: fixed; z-index: 9500; + background: var(--bg); border: 1px solid var(--border); + border-radius: var(--radius); box-shadow: 0 6px 18px rgba(0,0,0,0.18); + padding: 0.25rem; min-width: 11rem; +} +.cl-menu__item { + display: block; width: 100%; text-align: left; + border: none; background: none; color: var(--text); + padding: 0.4rem 0.6rem; font-size: 0.83rem; cursor: pointer; border-radius: var(--radius); +} +.cl-menu__item:hover { background: var(--bg-hover); } + /* Spreadsheet Pane */ .spreadsheet-pane { flex: 1; @@ -1906,10 +2233,16 @@ body.is-elevated::after {
ZDDC Classifier - v0.0.27-beta · 2026-06-09 15:30:13 · 237c353 + v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
+
+ + +
+ +
@@ -1931,22 +2264,41 @@ body.is-elevated::after { Auto-scroll -
-
+ +
- -
+ +

Files

@@ -2007,20 +2359,90 @@ body.is-elevated::after {
+ +
+
+
+ + +
+
+ + | + + + + + +
+
+
+
+
+ + Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”. +
+ +
+
+ +
+
+ + + +
-
-

ZDDC Classifier

-

- This standalone tool is being absorbed into the Browse app. - Browse's Grid view-mode now provides the same spreadsheet - workflow alongside file navigation. This standalone build remains - available for offline use and air-gapped environments. -

-

Rename a folder of files to ZDDC format using a spreadsheet interface.

-

Open a directory, fill in tracking number, revision, status, and title for each file, then save — the files are renamed on disk.

+
+

ZDDC Classifier

+

Turn a messy folder of project files into clean, correctly-named ZDDC documents — organized by tracking number and transmittal — without ever changing your originals.

+ + +
+
+

Your workspaces

+ +
+
+
+ + +
+
+

① Classify & copy recommended · non-destructive

+

Build a tidy copy of a project in a separate output folder. Your source files are only ever read, never renamed or moved.

+
    +
  1. New workspace → pick a folder. It scans once and saves to this browser, so you can close the tab and pick up later.
  2. +
  3. Preview a file (single-click it in the left tree) to see what it actually is.
  4. +
  5. Drag it onto the right pane — onto a tracking-number folder (the folder path becomes the number, the leaf is the revision, e.g. A (IFR)), and onto a transmittal (party + date + TRN/SUB + sequence).
  6. +
  7. Copy when ready → choose an output directory; renamed copies are written as <party>/<transmittal>/<name>, with duplicates detected.
  8. +
+
+
+

② Rename in place edits your files

+

A quick spreadsheet for a folder you own: fill in tracking number, revision, status and title, and rename the files on disk.

+
    +
  1. Click Use Local Directory (top bar) to open a folder.
  2. +
  3. Switch the toggle to Rename in place.
  4. +
  5. Edit cells (or paste columns from Excel); names already in ZDDC format are parsed automatically and validated as you type.
  6. +
  7. Save All renames the files where they sit.
  8. +
+
+
-
    -
  • Files already named to ZDDC format are parsed automatically
  • -
  • Edit cells directly, or copy columns to and from Excel
  • -
  • Real-time validation highlights non-compliant names
  • -
  • Rename one file or all modified files at once
  • -
- -

Click Use Local Directory to begin.

- -

This application works entirely in your browser. No data is transmitted to any server.

+

Everything runs in your browser — no files are uploaded. Tip: if your folder lives on OneDrive/SharePoint, set it to “Always keep on this device” first for a much faster scan.

@@ -4339,74 +4752,122 @@ X.B(E,Y);return E}return J}()) }()); // shared/toast.js — non-blocking notification helper available to every -// tool via window.zddc.toast(msg, level, opts). Originated as classifier's -// local showToast (classifier/js/excel.js); promoted here so tools that -// today use alert() or silent console.error can switch to a uniform -// non-blocking surface. +// tool via window.zddc.toast(msg, level, opts). +// +// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY — +// they stay until the user dismisses them (per-toast × or a "Clear all" +// button) so the message can be read, selected, and copied while +// troubleshooting. info/success toasts auto-dismiss. The message text is +// always selectable. // // Usage: -// window.zddc.toast('Saved.', 'success'); -// window.zddc.toast('Could not load: ' + err.message, 'error'); +// window.zddc.toast('Saved.', 'success'); // auto-dismiss +// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky // window.zddc.toast('Note', 'info', { durationMs: 3000 }); +// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky // // Levels: 'info' (default) | 'success' | 'warning' | 'error'. -// Each tool may also expose app.notify(msg, level) as a thin wrapper — -// see ARCHITECTURE.md for the convention. (function () { 'use strict'; if (!window.zddc) window.zddc = {}; - // Don't overwrite if a tool defined its own first. if (typeof window.zddc.toast === 'function') return; var DEFAULT_DURATION_MS = 5000; var FADE_MS = 300; + // Levels that persist until the user dismisses them (troubleshooting). + var STICKY = { error: true, warning: true }; + + function container() { + var c = document.getElementById('zddc-toasts'); + if (c) return c; + c = document.createElement('div'); + c.id = 'zddc-toasts'; + c.className = 'zddc-toasts'; + document.body.appendChild(c); + return c; + } + + // Show/hide a "Clear all" control when 2+ toasts are stacked. + function refreshClearAll(c) { + var bar = c.querySelector('.zddc-toasts__clear'); + var count = c.querySelectorAll('.zddc-toast').length; + if (count >= 2) { + if (!bar) { + bar = document.createElement('button'); + bar.type = 'button'; + bar.className = 'zddc-toasts__clear'; + bar.textContent = 'Clear all'; + bar.addEventListener('click', function () { + var all = c.querySelectorAll('.zddc-toast'); + for (var i = 0; i < all.length; i++) dismiss(all[i]); + }); + c.insertBefore(bar, c.firstChild); + } + } else if (bar) { + bar.remove(); + } + } + + function dismiss(el) { + if (el._dismissed) return; + el._dismissed = true; + if (el._timer) clearTimeout(el._timer); + el.classList.add('zddc-toast--fade'); + setTimeout(function () { + if (el.parentNode) el.parentNode.removeChild(el); + refreshClearAll(container()); + }, FADE_MS); + } function toast(message, level, opts) { opts = opts || {}; var lvl = (level === 'success' || level === 'error' || level === 'warning') ? level : 'info'; - - // Single-toast policy: dismiss any existing toast immediately - // so the new one is always the most recent. Matches the - // classifier's prior behavior and avoids stack-of-toasts UX. - var existing = document.querySelector('.zddc-toast'); - if (existing) existing.remove(); + var c = container(); var el = document.createElement('div'); el.className = 'zddc-toast zddc-toast--' + lvl; // ARIA: errors get assertive (interrupts SR queue), others polite. el.setAttribute('role', lvl === 'error' ? 'alert' : 'status'); el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite'); - el.textContent = message == null ? '' : String(message); - document.body.appendChild(el); - var dur = typeof opts.durationMs === 'number' ? - opts.durationMs : DEFAULT_DURATION_MS; - var timer = setTimeout(function () { - el.classList.add('zddc-toast--fade'); - setTimeout(function () { - if (el.parentNode) el.parentNode.removeChild(el); - }, FADE_MS); - }, dur); + // Selectable, copyable message text (its own element so clicking to + // select doesn't dismiss the toast — only the × does). + var msg = document.createElement('span'); + msg.className = 'zddc-toast__msg'; + msg.textContent = message == null ? '' : String(message); + el.appendChild(msg); - // Click-to-dismiss. Useful for sticky errors the user wants gone. - el.addEventListener('click', function () { - clearTimeout(timer); - if (el.parentNode) el.parentNode.removeChild(el); - }); + var close = document.createElement('button'); + close.type = 'button'; + close.className = 'zddc-toast__close'; + close.setAttribute('aria-label', 'Dismiss'); + close.textContent = '×'; + close.addEventListener('click', function () { dismiss(el); }); + el.appendChild(close); + c.appendChild(el); + + // Sticky (error/warning, or opts.durationMs === 0) persists; otherwise + // auto-dismiss after the (overridable) duration. + var sticky = opts.durationMs === 0 || + (typeof opts.durationMs !== 'number' && STICKY[lvl]); + if (!sticky) { + var dur = typeof opts.durationMs === 'number' + ? opts.durationMs : DEFAULT_DURATION_MS; + el._timer = setTimeout(function () { dismiss(el); }, dur); + } + + refreshClearAll(c); return el; } window.zddc.toast = toast; - // Route window.alert() calls into the toast helper. Every tool has - // accumulated some `alert(...)` sites for error reporting; rather - // than touch each one, intercept globally so they're non-blocking - // and ARIA-announced consistently. Native alert is preserved on - // window.alertNative for the rare case where a truly modal block - // is needed (e.g. before navigating away with unsaved changes). + // Route window.alert() into the toast helper (non-blocking, ARIA-announced, + // consistent). Native alert preserved on window.alertNative. alert() maps + // to an error toast (sticky) since that's its usual purpose. if (typeof window.alert === 'function' && !window.alertNative) { window.alertNative = window.alert.bind(window); window.alert = function (msg) { @@ -5196,6 +5657,9 @@ X.B(E,Y);return E}return J}()) cacheDOMElements(); setupEventListeners(); + // Workspace manager (renders the welcome list, owns new/open/autosave). + if (app.modules.workspace) app.modules.workspace.init(); + // Browser-compatibility branch: // HTTP mode (served by zddc-server) — works everywhere; the // HTTP polyfill stands in for the FS Access API. Auto-load @@ -5302,7 +5766,20 @@ X.B(E,Y);return E}return J}()) exportHashesBtn: document.getElementById('exportHashesBtn'), sha256Checkbox: document.getElementById('sha256Checkbox'), hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'), - + hideCompliantLabel: document.getElementById('hideCompliantLabel'), + classifyFilters: document.getElementById('classifyFilters'), + showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'), + showAssignedCheckbox: document.getElementById('showAssignedCheckbox'), + showExcludedCheckbox: document.getElementById('showExcludedCheckbox'), + showEmptyCheckbox: document.getElementById('showEmptyCheckbox'), + exportDatasetBtn: document.getElementById('exportDatasetBtn'), + importDatasetBtn: document.getElementById('importDatasetBtn'), + importDatasetInput: document.getElementById('importDatasetInput'), + resetDatasetBtn: document.getElementById('resetDatasetBtn'), + treeFilterInput: document.getElementById('treeFilterInput'), + trackingFilterInput: document.getElementById('trackingFilterInput'), + transmittalFilterInput: document.getElementById('transmittalFilterInput'), + // Folder tree folderTree: document.getElementById('folderTree'), folderTreePane: document.getElementById('folderTreePane'), @@ -5321,10 +5798,157 @@ X.B(E,Y);return E}return J}()) errorFiles: document.getElementById('errorFiles'), // Preview - togglePreviewBtn: document.getElementById('togglePreviewBtn') + togglePreviewBtn: document.getElementById('togglePreviewBtn'), + + // Mode switch + Classify & Copy panes + modeRenameBtn: document.getElementById('modeRenameBtn'), + modeClassifyBtn: document.getElementById('modeClassifyBtn'), + spreadsheetPane: document.getElementById('spreadsheetPane'), + targetPane: document.getElementById('targetPane'), + copyOutputBtn: document.getElementById('copyOutputBtn') }; } + /** + * Switch between "Rename" (in-place grid) and "Classify & Copy" (map files + * onto target trees, copy renamed copies out). The source tree (left) stays + * in both modes; only the right pane swaps. + */ + function setMode(mode) { + const classify = mode === 'classify'; + app.dom.modeRenameBtn.classList.toggle('active', !classify); + app.dom.modeClassifyBtn.classList.toggle('active', classify); + if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify; + if (app.dom.targetPane) app.dom.targetPane.hidden = !classify; + // Mode-specific source-tree filters: "Hide Compliant" is for the rename + // grid; "Hide Assigned" is for the classify workflow. + if (app.dom.hideCompliantLabel) app.dom.hideCompliantLabel.hidden = classify; + if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = !classify; + app.modules.classify.setEnabled(classify); + if (classify && app.modules.targetTree) { + app.modules.targetTree.init(); + app.modules.targetTree.render(); + } + // Re-render the source tree so its per-file markers appear/disappear. + if (app.modules.tree) app.modules.tree.render(); + } + + // ── dataset export / import (one record per file) ────────────────────── + // Round-trip the classification as a flat list of files, each carrying its + // full ZDDC filename (and optional transmittal). An external editor (e.g. an + // AI) just sets filenames; on import the app parses each filename and + // rebuilds the tracking tree (no node ids to manage). + function eachSourceFile(cb) { + (function walk(nodes) { + (nodes || []).forEach(function (n) { (n.files || []).forEach(cb); walk(n.children); }); + })(app.folderTree || []); + } + function exportDataset() { + var c = app.modules.classify, files = []; + eachSourceFile(function (f) { + var key = c.srcKeyForFile(f); + var a = c.getAssignment(key) || {}; + var d = c.deriveTarget(f); + var rec = { + source: key, + originalName: window.zddc.joinExtension(f.originalFilename, f.extension), + filename: a.excluded ? '' : (d.filename || ''), + excluded: !!a.excluded, + }; + if (!a.excluded && a.transmittalNodeId) { + var t = c.transmittalRecord(a.transmittalNodeId); + if (t) rec.transmittal = t; + } + files.push(rec); + }); + var payload = { + zddcClassifierFiles: 1, + exportedAt: new Date().toISOString(), + _format: 'One record per input file. Set "filename" to its full ZDDC name ' + + '"TRACKING_REV (STATUS) - Title.ext" — on import the app splits TRACKING on "-" and the ' + + 'final "_" into nested folders, and files in shared paths share ancestors. Set ' + + '"excluded": true for non-documents (filename then ignored). "transmittal" is optional: ' + + '{party, slot:"received"|"issued", date:"YYYY-MM-DD", type:"TRN"|"SUB", seq, status, title}. ' + + 'Classify every "source" key; do not invent files.', + outputName: c.serialize().outputName || null, + files: files, + }; + var name = 'classifier-dataset'; + try { + if (app.modules.workspace && typeof app.modules.workspace.activeName === 'function') { + name = app.modules.workspace.activeName() || name; + } + } catch (_) { /* ok */ } + var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = String(name).replace(/[^\w.-]+/g, '_') + '.json'; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + } + function importDataset(file) { + var reader = new FileReader(); + reader.onload = function () { + var obj; + try { obj = JSON.parse(reader.result); } + catch (e) { window.zddc.toast('Import failed — not valid JSON.', 'error'); return; } + if (!obj || !Array.isArray(obj.files)) { + window.zddc.toast('Import failed — expected a classifier dataset with a "files" list.', 'error'); return; + } + var c = app.modules.classify; + var hasData = c.getTrackingTree().length || c.getTransmittalTree().length + || Object.keys(c.serialize().assignments || {}).length; + if (hasData && !confirm('Replace the current classification with the imported dataset?')) return; + c.reset(); + var ok = 0, bad = 0; + obj.files.forEach(function (rec) { + if (!rec || !rec.source) return; + var key = rec.source; + if (rec.excluded) { c.setExcluded([key], true); ok++; return; } + if (rec.filename) { + var p = window.zddc.parseFilename(String(rec.filename).trim()); + if (p && p.valid) { + var stem = p.trackingNumber + '_' + p.revision + ' (' + p.status + ')'; + c.place([key], c.addTrackingPath(null, c.parseFolderLevels(stem)), 'tracking'); + if (p.title != null) c.setTitleOverride(key, p.title); + ok++; + } else { bad++; } + } + if (rec.transmittal && rec.transmittal.party) { + var t = rec.transmittal; + var pid = c.findOrAddParty(t.party); + var bid = c.findOrAddTransmittalBin(pid, t.slot || 'received', { + date: t.date, type: t.type || 'TRN', seq: t.seq, status: t.status, title: t.title, + }); + if (bid) c.place([key], bid, 'transmittal'); + } + }); + window.zddc.toast('Imported ' + ok + ' file' + (ok === 1 ? '' : 's') + + (bad ? (' — ' + bad + ' had an unparseable filename') : '') + '.', bad ? 'warning' : 'success'); + }; + reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); }; + reader.readAsText(file); + } + // Reset to a clean state: keep the scanned source tree (the raw input), but + // discard every classification — trees, assignments, excludes, overrides. + // Destructive and irreversible, so warn and steer the user to Export first. + function resetDataset() { + var c = app.modules.classify; + var n = Object.keys(c.serialize().assignments || {}).length; + if (!n && !c.getTrackingTree().length && !c.getTransmittalTree().length) { + window.zddc.toast('Nothing to reset — already at the raw input.', 'info'); + return; + } + if (!confirm('Reset to a clean state?\n\nThis discards ALL classifications (' + + n + ' assigned file' + (n === 1 ? '' : 's') + ', plus the tracking and ' + + 'transmittal trees) and returns to just the raw scanned input. Your actual ' + + 'files are not touched.\n\nThis cannot be undone — Export first if you might ' + + 'need this data.')) return; + c.reset(); + window.zddc.toast('Reset to the raw scanned input.', 'success'); + } + /** * Set up event listeners */ @@ -5348,15 +5972,66 @@ X.B(E,Y);return E}return J}()) // Hide compliant toggle app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle); - + + // Classify-mode source-tree filters: show/hide unassigned, assigned, excluded. + function pushClassifyFilters() { + if (app.modules.tree && app.modules.tree.setShowFilters) { + app.modules.tree.setShowFilters({ + unassigned: app.dom.showUnassignedCheckbox.checked, + assigned: app.dom.showAssignedCheckbox.checked, + excluded: app.dom.showExcludedCheckbox.checked, + empty: app.dom.showEmptyCheckbox.checked, + }); + } + } + [app.dom.showUnassignedCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox, app.dom.showEmptyCheckbox] + .forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); }); + // Collapse tree button app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree); + + // Workflow mode switch + if (app.dom.modeRenameBtn) app.dom.modeRenameBtn.addEventListener('click', function () { setMode('rename'); }); + if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); }); + if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); }); + + // Live source-tree filter (matches file path + name; reveals the hierarchy). + if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () { + if (app.modules.tree && app.modules.tree.setNameFilter) app.modules.tree.setNameFilter(this.value); + }); + // Target-tree filter — both tabs share one query (mirrored across inputs). + function targetFilter(val) { + if (app.dom.trackingFilterInput) app.dom.trackingFilterInput.value = val; + if (app.dom.transmittalFilterInput) app.dom.transmittalFilterInput.value = val; + if (app.modules.targetTree && app.modules.targetTree.setNameFilter) app.modules.targetTree.setNameFilter(val); + } + [app.dom.trackingFilterInput, app.dom.transmittalFilterInput].forEach(function (inp) { + if (inp) inp.addEventListener('input', function () { targetFilter(this.value); }); + }); + + // Dataset export / import (round-trip the classification through a JSON file). + if (app.dom.exportDatasetBtn) app.dom.exportDatasetBtn.addEventListener('click', exportDataset); + if (app.dom.importDatasetBtn) app.dom.importDatasetBtn.addEventListener('click', function () { app.dom.importDatasetInput.click(); }); + if (app.dom.resetDatasetBtn) app.dom.resetDatasetBtn.addEventListener('click', resetDataset); + if (app.dom.importDatasetInput) app.dom.importDatasetInput.addEventListener('change', function () { + if (this.files && this.files[0]) importDataset(this.files[0]); + this.value = ''; // allow re-importing the same file + }); // Keyboard shortcuts document.addEventListener('keydown', handleKeyDown); // Resize handle setupResizeHandle(); + + // Re-render the source tree when classify state changes (so file dots + // and placements stay in sync after a drop). Cheap no-op outside + // classify mode. + if (app.modules.classify) { + app.modules.classify.on(function () { + if (app.modules.classify.isEnabled() && app.modules.tree) app.modules.tree.render(); + }); + } } /** @@ -5473,27 +6148,35 @@ X.B(E,Y);return E}return J}()) /** * Open a directory handle and initialize the application */ - async function openDirectory(dirHandle) { - app.rootHandle = dirHandle; - - // Hide welcome screen and show main UI + // Show the main UI and initialize the per-tool modules ONCE. Shared by the + // legacy rename open and the workspace open/new flows (the latter scan or + // load a snapshot themselves). + var shellInited = false; + function enterAppShell() { hideWelcomeScreen(); showMainUI(); - - // Initialize modules BEFORE scanning (so they're ready for store updates) - app.modules.spreadsheet.init(); // Subscribe to store - app.modules.selection.init(); - app.modules.preview.init(); // After selection so it can listen for rowfocused - app.modules.resize.init(); - app.modules.filter.init(); - app.modules.sort.init(); - app.modules.tree.setupKeyboardShortcuts(); - + if (!shellInited) { + shellInited = true; + app.modules.spreadsheet.init(); // Subscribe to store + app.modules.selection.init(); + app.modules.preview.init(); // After selection so it can listen for rowfocused + app.modules.resize.init(); + app.modules.filter.init(); + app.modules.sort.init(); + app.modules.tree.setupKeyboardShortcuts(); + if (app.modules.targetTree) app.modules.targetTree.init(); + } + if (app.dom.refreshHeaderBtn) app.dom.refreshHeaderBtn.classList.remove('hidden'); + } + + async function openDirectory(dirHandle) { + app.rootHandle = dirHandle; + enterAppShell(); + // Default to Classify & Copy (the primary workflow). The user can switch + // to "Rename in place" via the toggle for the spreadsheet. + setMode('classify'); // Now scan directory (this will trigger store updates and renders) await app.modules.scanner.scanDirectory(dirHandle); - - // Show refresh button now that a directory is loaded - if (app.dom.refreshHeaderBtn) { app.dom.refreshHeaderBtn.classList.remove('hidden'); } } /** @@ -5506,17 +6189,33 @@ X.B(E,Y);return E}return J}()) } try { + // A snapshot-loaded workspace handle needs its read permission + // re-granted before we can enumerate it again. + if (app.modules.persist && app.modules.persist.verifyPermission) { + const ok = await app.modules.persist.verifyPermission(app.rootHandle, false); + if (!ok) { + if (window.zddc) window.zddc.toast('Permission to read the source directory was denied.', 'error'); + return; + } + } + // Clear current data app.folderTree = []; app.selectedFolders.clear(); app.lastSelectedFolderPath = null; - + // Reset store app.modules.store.reset(); // Rescan directory (modules already initialized, just rescan) await app.modules.scanner.scanDirectory(app.rootHandle); + // For a workspace, persist the refreshed snapshot (additive: the + // path-keyed map re-attaches; new files appear unassigned). + if (app.modules.workspace && app.modules.workspace.onRescanned) { + app.modules.workspace.onRescanned(); + } + } catch (err) { console.error('Error refreshing directory:', err); alert('Error refreshing directory: ' + err.message); @@ -5652,7 +6351,9 @@ X.B(E,Y);return E}return J}()) // Export functions for use by other modules app.modules.app = { - updateStats + updateStats, + setMode, + enterAppShell }; // Initialize when DOM is ready @@ -6355,6 +7056,1043 @@ X.B(E,Y);return E}return J}()) }; })(); +/** + * ZDDC Classifier — workspace persistence (IndexedDB). + * + * A "workspace" is one classification project: the picked source directory + * HANDLE, a SNAPSHOT of its completed scan (folder/file structure — names and + * paths only, no contents), and the Classify & Copy map (assignments + target + * trees). Scan once, resume instantly across sessions without re-walking the + * (often cloud-backed, high-latency) source. + * + * Two object stores so the welcome list stays cheap: + * - 'index' (tiny): { id, name, rootName, createdAt, updatedAt, summary } + * - 'data' (large): { id, rootHandle, tree, classify } + * + * A FileSystemDirectoryHandle is structured-cloneable, so IndexedDB can hold + * it; on reuse we re-request permission (one click). It's only needed at COPY + * time — opening a workspace runs entirely from the snapshot. + */ +(function () { + 'use strict'; + + var DB_NAME = 'zddc-classifier'; + var DB_VERSION = 2; + var IDX = 'index'; + var DATA = 'data'; + + var available = typeof indexedDB !== 'undefined'; + + function openDB() { + return new Promise(function (resolve, reject) { + if (!available) { reject(new Error('IndexedDB unavailable')); return; } + var req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = function () { + var db = req.result; + // 'kv' (v1, single implicit map) is intentionally left behind. + if (!db.objectStoreNames.contains(IDX)) db.createObjectStore(IDX, { keyPath: 'id' }); + if (!db.objectStoreNames.contains(DATA)) db.createObjectStore(DATA, { keyPath: 'id' }); + }; + req.onsuccess = function () { resolve(req.result); }; + req.onerror = function () { reject(req.error); }; + }); + } + + function reqP(req) { + return new Promise(function (resolve, reject) { + req.onsuccess = function () { resolve(req.result); }; + req.onerror = function () { reject(req.error); }; + }); + } + + // ── public API ───────────────────────────────────────────────────────── + + // Light metadata for every workspace (for the welcome list). Sorted newest + // first. Never loads the big snapshot. + function listWorkspaces() { + return openDB().then(function (db) { + return reqP(db.transaction(IDX, 'readonly').objectStore(IDX).getAll()); + }).then(function (rows) { + (rows || []).sort(function (a, b) { return (b.updatedAt || 0) - (a.updatedAt || 0); }); + return rows || []; + }).catch(function (e) { console.warn('persist.list', e); return []; }); + } + + // Full data record for one workspace: { id, rootHandle, tree, classify }. + function getWorkspace(id) { + return openDB().then(function (db) { + return reqP(db.transaction(DATA, 'readonly').objectStore(DATA).get(id)); + }).catch(function (e) { console.warn('persist.get', e); return null; }); + } + + // Save (create or update). meta = {id,name,rootName,createdAt,updatedAt,summary}; + // data = {id, rootHandle, tree, classify}. tree may be omitted on a classify- + // only autosave (the snapshot rarely changes) — then we preserve the stored one. + function putWorkspace(meta, data) { + return openDB().then(function (db) { + return new Promise(function (resolve, reject) { + var t = db.transaction([IDX, DATA], 'readwrite'); + t.oncomplete = function () { resolve(); }; + t.onerror = function () { reject(t.error); }; + t.objectStore(IDX).put(meta); + var ds = t.objectStore(DATA); + if (data && typeof data.tree !== 'undefined') { + ds.put(data); + } else if (data) { + // Merge classify/rootHandle without clobbering the snapshot. + var g = ds.get(meta.id); + g.onsuccess = function () { + var existing = g.result || { id: meta.id }; + if (typeof data.rootHandle !== 'undefined') existing.rootHandle = data.rootHandle; + if (typeof data.classify !== 'undefined') existing.classify = data.classify; + existing.id = meta.id; + ds.put(existing); + }; + } + }); + }).catch(function (e) { console.warn('persist.put', e); }); + } + + function deleteWorkspace(id) { + return openDB().then(function (db) { + return new Promise(function (resolve, reject) { + var t = db.transaction([IDX, DATA], 'readwrite'); + t.oncomplete = function () { resolve(); }; + t.onerror = function () { reject(t.error); }; + t.objectStore(IDX).delete(id); + t.objectStore(DATA).delete(id); + }); + }).catch(function (e) { console.warn('persist.delete', e); }); + } + + // Re-acquire read permission on a stored handle (one click). true if usable. + function verifyPermission(handle, write) { + if (!handle || typeof handle.queryPermission !== 'function') return Promise.resolve(false); + var opts = { mode: write ? 'readwrite' : 'read' }; + return handle.queryPermission(opts).then(function (p) { + if (p === 'granted') return true; + return handle.requestPermission(opts).then(function (p2) { return p2 === 'granted'; }); + }).catch(function () { return false; }); + } + + window.app.modules.persist = { + available: available, + listWorkspaces: listWorkspaces, + getWorkspace: getWorkspace, + putWorkspace: putWorkspace, + deleteWorkspace: deleteWorkspace, + verifyPermission: verifyPermission, + }; +})(); + +/** + * ZDDC Classifier — "Classify & Copy" state model. + * + * The non-destructive workflow: the source directory is read-only; the user + * maps each source file onto two orthogonal target trees, and a later copy + * step writes renamed copies into a separate output directory. + * + * - Tracking tab (→ filename), POSITIONAL: + * tracking number = the file's ancestor folder names joined with '-' + * revision (+status) = its immediate parent folder, named "REV (STATUS)" + * title = derived from the original filename + * → TRACKING_REV (STATUS) - TITLE.ext + * - Transmittal tab (→ output path): + * /{issued,received}// + * + * This module is the single source of truth: placements live in `assignments` + * keyed by source-relative path (so they survive a re-pick); the trees define + * structure only. All target values are DERIVED, never stored. + */ +(function () { + 'use strict'; + + // ── unique ids ─────────────────────────────────────────────────────────── + var _idSeq = 0; + function uid() { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + try { return window.crypto.randomUUID(); } catch (_) { /* non-secure ctx */ } + } + return 'n' + (Date.now().toString(36)) + '-' + (++_idSeq).toString(36); + } + + // ── state ──────────────────────────────────────────────────────────────── + var state = { + enabled: false, // classify mode on/off + assignments: {}, // srcKey -> { trackingNodeId, transmittalNodeId, excluded, titleOverride } + trackingTree: [], // [ { id, name, children:[] } ] (leaf = no children) + transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ] + outputName: null, // remembered output directory display name + }; + + // id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent } + var nodeIndex = {}; + + // ── pub/sub ────────────────────────────────────────────────────────────── + var listeners = []; + function on(cb) { listeners.push(cb); return function () { listeners = listeners.filter(function (f) { return f !== cb; }); }; } + var notifyScheduled = false; + function notify() { + // Coalesce bursts (a group-drop touches many keys) into one render. + // Listeners include the target/source re-renders AND the workspace + // autosave (workspace.js subscribes) — persistence is not this + // module's concern. + if (notifyScheduled) return; + notifyScheduled = true; + Promise.resolve().then(function () { + notifyScheduled = false; + for (var i = 0; i < listeners.length; i++) { + try { listeners[i](); } catch (e) { console.error('classify listener', e); } + } + }); + } + + // ── source keys + title derivation ─────────────────────────────────────── + function stripRoot(p) { + var i = (p || '').indexOf('/'); + return i < 0 ? '' : p.slice(i + 1); + } + // Stable key for a file: its path relative to the picked root (root segment + // dropped), so re-picking the same directory re-attaches the same map. + function srcKeyForFile(file) { + var rel = stripRoot(file.folderPath || ''); + var fn = zddc.joinExtension(file.originalFilename, file.extension); + return rel ? rel + '/' + fn : fn; + } + // Default title: if the original name already parses as ZDDC, reuse its + // title; otherwise the cleaned stem (originalFilename is the stem already). + function defaultTitle(file) { + var full = zddc.joinExtension(file.originalFilename, file.extension); + var parsed = zddc.parseFilename(full); + if (parsed && parsed.valid && parsed.title) return parsed.title; + return (file.originalFilename || '').trim(); + } + + // Parse a leaf folder label "A (IFR)" → { revision, status }. No parens → + // the whole label is the revision and status is blank. + var LEAF_RE = /^(.*?)\s*\(([^)]+)\)\s*$/; + function parseLeafLabel(name) { + var m = (name || '').match(LEAF_RE); + if (m) return { revision: m[1].trim(), status: m[2].trim() }; + return { revision: (name || '').trim(), status: '' }; + } + + // ── assignments ────────────────────────────────────────────────────────── + function assignmentFor(key) { + var a = state.assignments[key]; + if (!a) { + a = { trackingNodeId: null, transmittalNodeId: null, excluded: false, titleOverride: null }; + state.assignments[key] = a; + } + return a; + } + // Read-only: returns the existing entry or null (no side effects). + function getAssignment(key) { return state.assignments[key] || null; } + function cleanAssignment(key) { + var a = state.assignments[key]; + if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) { + delete state.assignments[key]; + } + } + + // Place keys onto a node along one axis ('tracking' | 'transmittal'). + // nodeId null clears that axis. + function place(keys, nodeId, axis) { + var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId'; + keys.forEach(function (k) { + var a = assignmentFor(k); + a[field] = nodeId || null; + a.excluded = false; // placing un-excludes + cleanAssignment(k); + }); + notify(); + } + function setExcluded(keys, excluded) { + keys.forEach(function (k) { + var a = assignmentFor(k); + a.excluded = !!excluded; + if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; } + cleanAssignment(k); + }); + notify(); + } + function setTitleOverride(key, title) { + var a = assignmentFor(key); + a.titleOverride = title && title.trim() ? title.trim() : null; + cleanAssignment(key); + notify(); + } + + // ── node index ─────────────────────────────────────────────────────────── + function rebuildIndex() { + nodeIndex = {}; + (function walkTracking(nodes, parent) { + (nodes || []).forEach(function (n) { + nodeIndex[n.id] = { node: n, kind: 'tracking', parent: parent }; + walkTracking(n.children, n); + }); + })(state.trackingTree, null); + (state.transmittalTree || []).forEach(function (party) { + nodeIndex[party.id] = { node: party, kind: 'party', parent: null }; + (party.children || []).forEach(function (slot) { + nodeIndex[slot.id] = { node: slot, kind: 'slot', parent: party }; + (slot.children || []).forEach(function (bin) { + nodeIndex[bin.id] = { node: bin, kind: 'transmittal', parent: slot }; + }); + }); + }); + } + function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; } + function infoFor(id) { return nodeIndex[id] || null; } + + // Ancestor name chain for a tracking node (root → node inclusive). + function trackingChain(info) { + var names = []; + var cur = info; + while (cur && cur.kind === 'tracking') { + names.unshift(cur.node.name); + cur = cur.parent ? infoFor(cur.parent.id) : null; + } + return names; + } + + // ── tracking tree ops ──────────────────────────────────────────────────── + function addTrackingNode(parentId, name) { + var node = { id: uid(), name: (name || 'new').trim() || 'new', children: [] }; + if (parentId) { + var info = infoFor(parentId); + if (!info || info.kind !== 'tracking') return null; + info.node.children.push(node); + } else { + state.trackingTree.push(node); + } + rebuildIndex(); + notify(); + return node.id; + } + + // ── transmittal tree ops ───────────────────────────────────────────────── + function addParty(name) { + var party = { id: uid(), kind: 'party', name: (name || 'Party').trim() || 'Party', children: [] }; + state.transmittalTree.push(party); + rebuildIndex(); + notify(); + return party.id; + } + function ensureSlot(party, slot) { + var existing = (party.children || []).filter(function (s) { return s.slot === slot; })[0]; + if (existing) return existing; + var node = { id: uid(), kind: 'slot', slot: slot, name: slot, children: [] }; + party.children.push(node); + return node; + } + // Create a transmittal bin. meta = { date, type:'TRN'|'SUB', seq, status?, title? }. + // The folder name follows the folder grammar; party node name doubles as the + // transmittal-number prefix (so its tracking is "--"). + function addTransmittalBin(partyId, slot, meta) { + var info = infoFor(partyId); + if (!info || info.kind !== 'party') return null; + var slotNode = ensureSlot(info.node, slot); + var bin = { id: uid(), kind: 'transmittal', name: transmittalFolderName(info.node.name, meta), meta: meta }; + slotNode.children.push(bin); + rebuildIndex(); + notify(); + return bin.id; + } + function transmittalFolderName(partyName, meta) { + var tn = [partyName, meta.type, meta.seq].filter(Boolean).join('-'); + var status = meta.status && zddc.isValidStatus(meta.status) ? meta.status : '---'; + var title = (meta.title && meta.title.trim()) || (meta.type === 'SUB' ? 'Submittal' : 'Transmittal'); + return zddc.formatFolder({ date: meta.date, trackingNumber: tn, status: status, title: title }); + } + + // ── shared node ops ────────────────────────────────────────────────────── + function renameNode(id, name) { + var info = infoFor(id); + if (!info) return; + if (info.kind === 'slot') return; // slots are fixed + info.node.name = (name || '').trim() || info.node.name; + if (info.kind === 'party') { + // Party rename re-derives child transmittal folder names (prefix). + (info.node.children || []).forEach(function (slot) { + (slot.children || []).forEach(function (bin) { + bin.name = transmittalFolderName(info.node.name, bin.meta); + }); + }); + } + rebuildIndex(); + notify(); + } + // Delete a node (and descendants). Any placement referencing a removed node + // is cleared so no file points at a ghost. + function deleteNode(id) { + var info = infoFor(id); + if (!info) return; + var removed = {}; + (function collect(n) { + removed[n.id] = true; + (n.children || []).forEach(collect); + })(info.node); + + if (info.kind === 'tracking') { + removeFrom(info.parent ? info.parent.children : state.trackingTree, id); + } else if (info.kind === 'party') { + removeFrom(state.transmittalTree, id); + } else if (info.kind === 'transmittal') { + removeFrom(info.parent.children, id); // info.parent is the slot node + } + // Clear dangling placements. + Object.keys(state.assignments).forEach(function (k) { + var a = state.assignments[k]; + if (a.trackingNodeId && removed[a.trackingNodeId]) a.trackingNodeId = null; + if (a.transmittalNodeId && removed[a.transmittalNodeId]) a.transmittalNodeId = null; + cleanAssignment(k); + }); + rebuildIndex(); + notify(); + } + function removeFrom(arr, id) { + for (var i = 0; i < arr.length; i++) { if (arr[i].id === id) { arr.splice(i, 1); return; } } + } + + // ── derive target ──────────────────────────────────────────────────────── + // Compute the full target for a file from its placements. Pure; returns + // { tracking, revision, status, title, extension, filename, outPath, + // party, slot, transmittalFolder, complete, excluded, errors:[] }. + function deriveTarget(file) { + var key = srcKeyForFile(file); + var a = state.assignments[key] || {}; + var out = { + key: key, + tracking: '', revision: '', status: '', + title: (a.titleOverride && a.titleOverride.trim()) || defaultTitle(file), + extension: file.extension || '', + filename: '', outPath: '', + party: '', slot: '', transmittalFolder: '', + trackingLeaf: false, excluded: !!a.excluded, errors: [], + }; + if (out.excluded) return out; + + // Axis 1 — tracking. + if (a.trackingNodeId) { + var ti = infoFor(a.trackingNodeId); + if (ti && ti.kind === 'tracking') { + var chain = trackingChain(ti); // [root … node] + out.tracking = chain.slice(0, -1).join('-'); // ancestors only + var leaf = parseLeafLabel(ti.node.name); + out.revision = leaf.revision; + out.status = leaf.status; + out.trackingLeaf = (ti.node.children || []).length === 0; + if (!out.tracking) out.errors.push('tracking number is empty — the file needs at least one ancestor folder'); + if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"'); + if (!out.status) out.errors.push('parent folder has no "(STATUS)" — name it like "A (IFR)"'); + if (!out.trackingLeaf) out.errors.push('not in a leaf folder yet'); + } + } else { + out.errors.push('no tracking number assigned'); + } + + // Axis 2 — transmittal → output path. + if (a.transmittalNodeId) { + var xi = infoFor(a.transmittalNodeId); + if (xi && xi.kind === 'transmittal') { + // bin → slot → party (nodeIndex stores parent as a NODE) + var slotInfo = xi.parent ? infoFor(xi.parent.id) : null; + out.slot = slotInfo ? slotInfo.node.slot : ''; + out.party = slotInfo && slotInfo.parent ? slotInfo.parent.name : ''; + out.transmittalFolder = xi.node.name; + if (out.party && out.slot && out.transmittalFolder) { + out.outPath = out.party + '/' + out.slot + '/' + out.transmittalFolder; + } + } + } else { + out.errors.push('not placed in a transmittal'); + } + + out.filename = zddc.formatFilename({ + trackingNumber: out.tracking, revision: out.revision, + status: out.status, title: out.title, extension: out.extension, + }); + if (!out.filename && out.errors.length === 0) out.errors.push('incomplete name'); + out.complete = !!(out.filename && out.outPath && out.errors.length === 0); + return out; + } + + // Files currently placed in a node (reverse lookup over all source files). + function filesInNode(nodeId, axis, allFiles) { + var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId'; + return (allFiles || []).filter(function (f) { + var a = state.assignments[srcKeyForFile(f)]; + return a && a[field] === nodeId; + }); + } + + // Per-file classification state for the left-tree markers. + // 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none' + function fileState(file) { + var a = state.assignments[srcKeyForFile(file)]; + if (!a) return 'none'; + if (a.excluded) return 'excluded'; + var t = !!a.trackingNodeId, x = !!a.transmittalNodeId; + if (t && x) { + var d = deriveTarget(file); + return d.complete ? 'done' : 'partial'; + } + if (t) return 'tracking'; + if (x) return 'transmittal'; + return 'none'; + } + + function stats(allFiles) { + var s = { total: 0, excluded: 0, none: 0, partial: 0, done: 0 }; + (allFiles || []).forEach(function (f) { + s.total++; + var st = fileState(f); + if (st === 'excluded') s.excluded++; + else if (st === 'done') s.done++; + else if (st === 'none') s.none++; + else s.partial++; // tracking | transmittal | partial + }); + return s; + } + + // ── serialize / load ───────────────────────────────────────────────────── + function serialize() { + return { + assignments: state.assignments, + trackingTree: state.trackingTree, + transmittalTree: state.transmittalTree, + outputName: state.outputName, + }; + } + function load(obj) { + if (!obj) return; + state.assignments = obj.assignments || {}; + state.trackingTree = obj.trackingTree || []; + state.transmittalTree = obj.transmittalTree || []; + state.outputName = obj.outputName || null; + rebuildIndex(); + notify(); + } + function reset() { + state.assignments = {}; state.trackingTree = []; state.transmittalTree = []; + state.outputName = null; + rebuildIndex(); + notify(); + } + + // ── add-folder pattern expansion ───────────────────────────────────────── + // Brace expansion for the add-folder box. Supports (non-nested) groups: + // {a,b,c} → alternation: a | b | c + // {0001-0002} → numeric range, zero-padded to the operands' width + // {0001-0002,0005} → mix ranges and literals in one group + // Multiple groups expand as a cartesian product, e.g. + // "X-{PM,EL}-{0001-0002,0005}_A (IFR)" → 6 names. + // A pattern with no braces returns itself (one name). Unbalanced braces are + // treated literally so the user never silently loses input. + function expandGroup(body) { + var out = []; + String(body).split(',').forEach(function (piece) { + var m = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(piece); + if (m) { + var a = m[1], b = m[2]; + var start = parseInt(a, 10), end = parseInt(b, 10); + // Pad when either operand carries a leading zero (e.g. 0001). + var width = (a.length > 1 && a[0] === '0') || (b.length > 1 && b[0] === '0') + ? Math.max(a.length, b.length) : 0; + var step = start <= end ? 1 : -1; + for (var v = start; step > 0 ? v <= end : v >= end; v += step) { + out.push(width ? String(v).padStart(width, '0') : String(v)); + } + } else { + out.push(piece); + } + }); + return out; + } + function expandFolderPattern(pattern) { + var s = String(pattern == null ? '' : pattern); + var parts = []; // each: {lit} or {opts:[...]} + var i = 0; + while (i < s.length) { + var open = s.indexOf('{', i); + if (open === -1) { parts.push({ lit: s.slice(i) }); break; } + var close = s.indexOf('}', open); + if (close === -1) { parts.push({ lit: s.slice(i) }); break; } // unbalanced → literal + if (open > i) parts.push({ lit: s.slice(i, open) }); + parts.push({ opts: expandGroup(s.slice(open + 1, close)) }); + i = close + 1; + } + var results = ['']; + parts.forEach(function (p) { + var opts = p.lit != null ? [p.lit] : p.opts; + var next = []; + results.forEach(function (prefix) { + opts.forEach(function (o) { next.push(prefix + o); }); + }); + results = next; + }); + // Trim + drop empties so a stray comma can't create a blank folder. + return results.map(function (r) { return r.trim(); }).filter(Boolean); + } + + // Parse one (already brace-expanded) folder name into the nested tracking + // levels it represents: split on "-" into tracking-number segments, then + // split the FINAL segment once on "_" to separate the last tracking segment + // from the "REV (STATUS)" leaf. So "CPO-0001_0 (IFU)" → ["CPO","0001","0 (IFU)"] + // and "BMB-187023-PM-MOM-0001_A (IFR)" → ["BMB","187023","PM","MOM","0001","A (IFR)"]. + // A name with no "-"/"_" is a single level (e.g. adding a leaf "A (IFR)"). + function parseFolderLevels(name) { + var s = String(name == null ? '' : name).trim(); + if (!s) return []; + var segs = s.split('-'); + var last = segs.pop(); + var u = last.indexOf('_'); + if (u >= 0) { segs.push(last.slice(0, u)); segs.push(last.slice(u + 1)); } + else { segs.push(last); } + return segs.map(function (x) { return x.trim(); }).filter(Boolean); + } + // Children array for a tracking node (or the roots for null), or null. + function trackingChildren(parentId) { + if (!parentId) return state.trackingTree; + var info = infoFor(parentId); + return (info && info.kind === 'tracking') ? info.node.children : null; + } + // Ensure a nested chain of tracking folders exists under parentId, reusing + // an existing child when one already has that name (so sibling leaves share + // ancestors). Returns the leaf node id. + function addTrackingPath(parentId, segments) { + var cur = parentId || null; + (segments || []).forEach(function (seg) { + var name = (seg || '').trim(); + if (!name) return; + var kids = trackingChildren(cur) || []; + var existing = kids.filter(function (n) { return n.name === name; })[0]; + cur = existing ? existing.id : addTrackingNode(cur, name); + }); + return cur; + } + + // A tracking node is a "complete" drop target when it's a leaf whose name + // carries a valid "(STATUS)" — i.e. a file dropped there yields a full name + // with no more levels needed. Used to decide whether a drop should prompt. + function trackingNodeComplete(nodeId) { + var info = infoFor(nodeId); + if (!info || info.kind !== 'tracking') return false; + if ((info.node.children || []).length) return false; + var leaf = parseLeafLabel(info.node.name); + return !!(leaf.status && zddc.isValidStatus(leaf.status)); + } + // Human-readable "root / … / node" path for a tracking node (prompt context). + function trackingPathLabel(nodeId) { + var info = infoFor(nodeId); + if (!info || info.kind !== 'tracking') return ''; + return trackingChain(info).join(' / '); + } + + // ── filename-based export/import helpers ───────────────────────────────── + // A flat, AI-friendly transmittal record for a placed file (export side). + function transmittalRecord(binId) { + var info = infoFor(binId); + if (!info || info.kind !== 'transmittal') return null; + var slot = info.parent ? infoFor(info.parent.id) : null; + var party = slot && slot.parent ? infoFor(slot.parent.id) : null; + var m = info.node.meta || {}; + return { + party: party ? party.node.name : '', + slot: slot ? slot.node.slot : '', + date: m.date || '', type: m.type || 'TRN', seq: m.seq || '', + status: m.status || '', title: m.title || '', + }; + } + // Find-or-create a party by name (import side — reuse so shared transmittals + // don't duplicate the party). + function findOrAddParty(name) { + var existing = (state.transmittalTree || []).filter(function (p) { return p.name === name; })[0]; + return existing ? existing.id : addParty(name); + } + // Find-or-create a transmittal bin under party/slot matching meta (import). + function findOrAddTransmittalBin(partyId, slot, meta) { + var pinfo = infoFor(partyId); + if (!pinfo || pinfo.kind !== 'party') return null; + var wantName = transmittalFolderName(pinfo.node.name, meta); + var slotNode = (pinfo.node.children || []).filter(function (s) { return s.slot === slot; })[0]; + if (slotNode) { + var existing = (slotNode.children || []).filter(function (b) { return b.name === wantName; })[0]; + if (existing) return existing.id; + } + return addTransmittalBin(partyId, slot, meta); + } + + // ── mode ───────────────────────────────────────────────────────────────── + function setEnabled(on) { state.enabled = !!on; notify(); } + function isEnabled() { return state.enabled; } + + window.app.modules.classify = { + // mode + setEnabled: setEnabled, isEnabled: isEnabled, + // pub/sub + on: on, + // keys/title + srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle, + // assignments + assignmentFor: assignmentFor, getAssignment: getAssignment, + place: place, setExcluded: setExcluded, + setTitleOverride: setTitleOverride, + // trees + addTrackingNode: addTrackingNode, addParty: addParty, + addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode, + expandFolderPattern: expandFolderPattern, + parseFolderLevels: parseFolderLevels, addTrackingPath: addTrackingPath, + trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel, + transmittalRecord: transmittalRecord, + findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin, + getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, + getTransmittalTree: function () { return state.transmittalTree; }, + // derive + reverse + deriveTarget: deriveTarget, filesInNode: filesInNode, + fileState: fileState, stats: stats, + // persistence + serialize: serialize, load: load, reset: reset, + getOutputName: function () { return state.outputName; }, + setOutputName: function (n) { state.outputName = n || null; notify(); }, + }; +})(); + +/** + * ZDDC Classifier — workspace manager (Classify & Copy). + * + * A workspace = one classification project: a source directory handle, a + * snapshot of its completed scan, and the Classify & Copy map. The welcome + * screen lists them; opening one resumes instantly from the snapshot (no + * re-scan), and the map autosaves as you work. Only Copy needs the live + * filesystem (a one-click permission re-grant). + */ +(function () { + 'use strict'; + + var els = {}; + var initialized = false; + var activeId = null; + var activeMeta = null; // {id,name,rootName,createdAt,updatedAt,summary} + var activeStoredHandle = null; // the workspace's persisted source dir handle + + function P() { return window.app.modules.persist; } + function C() { return window.app.modules.classify; } + function now() { return Date.now(); } + function uid() { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + try { return window.crypto.randomUUID(); } catch (_) { /* non-secure ctx */ } + } + return 'w' + now().toString(36) + Math.floor(Math.random() * 1e9).toString(36); + } + + function init() { + if (initialized) return; + initialized = true; + els = { + welcome: document.getElementById('welcomeScreen'), + list: document.getElementById('workspaceList'), + newBtn: document.getElementById('newWorkspaceBtn'), + wsBtn: document.getElementById('workspacesBtn'), + connectBtn: document.getElementById('connectDirBtn'), + }; + if (!P() || !P().available) { + // No IndexedDB → hide the workspace UI; legacy rename path still works. + var wrap = document.getElementById('workspacesSection'); + if (wrap) wrap.style.display = 'none'; + return; + } + if (els.newBtn) els.newBtn.addEventListener('click', newWorkspace); + if (els.wsBtn) els.wsBtn.addEventListener('click', showWelcome); + if (els.connectBtn) els.connectBtn.addEventListener('click', function () { tryReconnect(false); }); + if (els.list) els.list.addEventListener('click', onListClick); + + // Autosave the active workspace whenever the map changes. + C().on(scheduleAutosave); + + renderList(); + } + + // ── welcome list ──────────────────────────────────────────────────────── + function showWelcome() { + if (els.welcome) els.welcome.classList.remove('hidden'); + renderList(); + } + function hideWelcome() { + if (els.welcome) els.welcome.classList.add('hidden'); + } + + function relTime(ts) { + if (!ts) return ''; + var s = Math.max(0, Math.round((now() - ts) / 1000)); + if (s < 60) return 'just now'; + var m = Math.round(s / 60); if (m < 60) return m + 'm ago'; + var h = Math.round(m / 60); if (h < 24) return h + 'h ago'; + var d = Math.round(h / 24); return d + 'd ago'; + } + + function renderList() { + if (!els.list) return; + P().listWorkspaces().then(function (rows) { + els.list.textContent = ''; + if (!rows.length) { + var empty = document.createElement('div'); + empty.className = 'ws-empty'; + empty.textContent = 'No workspaces yet. “+ New workspace” scans a folder once and saves it here.'; + els.list.appendChild(empty); + return; + } + rows.forEach(function (r) { els.list.appendChild(rowEl(r)); }); + }); + } + function rowEl(r) { + var s = r.summary || { files: 0, done: 0, excluded: 0 }; + var row = document.createElement('div'); + row.className = 'ws-row'; + row.dataset.id = r.id; + + var main = document.createElement('div'); + main.className = 'ws-row__main'; + var nm = document.createElement('div'); nm.className = 'ws-row__name'; nm.textContent = r.name; + var meta = document.createElement('div'); meta.className = 'ws-row__meta'; + meta.textContent = (r.rootName || '') + ' · ' + s.done + '/' + s.files + ' classified' + + (s.excluded ? (' · ' + s.excluded + ' excluded') : '') + ' · updated ' + relTime(r.updatedAt); + main.appendChild(nm); main.appendChild(meta); + + var actions = document.createElement('div'); + actions.className = 'ws-row__actions'; + [['open', 'Open'], ['rename', 'Rename'], ['delete', 'Delete']].forEach(function (a) { + var b = document.createElement('button'); + b.className = 'btn btn-sm ' + (a[0] === 'open' ? 'btn-primary' : 'btn-secondary'); + b.dataset.act = a[0]; b.textContent = a[1]; + actions.appendChild(b); + }); + row.appendChild(main); row.appendChild(actions); + return row; + } + function onListClick(e) { + var btn = e.target.closest('[data-act]'); + if (!btn) return; + var row = btn.closest('.ws-row'); + var id = row && row.dataset.id; + if (!id) return; + if (btn.dataset.act === 'open') openWorkspace(id); + else if (btn.dataset.act === 'rename') renameWorkspace(id); + else if (btn.dataset.act === 'delete') deleteWorkspace(id); + } + + // ── summary ─────────────────────────────────────────────────────────── + function allFiles() { + var out = []; + (function walk(ns) { (ns || []).forEach(function (n) { (n.files || []).forEach(function (f) { out.push(f); }); walk(n.children); }); })(window.app.folderTree || []); + return out; + } + function summary() { + var s = C().stats(allFiles()); + return { files: s.total, done: s.done, excluded: s.excluded }; + } + + // ── create / open / rename / delete ───────────────────────────────────── + async function newWorkspace() { + if (!window.showDirectoryPicker) { + window.zddc.toast('Workspaces need the File System Access API (use Chromium).', 'error'); + return; + } + var dir; + try { dir = await window.showDirectoryPicker(); } + catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open folder — ' + (e.message || e), 'error'); return; } + + var name = prompt('Name this workspace:', dir.name); + if (name === null) name = dir.name; + name = name.trim() || dir.name; + + window.app.rootHandle = dir; + activeStoredHandle = dir; + window.app.modules.app.enterAppShell(); + window.app.modules.app.setMode('classify'); + hideWelcome(); + + activeId = uid(); + activeMeta = { id: activeId, name: name, rootName: dir.name, createdAt: now(), updatedAt: now(), summary: { files: 0, done: 0, excluded: 0 } }; + // Create the record UP FRONT so an interrupted scan survives and resumes. + await saveSnapshotFull(); + updateConnectUI(); + + // Periodically persist the partial snapshot during the (slow) scan, so an + // interruption resumes from where it left off instead of starting over. + var iv = setInterval(saveSnapshotFull, 5000); + try { await window.app.modules.scanner.scanDirectory(dir); } + finally { clearInterval(iv); saveSnapshotFull(); } + } + + async function openWorkspace(id) { + var rec = await P().getWorkspace(id); + var rows = await P().listWorkspaces(); + var meta = rows.filter(function (r) { return r.id === id; })[0]; + if (!rec || !meta) { window.zddc.toast('Could not load that workspace.', 'error'); return; } + + activeId = id; + activeMeta = meta; + activeStoredHandle = rec.rootHandle || null; + window.app.rootHandle = null; // not connected until reconnect + window.app.modules.app.enterAppShell(); + window.app.modules.scanner.loadSnapshot(rec.tree || []); + C().load(rec.classify || {}); + window.app.modules.app.setMode('classify'); + hideWelcome(); + + // Offer to reconnect the source directory (needed to preview, copy, or + // finish an interrupted scan). Silent if permission is already granted. + await tryReconnect(true); + updateConnectUI(); + } + + // Persist the full workspace (meta + snapshot + map + source handle). + function saveSnapshotFull() { + if (!activeId || !activeMeta) return Promise.resolve(); + activeMeta.updatedAt = now(); + activeMeta.summary = summary(); + return P().putWorkspace(activeMeta, { + id: activeId, + rootHandle: window.app.rootHandle || activeStoredHandle || null, + tree: window.app.modules.scanner.snapshotTree(), + classify: C().serialize(), + }); + } + + // Connect (or reconnect) the source directory. silentOnly=true never shows a + // permission prompt or picker — it only adopts an already-granted handle and + // otherwise nudges the user to click "Connect directory". + async function tryReconnect(silentOnly) { + var h = activeStoredHandle; + if (h && typeof h.queryPermission === 'function') { + var p = 'denied'; + try { p = await h.queryPermission({ mode: 'read' }); } catch (_) { /* ignore */ } + if (p === 'granted') { window.app.rootHandle = h; return afterConnect(); } + if (!silentOnly) { + var p2 = 'denied'; + try { p2 = await h.requestPermission({ mode: 'read' }); } catch (_) { /* ignore */ } + if (p2 === 'granted') { window.app.rootHandle = h; return afterConnect(); } + } + } + if (silentOnly) { + if (!window.app.rootHandle && activeId) { + window.zddc.toast('This workspace’s source directory isn’t connected — click “Connect directory” to preview, copy, or finish scanning.', 'info', { durationMs: 8000 }); + } + return false; + } + // Explicit: no usable stored handle (or permission denied) → let the user pick. + if (!window.showDirectoryPicker) { window.zddc.toast('Connecting a directory needs the File System Access API.', 'error'); return false; } + try { + var picked = await window.showDirectoryPicker(); + window.app.rootHandle = picked; + activeStoredHandle = picked; + return afterConnect(); + } catch (e) { + if (e.name !== 'AbortError') window.zddc.toast('Could not connect directory — ' + (e.message || e), 'error'); + return false; + } + } + + async function afterConnect() { + updateConnectUI(); + // Resume any still-pending folders now that we have the handle. + var did = await window.app.modules.scanner.resumeScan(window.app.rootHandle); + saveSnapshotFull(); // persist refreshed snapshot + the (re-granted) handle + return true; + } + + function updateConnectUI() { + if (!els.connectBtn) return; + var show = !!activeId && !window.app.rootHandle; + els.connectBtn.hidden = !show; + } + + function renameWorkspace(id) { + P().listWorkspaces().then(function (rows) { + var meta = rows.filter(function (r) { return r.id === id; })[0]; + if (!meta) return; + var name = prompt('Rename workspace:', meta.name); + if (!name || !name.trim()) return; + meta.name = name.trim(); meta.updatedAt = now(); + if (activeMeta && activeMeta.id === id) activeMeta.name = meta.name; + P().putWorkspace(meta, null).then(renderList); + }); + } + function deleteWorkspace(id) { + if (!confirm('Delete this workspace? The map and snapshot are removed — your source files are untouched.')) return; + if (activeId === id) { activeId = null; activeMeta = null; } + P().deleteWorkspace(id).then(renderList); + } + + // ── autosave (debounced) ──────────────────────────────────────────────── + var saveTimer = null; + function scheduleAutosave() { + if (!activeId || !activeMeta) return; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(function () { + saveTimer = null; + activeMeta.updatedAt = now(); + activeMeta.summary = summary(); + // classify-only put: tree omitted → the stored snapshot is preserved. + P().putWorkspace(activeMeta, { id: activeId, classify: C().serialize() }); + }, 500); + } + + // Called after a "Refresh from disk" rescan — re-persist the snapshot for + // the active workspace (the path-keyed map carries over automatically). + function onRescanned() { + if (!activeId || !activeMeta) return; + activeMeta.updatedAt = now(); + activeMeta.summary = summary(); + P().putWorkspace(activeMeta, { + id: activeId, + tree: window.app.modules.scanner.snapshotTree(), + classify: C().serialize(), + }); + } + + window.app.modules.workspace = { + init: init, + showWelcome: showWelcome, + newWorkspace: newWorkspace, + openWorkspace: openWorkspace, + onRescanned: onRescanned, + renderList: renderList, + activeId: function () { return activeId; }, + }; +})(); + +/** + * ZDDC Classifier — drag payload bus for Classify & Copy. + * + * HTML5 dataTransfer can't be read during `dragover` (only on `drop`), and we + * need the dragged set to drive drop-target highlighting. So the source keys + * live in a module variable for the lifetime of a drag; dataTransfer carries a + * marker so the browser shows a copy cursor and external drops are ignored. + */ +(function () { + 'use strict'; + + var keys = []; + + function setDrag(srcKeys, e) { + keys = (srcKeys || []).slice(); + if (e && e.dataTransfer) { + e.dataTransfer.effectAllowed = 'copy'; + try { e.dataTransfer.setData('application/x-zddc-keys', keys.join('\n')); } catch (_) { /* ok */ } + } + } + function getDrag() { return keys; } + function active() { return keys.length > 0; } + function clearDrag() { keys = []; } + + window.app.modules.dnd = { + setDrag: setDrag, getDrag: getDrag, active: active, clearDrag: clearDrag, + }; +})(); + /** * ZDDC Validation Module * Validates file names against ZDDC conventions using the shared zddc library. @@ -6422,6 +8160,15 @@ X.B(E,Y);return E}return J}()) let scanGen = 0; // bumped per scan; stale workers bail let scanStats = null; // { folders, files, current, done, startedAt } let renderTimer = null; // throttle for progressive re-render + // How many directories to read in flight at once. The scan is I/O-bound — + // each readdir is a round-trip to the backing store (cloud-sync / network + // mounts like OneDrive or Samba have high per-op latency), so the lever is + // parallel in-flight reads, not CPU threads. This only helps the + // many-folders case; a single fat folder is enumerated one entry at a time + // by the File System Access API and can't be parallelized. Raise it if the + // store tolerates more concurrency; too high risks cloud-provider + // throttling. + var SCAN_CONCURRENCY = 32; function scheduleRender() { if (renderTimer) return; @@ -6438,18 +8185,27 @@ X.B(E,Y);return E}return J}()) updateScanStatus(); } - // Render the running scan status into the tree-pane header. + // elapsed since the scan started, e.g. "3.2s" or "1m 04s". + function elapsedStr() { + if (!scanStats) return '0s'; + const ms = Date.now() - scanStats.startedAt; + if (ms < 60000) return (ms / 1000).toFixed(1) + 's'; + const m = Math.floor(ms / 60000); + const s = Math.round((ms % 60000) / 1000); + return m + 'm ' + (s < 10 ? '0' : '') + s + 's'; + } + + // Render the running scan status (with live elapsed time) into the footer. function updateScanStatus() { const el = document.getElementById('scanStatus'); if (!el || !scanStats) return; if (scanStats.done) { - const secs = ((Date.now() - scanStats.startedAt) / 1000).toFixed(1); el.textContent = 'Scanned ' + scanStats.folders + ' folders · ' - + scanStats.files + ' files in ' + secs + 's'; + + scanStats.files + ' files in ' + elapsedStr(); el.classList.remove('scanning'); } else { el.textContent = 'Scanning… ' + scanStats.folders + ' folders · ' - + scanStats.files + ' files' + + scanStats.files + ' files · ' + elapsedStr() + (scanStats.current ? ' — ' + scanStats.current : ''); el.classList.add('scanning'); } @@ -6497,14 +8253,56 @@ X.B(E,Y);return E}return J}()) } } - // One-shot toast for scan errors (permission denied, network hiccups on a - // share). De-duped per path so a flaky folder doesn't spam. + // Translate a File System Access API error into accurate, actionable text. + // The browser's raw DOMException messages are cryptic and often read like a + // permission problem when they aren't — we key off err.name (reliable) + // rather than the message. Returns a plain-language explanation; the raw + // name + message are still appended by the caller for troubleshooting. + function describeFsError(err) { + var name = err && err.name ? err.name : ''; + switch (name) { + case 'NotAllowedError': + return 'Permission to read this folder was denied or revoked. ' + + 'Re-pick the root folder to re-grant access.'; + case 'InvalidStateError': + // The handle was read once, then the directory changed underneath + // it (common on a network/SMB share that's being written to, or + // after a disconnect/reconnect). NOT a permissions problem. + return 'The folder changed on disk since it was first read ' + + '(common on a busy or reconnecting network share). ' + + 'Rescan to pick up the current contents.'; + case 'NotFoundError': + return 'The folder no longer exists — it may have been moved, ' + + 'renamed, or deleted since the scan started.'; + case 'NotReadableError': + return 'The folder could not be read — the share may have ' + + 'disconnected, or the OS denied access.'; + case 'SecurityError': + return 'The browser blocked access to this folder for security ' + + 'reasons.'; + case 'TypeMismatchError': + return 'Expected a folder here but found a file (or vice-versa).'; + case 'AbortError': + return 'Reading this folder was aborted.'; + default: + return 'Could not read this folder.'; + } + } + + // One-shot toast for scan errors (permission denied, stale handles, network + // hiccups on a share). De-duped per path so a flaky folder doesn't spam. const scanErrorsSeen = new Set(); function reportScanError(path, err) { console.error('Scan error:', path, err); if (scanErrorsSeen.has(path)) return; scanErrorsSeen.add(path); - const msg = 'Couldn’t scan ' + path + ': ' + (err && err.message ? err.message : err); + // Plain-language explanation, then the raw error in parentheses so the + // user can copy it (toasts are selectable) for deeper troubleshooting. + var raw = err && err.name + ? err.name + (err.message ? ': ' + err.message : '') + : (err && err.message ? err.message : String(err)); + var msg = 'Couldn’t scan ' + path + ' — ' + describeFsError(err) + + '\n\n(' + raw + ')'; if (window.zddc && typeof window.zddc.toast === 'function') { window.zddc.toast(msg, 'error'); } @@ -6545,42 +8343,70 @@ X.B(E,Y);return E}return J}()) } flushRender(); - // Breadth-first by level behind a bounded worker pool: level 1, then - // level 2, … each rendered as it lands (top levels appear first). - // Deeper levels keep filling in; workers await between directories so - // the UI stays responsive on a slow/large network drive. - let level = [root]; - while (level.length && myGen === scanGen) { - await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); }); - const next = []; - for (const n of level) { - for (const c of n.children) { - if (preserveState && savedExpanded.has(c.path)) c.expanded = true; - if (c.scanState === 'pending') next.push(c); - } - } - level = next; + // Tick the footer's elapsed time once a second even if no new folder + // landed (so a slow directory doesn't make the timer look frozen). + const ticker = setInterval(function () { + if (myGen !== scanGen || (scanStats && scanStats.done)) { clearInterval(ticker); return; } + updateScanStatus(); + }, 1000); + + // Continuous breadth-first walk: up to SCAN_CONCURRENCY directory reads + // in flight at once, pulling newly-discovered child dirs as they land + // (no per-level barrier, so the pool stays saturated). Top levels still + // appear first (FIFO). The cap is the lever — see SCAN_CONCURRENCY. + await drainQueue([root], myGen, SCAN_CONCURRENCY); + if (preserveState && savedExpanded.size) { + restoreExpandedPaths(window.app.folderTree, savedExpanded); } + clearInterval(ticker); if (myGen !== scanGen) return; // superseded by a newer scan scanStats.done = true; scanStats.current = ''; flushRender(); + + // Completion toast with the totals + elapsed time. + if (window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast( + 'Scan complete — ' + scanStats.folders + ' folders, ' + + scanStats.files + ' files in ' + elapsedStr() + '.', + 'success'); + } } - // Run fn over items with at most `limit` concurrent calls; resolves when - // all have settled. Termination is clean (no transient-empty-queue race). - async function runWithConcurrency(items, limit, fn) { - let i = 0; - async function runner() { - while (i < items.length) { - const idx = i++; - await fn(items[idx]); + // Continuous worker pool over a shared queue: keep up to `conc` directory + // reads in flight at once, pulling newly-discovered child dirs as they land + // — no per-level barrier, so workers never idle waiting on the slowest dir + // in a level. Roughly breadth-first (FIFO; a node's children are enqueued + // after it), so top levels still surface first. Resolves when the queue is + // drained and no read is in flight (clean termination, no empty-queue race). + function drainQueue(seed, myGen, conc) { + const queue = seed.slice(); + let active = 0; + return new Promise(function (resolve) { + function finishIfIdle() { + if (queue.length === 0 && active === 0) resolve(); } - } - const runners = []; - for (let k = 0; k < Math.min(limit, items.length); k++) runners.push(runner()); - await Promise.all(runners); + function pump() { + while (myGen === scanGen && active < conc && queue.length) { + const node = queue.shift(); + active++; + Promise.resolve(scanNodeChildren(node, myGen)).then(function () { + active--; + if (myGen === scanGen) { + const kids = node.children; + for (let i = 0; i < kids.length; i++) { + if (kids[i].scanState === 'pending') queue.push(kids[i]); + } + } + pump(); + finishIfIdle(); + }, function () { active--; pump(); finishIfIdle(); }); + } + finishIfIdle(); + } + pump(); + }); } // Force a folder's subtree to scan NOW (jumped ahead of the background @@ -6588,16 +8414,7 @@ X.B(E,Y);return E}return J}()) // shows complete contents. Idempotent + shares the live scan generation. async function ensureScanned(node) { if (!node || !node.handle || node.scanState === 'done') return; - const myGen = scanGen; - let level = [node]; - while (level.length && myGen === scanGen) { - await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); }); - const next = []; - for (const n of level) { - for (const c of n.children) if (c.scanState === 'pending') next.push(c); - } - level = next; - } + await drainQueue([node], scanGen, SCAN_CONCURRENCY); flushRender(); } @@ -6635,6 +8452,8 @@ X.B(E,Y);return E}return J}()) // only a 'pending' node is scanned, so concurrent callers (background + // open-prioritised) don't double-scan. async function scanNodeChildren(node, myGen) { + // A .zip is a lazy node — read its contents only when opened. + if (node.scanState === 'zip-pending') { await scanZipNode(node); return; } if (node.scanState !== 'pending') return; node.scanState = 'scanning'; if (scanStats) scanStats.current = node.path; @@ -6644,18 +8463,19 @@ X.B(E,Y);return E}return J}()) for await (const entry of node.handle.values()) { if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled if (entry.kind === 'file') { - const fo = await createFileObject(entry, node.handle); - if (!fo) continue; + const fo = createFileObject(entry, node.handle); fo.folderPath = node.path; files.push(fo); if (scanStats) scanStats.files++; if (fo.extension === 'zip' && typeof JSZip !== 'undefined') { + // Don't read the archive during the listing — make an + // expandable, lazy zip node scanned on open (scanZipNode). const zipName = zddc.joinExtension(fo.originalFilename, fo.extension); const zipPath = node.path + '/' + zipName; const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath }; const zipNode = makeNode(zh, zipPath, node); - try { await scanZipIntoNode(zipNode, fo); } - catch (e) { reportScanError(zipPath, e); zipNode.scanState = 'done'; } + zipNode._zipFileObj = fo; + zipNode.scanState = 'zip-pending'; childDirs.push(zipNode); if (scanStats) scanStats.folders++; } @@ -6673,18 +8493,15 @@ X.B(E,Y);return E}return J}()) node.fileCount = files.length; node.children = childDirs; node.subdirCount = childDirs.length; - // Roll this folder's own files/dirs (plus the full contents of any - // inline-zip children) into the running subtree totals of this node - // and every ancestor. Regular child dirs add their own share when they - // get scanned — that's how the total fills in progressively. - let addF = files.length; - let addD = childDirs.length; - for (const c of childDirs) { - if (c.scanState === 'done') { addF += c.runFiles; addD += c.runDirs; } - } + // Roll this folder's own files/dirs into the running subtree totals of + // this node + every ancestor. Real child dirs add their share when they + // get scanned; lazy zip nodes add theirs when opened (scanZipNode). + const addF = files.length; + const addD = childDirs.length; for (let a = node; a; a = a.parent) { a.runFiles += addF; a.runDirs += addD; } - // Zip children are scanned inline ('done'); real dirs are still pending. - node.pending = childDirs.filter(function (c) { return c.scanState !== 'done'; }).length; + // Only real unscanned dirs hold the parent open; zip-pending children + // are lazy, so they don't. + node.pending = childDirs.filter(function (c) { return c.scanState === 'pending'; }).length; if (node.pending === 0) { markDone(node); } else { @@ -6693,6 +8510,42 @@ X.B(E,Y);return E}return J}()) scheduleRender(); } + // Read a lazy zip node's contents on demand (when opened), building its + // child nodes and folding its internal totals into ancestors. + async function scanZipNode(node) { + if (node.scanState !== 'zip-pending') return; + var fileObj = node._zipFileObj; + if (!fileObj) { + // Restored from a snapshot — no live file object. Resolve the .zip + // from the workspace root by its path so it can be opened on demand. + if (!window.app.rootHandle || !node.zipPath) return; + try { + var dir = await resolveDirHandle(window.app.rootHandle, relFromRoot(parentPath(node.zipPath))); + fileObj = { handle: await dir.getFileHandle(baseName(node.zipPath)), folderHandle: dir }; + } catch (e) { + reportScanError(node.path, e); node.scanState = 'done'; node.runFiles = 0; node.runDirs = 0; return; + } + } + node.scanState = 'scanning'; + scheduleRender(); + try { + await scanZipIntoNode(node, fileObj); // builds children, runFiles/runDirs, sets 'done' + } catch (e) { + reportScanError(node.path, e); + node.scanState = 'done'; + node.runFiles = 0; + node.runDirs = 0; + } + node._zipFileObj = null; + // The zip counted as 1 dir in its parent already; now fold in its + // internal files/dirs to every ancestor's running totals. + for (let a = node.parent; a; a = a.parent) { + a.runFiles += node.runFiles; + a.runDirs += node.runDirs; + } + scheduleRender(); + } + // Build a zip-root node's children from its archive contents (in memory), // marking the whole zip subtree 'done' immediately. Mirrors the on-disk // node shape so the rest of the app treats zip folders like real ones. @@ -7024,37 +8877,197 @@ X.B(E,Y);return E}return J}()) /** * Create file object with metadata */ - async function createFileObject(fileHandle, folderHandle) { - try { - const file = await fileHandle.getFile(); - const split = zddc.splitExtension(file.name); + // Build a file row from JUST the directory entry — no getFile(). Listing a + // network share is already slow; the old code opened EVERY file to read + // size/lastModified (which the grid doesn't even display), turning a + // listing into one network round-trip per file. size/lastModified are now + // loaded on demand by preview / SHA / rename, which call getFile() + // themselves. The scan is now a pure directory listing. + function createFileObject(fileHandle, folderHandle) { + const split = zddc.splitExtension(fileHandle.name); + return { + handle: fileHandle, + folderHandle: folderHandle, + originalFilename: split.name, + extension: split.extension, + size: null, + lastModified: null, + // Editable fields + trackingNumber: '', + revision: '', + status: '', + title: '', + // State + isDirty: false, + error: false, + errorMessage: '', + validation: null, + sha256: null + // folderPath added by the caller. + }; + } - return { - handle: fileHandle, - folderHandle: folderHandle, - originalFilename: split.name, - extension: split.extension, - size: file.size, - lastModified: file.lastModified, - - // Editable fields - trackingNumber: '', - revision: '', - status: '', - title: '', - - // State - isDirty: false, - error: false, - errorMessage: '', - validation: null, - sha256: null - // folderPath will be added later in buildTree - }; - } catch (err) { - console.error('Error reading file:', fileHandle.name, err); - return null; + // ── Workspace snapshot (scan once, resume without re-walking the FS) ──── + + // Serialize the completed scan to compact JSON (short keys: large trees). + // Zip subtrees ARE preserved: a scanned archive keeps its virtual folders + + // members so classifications inside it survive reopen; copy/preview re-load + // the archive lazily from the root (ensureZipLoaded). An archive that was + // never opened persists as a lazy 'zip' node that reopens on demand. + function snapshotTree() { + function serFile(f) { + var o = { o: f.originalFilename, e: f.extension, p: f.folderPath }; + if (f.isVirtual) { o.z = f.zipPath; o.ze = f.zipEntryPath; } // zip member + return o; } + function serNode(n) { + var o = { n: n.name, p: n.path }; + if (n.isZipRoot) o.zr = 1; // archive root (zipPath === n.path) + else if (n.isVirtualDir) o.vd = n.zipPath; // folder inside an archive + if (n.files && n.files.length) o.f = n.files.map(serFile); + if (n.children && n.children.length) o.c = n.children.map(serNode); + // Record scan progress so an interrupted scan can resume: 'children' + // = direct entries fully read (kids may still be pending); anything + // unfinished → 'pending' to re-read. An unopened archive persists as + // 'zip' (reopen lazily, never a real dir re-walk). 'done' is the + // default and omitted. + var st = n.scanState; + if (n.isZipRoot && st !== 'done') o.s = 'zip'; + else if (st && st !== 'done') o.s = (st === 'children') ? 'children' : 'pending'; + return o; + } + return (window.app.folderTree || []).map(serNode); + } + + // Rebuild window.app.folderTree from a snapshot — handle-less nodes, marked + // 'done', subtree totals recomputed. Handles are resolved lazily from the + // workspace root handle at copy/preview time. + function loadSnapshot(snap) { + function deFile(sf) { + var fo = { + handle: null, folderHandle: null, + originalFilename: sf.o, extension: sf.e, + size: null, lastModified: null, + trackingNumber: '', revision: '', status: '', title: '', + isDirty: false, error: false, errorMessage: '', validation: null, sha256: null, + folderPath: sf.p, + }; + if (sf.z) { fo.isVirtual = true; fo.zipPath = sf.z; fo.zipEntryPath = sf.ze; } + return fo; + } + function deNode(sn, parent) { + var desc = { name: sn.n, kind: 'directory' }; + if (sn.zr) { desc.isZipRoot = true; desc.zipPath = sn.p; } + else if (sn.vd) { desc.isVirtualDir = true; desc.zipPath = sn.vd; } + var node = makeNode(desc, sn.p, parent); + node.handle = null; + if (sn.zr || sn.vd) node.virtualPath = sn.p; + // 'zip' restores an unopened archive (reopen lazily); else resume marker. + node.scanState = sn.s === 'zip' ? 'zip-pending' : (sn.s || 'done'); + node.expanded = false; + node.files = (sn.f || []).map(deFile); + node.children = (sn.c || []).map(function (c) { return deNode(c, node); }); + node.fileCount = node.files.length; + node.subdirCount = node.children.length; + return node; + } + var roots = (snap || []).map(function (sn) { return deNode(sn, null); }); + if (roots[0]) roots[0].expanded = true; + (function totals(nodes) { + nodes.forEach(function (n) { + totals(n.children); + var rf = n.files.length, rd = n.children.length; + n.children.forEach(function (c) { rf += c.runFiles; rd += c.runDirs; }); + n.runFiles = rf; n.runDirs = rd; + }); + })(roots); + window.app.folderTree = roots; + if (window.app.modules.store && window.app.modules.store.setFolderTree) { + window.app.modules.store.setFolderTree(roots); + } + return roots; + } + + // ── Lazy handle resolution (snapshot files carry paths, not handles) ──── + function relFromRoot(p) { var i = (p || '').indexOf('/'); return i < 0 ? '' : p.slice(i + 1); } + function parentPath(p) { var i = (p || '').lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); } + function baseName(p) { var i = (p || '').lastIndexOf('/'); return i < 0 ? p : p.slice(i + 1); } + // Load (and cache) a zip archive by its tree path. After a snapshot restore + // the in-memory cache is empty, so resolve the .zip from the workspace root + // and parse it on demand. Returns the cache record { zip, fileHandle, ... }. + async function ensureZipLoaded(rootHandle, zipPath) { + var cached = zipCache.get(zipPath); + if (cached && cached.zip) return cached; + if (!rootHandle) throw new Error('source directory not connected'); + var dir = await resolveDirHandle(rootHandle, relFromRoot(parentPath(zipPath))); + var fh = await dir.getFileHandle(baseName(zipPath)); + var zip = await JSZip.loadAsync(await (await fh.getFile()).arrayBuffer()); + var rec = { zip: zip, fileHandle: fh, folderHandle: dir }; + zipCache.set(zipPath, rec); + return rec; + } + // Read a zip member's bytes as a Blob (lazily loading its archive). + async function extractZipMember(rootHandle, fileObj) { + var rec = await ensureZipLoaded(rootHandle, fileObj.zipPath); + var entry = rec.zip.file(fileObj.zipEntryPath); + if (!entry) throw new Error('zip member not found: ' + fileObj.zipEntryPath); + return await entry.async('blob'); + } + async function resolveDirHandle(rootHandle, relPath) { + var cur = rootHandle; + var parts = (relPath || '').split('/').filter(Boolean); + for (var i = 0; i < parts.length; i++) { cur = await cur.getDirectoryHandle(parts[i]); } + return cur; + } + // Resolve (and cache) a file object's handle from the workspace root. + async function resolveFileHandle(rootHandle, fileObj) { + if (fileObj.handle) return fileObj.handle; + var dir = await resolveDirHandle(rootHandle, relFromRoot(fileObj.folderPath)); + var name = zddc.joinExtension(fileObj.originalFilename, fileObj.extension); + var h = await dir.getFileHandle(name); + fileObj.handle = h; + fileObj.folderHandle = dir; + return h; + } + + // Resume an interrupted scan: walk the loaded tree for 'pending' folders, + // resolve their handles from the (reconnected) root, and drain only those — + // already-scanned folders are left alone. Returns true if work was done. + async function resumeScan(rootHandle) { + if (!rootHandle) return false; + var pend = []; + (function walk(ns) { + (ns || []).forEach(function (n) { + if (n.scanState === 'pending') pend.push(n); + else walk(n.children); + }); + })(window.app.folderTree || []); + if (!pend.length) return false; + + var myGen = ++scanGen; + zipCache.clear(); + scanStats = { folders: 0, files: 0, current: '', done: false, startedAt: Date.now() }; + var ticker = setInterval(function () { + if (myGen !== scanGen || (scanStats && scanStats.done)) { clearInterval(ticker); return; } + updateScanStatus(); + }, 1000); + + for (var i = 0; i < pend.length; i++) { + try { pend[i].handle = await resolveDirHandle(rootHandle, relFromRoot(pend[i].path)); } + catch (e) { pend[i].scanState = 'done'; reportScanError(pend[i].path, e); } + } + await drainQueue(pend.filter(function (n) { return n.handle; }), myGen, SCAN_CONCURRENCY); + + clearInterval(ticker); + if (myGen !== scanGen) return true; + scanStats.done = true; + scanStats.current = ''; + flushRender(); + if (window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast('Resumed scan complete — ' + scanStats.folders + ' folders, ' + + scanStats.files + ' files added in ' + elapsedStr() + '.', 'success'); + } + return true; } // Export module @@ -7062,7 +9075,14 @@ X.B(E,Y);return E}return J}()) scanDirectory, ensureScanned, getZipCache, - extractZip + extractZip, + snapshotTree, + loadSnapshot, + resolveFileHandle, + resolveDirHandle, + ensureZipLoaded, + extractZipMember, + resumeScan }; })(); @@ -7074,12 +9094,163 @@ X.B(E,Y);return E}return J}()) (function() { 'use strict'; + // ── Classify & Copy helpers ──────────────────────────────────────────── + function classifyOn() { + var c = window.app.modules.classify; + return c && c.isEnabled(); + } + // All file objects in a folder's (already-scanned) subtree — group-drag. + function subtreeFiles(folder, out) { + out = out || []; + (folder.files || []).forEach(function (f) { out.push(f); }); + (folder.children || []).forEach(function (c) { subtreeFiles(c, out); }); + return out; + } + function keysFor(files) { + var c = window.app.modules.classify; + return files.map(function (f) { return c.srcKeyForFile(f); }); + } + // A small status dot reflecting a file's classification state. + var STATE_TITLE = { + none: 'unassigned', tracking: 'has tracking number, needs a transmittal', + transmittal: 'in a transmittal, needs a tracking number', + partial: 'placed, but the name is incomplete', done: 'fully classified', + excluded: 'excluded — will not be copied', + }; + function stateDot(state) { + var dot = document.createElement('span'); + dot.className = 'cl-dot cl-dot--' + state; + dot.title = STATE_TITLE[state] || ''; + return dot; + } + + // ── Classify-mode source-tree filters ────────────────────────────────── + // The goal in either target tab is to assign-or-exclude every file. Each + // file falls in one bucket FOR THE ACTIVE AXIS — unassigned / assigned / + // excluded — and three "Show …" toggles control which buckets are visible + // (so unchecking Assigned+Excluded leaves only what's left to do). A folder + // whose whole scanned subtree is filtered away is itself hidden. + var showFilters = { unassigned: true, assigned: true, excluded: true }; + var showEmpty = true; // show folders that contain no files + function setShowFilters(f) { + showFilters = { + unassigned: f.unassigned !== false, + assigned: f.assigned !== false, + excluded: f.excluded !== false, + }; + showEmpty = f.empty !== false; + render(); + } + function allFiltersOn() { return showFilters.unassigned && showFilters.assigned && showFilters.excluded; } + function activeAxis() { + var tt = window.app.modules.targetTree; + return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking'; + } + // Bucket a file relative to the active axis: 'excluded' | 'assigned' | 'unassigned'. + function fileCategory(file) { + var c = window.app.modules.classify; + var a = c.getAssignment(c.srcKeyForFile(file)); + if (a && a.excluded) return 'excluded'; + var assigned = a && (activeAxis() === 'transmittal' ? a.transmittalNodeId : a.trackingNodeId); + return assigned ? 'assigned' : 'unassigned'; + } + function classifyAllows(file) { return !classifyOn() || !!showFilters[fileCategory(file)]; } + + // ── name filter (the autofilter box above the tree) ──────────────────── + // Live substring search over each file's full path+name (and folder names), + // ANDing space-separated terms. Matches reveal their whole folder hierarchy. + var nameFilter = '', filterTerms = []; + function setNameFilter(q) { + nameFilter = (q || '').trim(); + filterTerms = nameFilter.toLowerCase().split(/\s+/).filter(Boolean); + render(); + } + function filterActive() { return filterTerms.length > 0; } + function nameHit(text) { + if (!filterTerms.length) return true; + var t = String(text || '').toLowerCase(); + for (var i = 0; i < filterTerms.length; i++) { if (t.indexOf(filterTerms[i]) === -1) return false; } + return true; + } + // Anything narrowing the tree (a name search, a show-filter off, or hiding empties). + function anyFilter() { return filterActive() || (classifyOn() && (!allFiltersOn() || !showEmpty)); } + // One pass → the set of folder paths + file keys to render. A file shows when + // it passes the show-filters AND (no name search, OR an ancestor folder + // matched, OR its own path/name matches). A folder shows when it (or an + // ancestor) matches, or anything inside it shows — so the path to a hit is + // always revealed. + var visible = null; // { folders, files } while filtering, else null + function computeVisible() { + var c = window.app.modules.classify; + var folders = Object.create(null), files = Object.create(null), open = Object.create(null); + var nf = filterActive(); + function walk(folder, ancMatched) { + var selfMatch = nf && nameHit(folder.path || folder.name); + var matched = ancMatched || selfMatch; + var show = false, hasFile = false, descMatch = false; + (folder.children || []).forEach(function (ch) { + var r = walk(ch, matched); + if (r.show) show = true; + if (r.hasFile) hasFile = true; + if (r.subtreeMatch) descMatch = true; // a child leads to a match + }); + (folder.files || []).forEach(function (f) { + hasFile = true; + if (!classifyAllows(f)) return; + var fileMatch = nf && nameHit(c.srcKeyForFile(f)); + if (!nf || matched || fileMatch) { files[c.srcKeyForFile(f)] = true; show = true; } + if (fileMatch) descMatch = true; // a match sits directly in this folder + }); + if (matched) show = true; + // "Show Empty" off → hide folders whose whole subtree holds no files. + if (!hasFile && !showEmpty && !matched) show = false; + if (show) folders[folder.path] = true; + // Auto-open ONLY the connector folders on the path down to a match — + // never the matched node itself. Terminal matches and everything + // off-path keep their real collapse state; the root's expand-all + // covers the rest. (Search reveals where hits are; it doesn't reshape + // the tree.) + if (nf && descMatch) open[folder.path] = true; + return { show: show, hasFile: hasFile, subtreeMatch: descMatch || selfMatch }; + } + (window.app.folderTree || []).forEach(function (root) { walk(root, false); }); + return { folders: folders, files: files, open: open }; + } + // True only for folders the search needs opened to expose a hit beneath them. + function autoOpen(folder) { return !!(visible && visible.open && visible.open[folder.path]); } + function folderShown(folder) { return !visible || !!visible.folders[folder.path]; } + function fileShown(file) { + if (!classifyAllows(file)) return false; + return !visible || !!visible.files[window.app.modules.classify.srcKeyForFile(file)]; + } + // All scanned files (for the per-bucket counts on the filter checkboxes). + function allClassifyFiles() { + var out = []; + (window.app.folderTree || []).forEach(function (f) { subtreeFiles(f, out); }); + return out; + } + function updateFilterCounts() { + if (!classifyOn()) return; + var n = { unassigned: 0, assigned: 0, excluded: 0 }; + allClassifyFiles().forEach(function (f) { n[fileCategory(f)]++; }); + ['unassigned', 'assigned', 'excluded'].forEach(function (k) { + var el = document.getElementById('show' + k.charAt(0).toUpperCase() + k.slice(1) + 'Count'); + if (el) el.textContent = '(' + n[k] + ')'; + }); + } + /** * Render the folder tree */ function render() { const container = window.app.dom.folderTree; + // Preserve scroll across re-render — toggling a Show filter shouldn't + // jump the view back to the top. + const prevScroll = container.scrollTop; + wireClassifyInteractions(); container.innerHTML = ''; + updateFilterCounts(); + visible = anyFilter() ? computeVisible() : null; if (window.app.folderTree.length === 0) { container.innerHTML = '
No folders found
'; @@ -7087,11 +9258,17 @@ X.B(E,Y);return E}return J}()) } window.app.folderTree.forEach(folder => { + if (!folderShown(folder)) return; const element = createFolderElement(folder); container.appendChild(element); }); + if (!container.children.length) { + container.innerHTML = '
' + + (filterActive() ? 'No files match “' + nameFilter + '”.' : 'Nothing matches the current filters.') + '
'; + } updateSelectedCount(); + container.scrollTop = prevScroll; } /** @@ -7104,11 +9281,15 @@ X.B(E,Y);return E}return J}()) */ function populateCount(el, folder) { el.textContent = ''; + el.classList.remove('done'); const st = folder.scanState; if (st === 'pending') return; + if (st === 'zip-pending') { el.textContent = '(zip — open to scan)'; return; } if (st === 'scanning') { el.textContent = 'scanning…'; return; } const done = st === 'done'; + // When fully scanned both numbers are blue; .done turns the labels blue too. + if (done) el.classList.add('done'); const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0; const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0; @@ -7116,17 +9297,29 @@ X.B(E,Y);return E}return J}()) frag.appendChild(document.createTextNode('(')); if (dDir > 0 || tDir > 0) { appendPair(frag, dDir, tDir, done); - frag.appendChild(document.createTextNode(tDir === 1 ? ' folder, ' : ' folders, ')); + appendLabel(frag, tDir === 1 ? ' folder, ' : ' folders, '); } appendPair(frag, dFile, tFile, done); - frag.appendChild(document.createTextNode(tFile === 1 ? ' file)' : ' files)')); + appendLabel(frag, tFile === 1 ? ' file)' : ' files)'); el.appendChild(frag); } - // Append "" and, when there's a subtree (or scanning is ongoing), - // "+" with the total in a span that greys + pulses until final. + // The "folders"/"files" word labels — blue only once the row is .done. + function appendLabel(frag, text) { + const s = document.createElement('span'); + s.className = 'ct-label'; + s.textContent = text; + frag.appendChild(s); + } + + // Append "" (always a completed/blue number) and, when there's a + // subtree (or scanning is ongoing), "+" with the total in a span + // that greys + pulses until final, then turns blue. function appendPair(frag, direct, total, done) { - frag.appendChild(document.createTextNode(String(direct))); + const d = document.createElement('span'); + d.className = 'ct-direct'; + d.textContent = String(direct); + frag.appendChild(d); if (!done || total > direct) { frag.appendChild(document.createTextNode('+')); const t = document.createElement('span'); @@ -7157,14 +9350,30 @@ X.B(E,Y);return E}return J}()) item.classList.add('selected'); } + // Classify mode: the folder row is a drag source for a group-drag of + // every file in its subtree. + if (classifyOn()) { + item.draggable = true; + item.addEventListener('dragstart', function (e) { + e.stopPropagation(); + var files = subtreeFiles(folder); + if (!files.length) { e.preventDefault(); return; } + window.app.modules.dnd.setDrag(keysFor(files), e); + }); + } + // Toggle button: shown when the folder has children OR hasn't been // scanned yet (it might have children — expanding triggers its scan). const toggle = document.createElement('span'); toggle.className = 'folder-toggle'; const mightHaveChildren = (folder.children && folder.children.length > 0) - || folder.scanState === 'pending'; + || folder.scanState === 'pending' + || folder.scanState === 'zip-pending' + // Classify mode: a folder with files (even none of subfolders) is + // expandable so its files can be revealed and dragged. + || (classifyOn() && folder.files && folder.files.length > 0); if (mightHaveChildren) { - toggle.textContent = folder.expanded ? '▼' : '▶'; + toggle.textContent = (folder.expanded || autoOpen(folder)) ? '▼' : '▶'; toggle.addEventListener('click', (e) => { e.stopPropagation(); const recursive = e.ctrlKey || e.metaKey; @@ -7187,6 +9396,12 @@ X.B(E,Y);return E}return J}()) } item.appendChild(icon); + // Classify mode: an aggregate state dot for the folder's subtree. + if (classifyOn()) { + const agg = aggregateState(subtreeFiles(folder)); + if (agg) item.appendChild(stateDot(agg)); + } + // Folder name const name = document.createElement('span'); name.className = 'folder-name'; @@ -7236,20 +9451,68 @@ X.B(E,Y);return E}return J}()) div.appendChild(item); - // Children (if expanded) - if (folder.expanded && folder.children && folder.children.length > 0) { + // Children — when expanded, or opened on the path to a search hit below. + // The Show toggles never force-expand; search opens only connector folders. + if ((folder.expanded || autoOpen(folder)) && folder.children && folder.children.length > 0) { const childrenDiv = document.createElement('div'); childrenDiv.className = 'folder-children'; folder.children.forEach(child => { + if (!folderShown(child)) return; const childElement = createFolderElement(child, level + 1); childrenDiv.appendChild(childElement); }); div.appendChild(childrenDiv); } + // Classify mode: list this folder's own files (draggable leaves) when + // expanded (or opened to reveal a search hit), so they can be dropped. + if (classifyOn() && (folder.expanded || autoOpen(folder)) && folder.files && folder.files.length > 0) { + const filesDiv = document.createElement('div'); + filesDiv.className = 'folder-children folder-files'; + folder.files.forEach(function (file) { + if (!fileShown(file)) return; + filesDiv.appendChild(createFileElement(file, level + 1)); + }); + div.appendChild(filesDiv); + } + return div; } + /** + * Create a draggable source-file row (classify mode only). + */ + function createFileElement(file, level) { + const c = window.app.modules.classify; + const item = document.createElement('div'); + item.className = 'file-item'; + item.style.paddingLeft = `${level * 1.5}rem`; + item.draggable = true; + item.title = 'Click to preview · drag onto a tracking folder or transmittal to assign'; + const key = c.srcKeyForFile(file); + item.dataset.key = key; + const st = c.fileState(file); + if (st === 'excluded') item.classList.add('excluded'); + + item.appendChild(stateDot(st)); + + const icon = document.createElement('span'); + icon.className = 'file-icon'; + icon.innerHTML = '📄'; // 📄 + item.appendChild(icon); + + const name = document.createElement('span'); + name.className = 'file-name'; + name.textContent = zddc.joinExtension(file.originalFilename, file.extension); + item.appendChild(name); + + item.addEventListener('dragstart', function (e) { + e.stopPropagation(); + window.app.modules.dnd.setDrag([key], e); + }); + return item; + } + /** * Handle folder click with multi-select support */ @@ -7462,9 +9725,10 @@ X.B(E,Y);return E}return J}()) * Update selected folders count */ function updateSelectedCount() { + const el = window.app.dom.selectedFoldersCount; + if (!el) return; // count no longer shown in the folder-tree header const count = window.app.selectedFolders.size; - window.app.dom.selectedFoldersCount.textContent = - `${count} folder${count !== 1 ? 's' : ''} selected`; + el.textContent = `${count} folder${count !== 1 ? 's' : ''} selected`; } /** @@ -7636,6 +9900,146 @@ X.B(E,Y);return E}return J}()) container.tabIndex = 0; } + // ── Classify interactions (exclude menu, cross-tree reveal) ───────────── + var classifyWired = false; + function wireClassifyInteractions() { + if (classifyWired) return; + classifyWired = true; + var ft = window.app.dom.folderTree; + if (!ft) { classifyWired = false; return; } + ft.addEventListener('contextmenu', onContextMenu); + // Single-click a source file → preview it (the "look at it, then assign" + // half of the workflow). Drag still assigns; right-click excludes. + ft.addEventListener('click', function (e) { + if (!classifyOn()) return; + var fe = e.target.closest('.file-item'); + if (!fe || !fe.dataset.key) return; + var file = findFileByKey(fe.dataset.key); + if (file && window.app.modules.preview && window.app.modules.preview.previewFile) { + window.app.modules.preview.previewFile(file); + } + }); + } + + // Aggregate classification state across a folder's loaded subtree files. + function aggregateState(files) { + if (!files.length) return null; + var c = window.app.modules.classify; + var ex = 0, done = 0, placed = 0; + files.forEach(function (f) { + var s = c.fileState(f); + if (s === 'excluded') ex++; + else if (s === 'done') done++; + else if (s !== 'none') placed++; + }); + if (ex === files.length) return 'excluded'; + var active = files.length - ex; + if (active > 0 && done === active) return 'done'; + if (done > 0 || placed > 0) return 'partial'; + return 'none'; + } + + function findFolderByPath(path) { + var hit = null; + (function walk(nodes) { + (nodes || []).forEach(function (n) { + if (hit) return; + if (n.path === path) { hit = n; return; } + walk(n.children); + }); + })(window.app.folderTree); + return hit; + } + function findFileByKey(key) { + var c = window.app.modules.classify, hit = null; + (function walk(nodes) { + (nodes || []).forEach(function (n) { + if (hit) return; + (n.files || []).forEach(function (f) { if (!hit && c.srcKeyForFile(f) === key) hit = f; }); + walk(n.children); + }); + })(window.app.folderTree); + return hit; + } + function expandToPath(folderPath) { + (function walk(nodes) { + (nodes || []).forEach(function (n) { + if (n.path === folderPath || folderPath.indexOf(n.path + '/') === 0) { + n.expanded = true; + walk(n.children); + } + }); + })(window.app.folderTree); + } + + // Reveal a source file (target → source). Expands its folder chain, renders, + // scrolls + flashes the row. + function revealFile(key) { + var file = findFileByKey(key); + if (!file) return; + expandToPath(file.folderPath); + render(); + var rows = window.app.dom.folderTree.querySelectorAll('.file-item'); + var row = Array.prototype.filter.call(rows, function (r) { return r.dataset.key === key; })[0]; + if (row) { + row.scrollIntoView({ block: 'center' }); + row.classList.add('match-highlight'); + setTimeout(function () { row.classList.remove('match-highlight'); }, 1500); + } + } + + // ── context menu (exclude / include / clear) ─────────────────────────── + var menuEl = null; + function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } } + function showMenu(x, y, items) { + hideMenu(); + menuEl = document.createElement('div'); + menuEl.className = 'cl-menu'; + items.forEach(function (it) { + var b = document.createElement('button'); + b.className = 'cl-menu__item'; + b.textContent = it.label; + b.addEventListener('click', function () { hideMenu(); it.fn(); }); + menuEl.appendChild(b); + }); + menuEl.style.left = x + 'px'; + menuEl.style.top = y + 'px'; + document.body.appendChild(menuEl); + setTimeout(function () { + document.addEventListener('click', hideMenu, { once: true }); + document.addEventListener('scroll', hideMenu, { once: true, capture: true }); + }, 0); + } + function onContextMenu(e) { + if (!classifyOn()) return; + var c = window.app.modules.classify; + var fileEl = e.target.closest('.file-item'); + var folderEl = e.target.closest('.folder-item'); + if (!fileEl && !folderEl) return; + e.preventDefault(); + var items = []; + if (fileEl) { + var key = fileEl.dataset.key; + var a = c.getAssignment(key); + var excluded = !!(a && a.excluded); + items.push({ label: excluded ? 'Include in copy' : 'Exclude from copy', fn: function () { c.setExcluded([key], !excluded); } }); + if (a && (a.trackingNodeId || a.transmittalNodeId)) { + if (a.trackingNodeId) items.push({ label: 'Clear tracking', fn: function () { c.place([key], null, 'tracking'); } }); + if (a.transmittalNodeId) items.push({ label: 'Clear transmittal', fn: function () { c.place([key], null, 'transmittal'); } }); + } + } else { + var folder = findFolderByPath(folderEl.dataset.path); + var keys = keysFor(subtreeFiles(folder || { files: [], children: [] })); + if (!keys.length) return; + var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; }); + items.push({ + label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')', + fn: function () { c.setExcluded(keys, !allExcl); }, + }); + } + showMenu(e.clientX, e.clientY, items); + } + // Export module window.app.modules.tree = { render, @@ -7643,7 +10047,755 @@ X.B(E,Y);return E}return J}()) loadFilesFromSelectedFolders, setupKeyboardShortcuts, expandAll, - selectAll + selectAll, + revealFile, + setShowFilters, + setNameFilter + }; +})(); + +/** + * ZDDC Classifier — target-tree pane (Classify & Copy mode). + * + * Renders the two orthogonal target trees the user maps files onto: + * - "By tracking number": folders that join with "-" into the tracking + * number; the leaf folder ("A (IFR)") is the revision+status. + * - "By transmittal": /{received,issued}/. + * + * Structure here, placements in classify.js. Drag-and-drop assignment is wired + * in source-dnd.js / phase 3; this module owns rendering + folder/bin CRUD and + * shows the derived filename for each placed file. + */ +(function () { + 'use strict'; + + var SLOTS = ['received', 'issued']; + + var els = {}; + var collapsed = {}; // nodeId -> true when collapsed (default expanded) + var openForm = null; // { partyId, slot } when a bin form is open + var initialized = false; + var currentTab = 'tracking'; // 'tracking' | 'transmittal' — the active axis + + function init() { + if (initialized) return; + initialized = true; + els = { + trackingTab: document.getElementById('trackingTab'), + transmittalTab: document.getElementById('transmittalTab'), + trackingPanel: document.getElementById('trackingPanel'), + transmittalPanel: document.getElementById('transmittalPanel'), + trackingTree: document.getElementById('trackingTree'), + transmittalTree: document.getElementById('transmittalTree'), + addTrackingRootBtn: document.getElementById('addTrackingRootBtn'), + addPartyBtn: document.getElementById('addPartyBtn'), + stats: document.getElementById('classifyStats'), + }; + + els.trackingTab.addEventListener('click', function () { showTab('tracking'); }); + els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); }); + els.addTrackingRootBtn.addEventListener('click', function () { + var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n' + + 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', ''); + addFoldersFromPattern(null, name); + }); + els.addPartyBtn.addEventListener('click', function () { + var name = prompt('Party name (also the transmittal-number prefix):', ''); + if (name && name.trim()) C().addParty(name.trim()); + }); + + els.trackingTree.addEventListener('click', onTrackingClick); + els.transmittalTree.addEventListener('click', onTransmittalClick); + els.trackingTree.addEventListener('change', onFileNameChange); + els.transmittalTree.addEventListener('change', onFileNameChange); + + setupDropZone(els.trackingTree, 'tracking'); + setupDropZone(els.transmittalTree, 'transmittal'); + + C().on(render); + if (window.app.modules.store && window.app.modules.store.on) { + window.app.modules.store.on('files', render); + } + render(); + } + + function C() { return window.app.modules.classify; } + // Every scanned source file (classify mode reads the left tree, not the + // selection-scoped grid). Lazy folders contribute their files once scanned. + function allFiles() { + var out = []; + (function walk(nodes) { + (nodes || []).forEach(function (n) { + (n.files || []).forEach(function (f) { out.push(f); }); + walk(n.children); + }); + })(window.app.folderTree || []); + return out; + } + // One pass: group files by the node they're placed in, per axis. + function buildPlaced(files) { + var c = C(), byT = {}, byX = {}; + files.forEach(function (f) { + var a = c.getAssignment(c.srcKeyForFile(f)); + if (!a) return; + if (a.trackingNodeId) (byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f); + if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f); + }); + return { tracking: byT, transmittal: byX }; + } + + function showTab(which) { + var t = which === 'transmittal'; + currentTab = t ? 'transmittal' : 'tracking'; + els.trackingTab.classList.toggle('active', !t); + els.transmittalTab.classList.toggle('active', t); + els.trackingPanel.hidden = t; + els.transmittalPanel.hidden = !t; + // The "Hide Assigned" filter on the source tree is per-axis, so the + // visible set changes with the active tab — re-render the left tree. + if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); + } + function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; } + + // Expand a brace pattern into folder names and create them (confirming a + // multi-create first). parentId null = root folders. See expandFolderPattern. + function addFoldersFromPattern(parentId, raw) { + if (!raw || !raw.trim()) return; + var names = C().expandFolderPattern(raw); + if (!names.length) return; + if (names.length > 1) { + var shown = names.slice(0, 8).join('\n'); + if (names.length > 8) shown += '\n…and ' + (names.length - 8) + ' more'; + if (!confirm('Create ' + names.length + ' folders?\n\n' + shown)) return; + } + // Each expanded name is parsed into nested tracking levels (split on + // "-", final "_" splits the leaf rev), reusing shared ancestors. + names.forEach(function (nm) { C().addTrackingPath(parentId, C().parseFolderLevels(nm)); }); + } + + // ── render ─────────────────────────────────────────────────────────────── + function render() { + if (!initialized || !C().isEnabled()) return; + var files = allFiles(); + var placed = buildPlaced(files); + renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking); + renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal); + renderStats(files); + } + + function renderStats(files) { + var s = C().stats(files); + if (els.stats) { + els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · ' + + s.none + ' unassigned · ' + s.excluded + ' excluded'; + } + var copyBtn = document.getElementById('copyOutputBtn'); + if (copyBtn) { + copyBtn.disabled = s.done === 0; + copyBtn.textContent = s.done ? ('Copy ' + s.done + '…') : 'Copy…'; + } + } + + function el(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + + function nodeActions(extra) { + var wrap = el('span', 'tnode__actions'); + (extra || []).forEach(function (a) { + var b = el('button', 'tnode__act', a.label); + b.dataset.act = a.act; + b.title = a.title || ''; + wrap.appendChild(b); + }); + return wrap; + } + + function fileList(files) { + var box = el('div', 'tnode__files'); + files.forEach(function (f) { + var d = C().deriveTarget(f); + var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : '')); + row.dataset.key = d.key; + var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')); + orig.title = 'Click to preview'; + row.appendChild(orig); + row.appendChild(el('span', 'tfile__arrow', '→')); + // Editable derived filename — edit it to re-file the item. + var name = el('input', 'tfile__name' + (d.errors.length ? ' tfile__name--err' : '')); + name.type = 'text'; + name.value = d.filename || ''; + name.placeholder = '(incomplete)'; + name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item'; + row.appendChild(name); + box.appendChild(row); + }); + return box; + } + + // ── name filter (the autofilter box above the target trees) ──────────── + var rfTerms = []; + function setNameFilter(q) { + rfTerms = String(q || '').trim().toLowerCase().split(/\s+/).filter(Boolean); + render(); + } + function rfActive() { return rfTerms.length > 0; } + function rfHit(text) { + if (!rfTerms.length) return true; + var t = String(text || '').toLowerCase(); + for (var i = 0; i < rfTerms.length; i++) { if (t.indexOf(rfTerms[i]) === -1) return false; } + return true; + } + // A placed-file row matches on its original name or its derived ZDDC name. + function fileRowMatches(f) { + var orig = f.originalFilename + (f.extension ? '.' + f.extension : ''); + return rfHit(orig) || rfHit(C().deriveTarget(f).filename || ''); + } + + // Tracking tree (recursive, filter-aware — a match reveals its whole path). + function renderTrackingInto(container, nodes, placedMap) { + container.textContent = ''; + if (!nodes.length) { + container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.')); + return; + } + nodes.forEach(function (n) { var e = trackingNode(n, placedMap, false); if (e) container.appendChild(e); }); + if (rfActive() && !container.children.length) { + container.appendChild(el('div', 'target-empty', 'No matches in the tracking tree.')); + } + } + function trackingNode(n, placedMap, ancMatched) { + var matched = ancMatched || rfHit(n.name); + var isLeaf = (n.children || []).length === 0; + var expanded = !collapsed[n.id] || rfActive(); // auto-expand to reveal matches + var childEls = []; + if (expanded || rfActive()) { + (n.children || []).forEach(function (c) { var ce = trackingNode(c, placedMap, matched); if (ce) childEls.push(ce); }); + } + var placed = placedMap[n.id] || []; + var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed; + if (rfActive() && !matched && !childEls.length && !shownFiles.length) return null; + + var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : '')); + wrap.dataset.id = n.id; + var row = el('div', 'tnode__row'); + var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (expanded ? '▾' : '▸')); + if (!isLeaf) toggle.dataset.act = 'toggle'; + row.appendChild(toggle); + row.appendChild(el('span', 'tnode__name', n.name)); + if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); + row.appendChild(nodeActions([ + { act: 'add', label: '+', title: 'Add child folder' }, + { act: 'rename', label: '✎', title: 'Rename' }, + { act: 'del', label: '🗑', title: 'Delete' }, + ])); + wrap.appendChild(row); + if (shownFiles.length) wrap.appendChild(fileList(shownFiles)); + if (!isLeaf && expanded && childEls.length) { + var kids = el('div', 'tnode__children'); + childEls.forEach(function (ce) { kids.appendChild(ce); }); + wrap.appendChild(kids); + } + return wrap; + } + + // Transmittal tree + function renderTransmittalInto(container, parties, placedMap) { + container.textContent = ''; + if (!parties.length) { + container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.')); + return; + } + parties.forEach(function (p) { var e = partyNode(p, placedMap); if (e) container.appendChild(e); }); + if (rfActive() && !container.children.length) { + container.appendChild(el('div', 'target-empty', 'No matches in the transmittal tree.')); + } + } + function partyNode(party, placedMap) { + var partyMatch = rfHit(party.name); + var slotEls = [], anyBin = false; + SLOTS.forEach(function (slot) { + var slotNode = (party.children || []).filter(function (s) { return s.slot === slot; })[0]; + var sw = el('div', 'tslot'); + sw.dataset.party = party.id; + sw.dataset.slot = slot; + var sr = el('div', 'tslot__row'); + sr.appendChild(el('span', 'tslot__name', slot)); + var addBtn = el('button', 'tnode__act', '+ Transmittal'); + addBtn.dataset.act = 'addbin'; + sr.appendChild(addBtn); + sw.appendChild(sr); + + if (openForm && openForm.partyId === party.id && openForm.slot === slot) { + sw.appendChild(binForm(party.id, slot)); + } + (slotNode ? slotNode.children : []).forEach(function (bin) { + var be = binNode(bin, placedMap, partyMatch); + if (be) { sw.appendChild(be); anyBin = true; } + }); + slotEls.push(sw); + }); + if (rfActive() && !partyMatch && !anyBin) return null; + + var wrap = el('div', 'tnode tnode--party'); + wrap.dataset.id = party.id; + var row = el('div', 'tnode__row'); + row.appendChild(el('span', 'tnode__icon', '🏢')); + row.appendChild(el('span', 'tnode__name', party.name)); + row.appendChild(nodeActions([ + { act: 'rename-party', label: '✎', title: 'Rename party' }, + { act: 'del-party', label: '🗑', title: 'Delete party' }, + ])); + wrap.appendChild(row); + slotEls.forEach(function (sw) { wrap.appendChild(sw); }); + return wrap; + } + function binNode(bin, placedMap, ancMatched) { + var matched = ancMatched || rfHit(bin.name || ''); + var placed = placedMap[bin.id] || []; + var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed; + if (rfActive() && !matched && !shownFiles.length) return null; + var wrap = el('div', 'tnode tnode--bin'); + wrap.dataset.id = bin.id; + var row = el('div', 'tnode__row'); + row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)')); + if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); + row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }])); + wrap.appendChild(row); + if (shownFiles.length) wrap.appendChild(fileList(shownFiles)); + return wrap; + } + + var STATUSES = ['---', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'REC', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'TBD']; + function binForm(partyId, slot) { + var form = el('div', 'binform'); + form.dataset.party = partyId; + form.dataset.slot = slot; + var date = el('input', 'binform__date'); date.type = 'date'; + try { date.value = new Date().toISOString().slice(0, 10); } catch (_) { /* ok */ } + var type = document.createElement('select'); type.className = 'binform__type'; + ['TRN', 'SUB'].forEach(function (t) { var o = el('option', null, t); o.value = t; type.appendChild(o); }); + var seq = el('input', 'binform__seq'); seq.type = 'text'; seq.placeholder = 'seq (e.g. 0007)'; + var status = document.createElement('select'); status.className = 'binform__status'; + STATUSES.forEach(function (s) { var o = el('option', null, s); o.value = s; status.appendChild(o); }); + var title = el('input', 'binform__title'); title.type = 'text'; title.placeholder = 'title (optional)'; + var add = el('button', 'btn btn-sm btn-primary', 'Add'); add.dataset.act = 'binadd'; + var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel'); cancel.dataset.act = 'bincancel'; + [date, type, seq, status, title, add, cancel].forEach(function (n) { form.appendChild(n); }); + return form; + } + + // ── events ───────────────────────────────────────────────────────────── + function closestNodeId(target) { + var n = target.closest('.tnode'); + return n ? n.dataset.id : null; + } + function fileByKey(key) { + var files = allFiles(); + for (var i = 0; i < files.length; i++) { if (C().srcKeyForFile(files[i]) === key) return files[i]; } + return null; + } + // Click a placed-file row (anywhere but its editable name) → preview it. + function previewFromTarget(e) { + if (e.target.closest('.tfile__name')) return false; + var tf = e.target.closest('.tfile'); + if (!tf || !tf.dataset.key) return false; + var f = fileByKey(tf.dataset.key); + if (f && window.app.modules.preview && window.app.modules.preview.previewFile) { + window.app.modules.preview.previewFile(f); + } + return true; + } + // Edited a placed-file's ZDDC filename → re-derive its tracking placement + // (creating the folder path if needed) + its title override. + function onFileNameChange(e) { + var input = e.target.closest('.tfile__name'); + if (input) commitFilenameEdit(input); + } + function commitFilenameEdit(input) { + var tf = input.closest('.tfile'); + if (!tf || !tf.dataset.key) return; + var parsed = window.zddc.parseFilename((input.value || '').trim()); + if (!parsed || !parsed.valid) { + window.zddc.toast('Not a valid ZDDC filename — expected "TRACKING_REV (STATUS) - Title.ext".', 'warning'); + render(); // restore the derived value + return; + } + var stem = parsed.trackingNumber + '_' + parsed.revision + ' (' + parsed.status + ')'; + var leaf = C().addTrackingPath(null, C().parseFolderLevels(stem)); + C().place([tf.dataset.key], leaf, 'tracking'); + if (parsed.title != null) C().setTitleOverride(tf.dataset.key, parsed.title); + // place/setTitleOverride fire classify.notify → re-render. + } + // Collapse/expand a node and its whole subtree (ctrl/cmd-click a toggle). + function setSubtreeCollapsed(nodeId, collapse) { + var node = C().getNode(nodeId); + if (!node) return; + (function walk(n) { + if ((n.children || []).length) { if (collapse) collapsed[n.id] = true; else delete collapsed[n.id]; } + (n.children || []).forEach(walk); + })(node); + } + function onTrackingClick(e) { + if (previewFromTarget(e)) return; + var btn = e.target.closest('[data-act]'); + if (!btn) return; + var act = btn.dataset.act; + var id = closestNodeId(btn); + if (act === 'toggle') { + var collapse = !collapsed[id]; + if (e.ctrlKey || e.metaKey) setSubtreeCollapsed(id, collapse); + else if (collapse) collapsed[id] = true; else delete collapsed[id]; + render(); + return; + } + if (act === 'add') { + var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)").\n' + + 'Brace patterns expand: {PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)', ''); + addFoldersFromPattern(id, name); + } else if (act === 'rename') { + var node = C().getNode(id); + var nn = prompt('Rename folder:', node ? node.name : ''); + if (nn && nn.trim()) C().renameNode(id, nn.trim()); + } else if (act === 'del') { + if (confirm('Delete this folder and everything under it? Files placed here become unassigned.')) C().deleteNode(id); + } + } + function onTransmittalClick(e) { + if (previewFromTarget(e)) return; + var btn = e.target.closest('[data-act]'); + if (!btn) return; + var act = btn.dataset.act; + + if (act === 'addbin') { + var slotEl = btn.closest('.tslot'); + openForm = { partyId: slotEl.dataset.party, slot: slotEl.dataset.slot }; + render(); + return; + } + if (act === 'bincancel') { openForm = null; render(); return; } + if (act === 'binadd') { + var form = btn.closest('.binform'); + var meta = { + date: form.querySelector('.binform__date').value, + type: form.querySelector('.binform__type').value, + seq: form.querySelector('.binform__seq').value.trim(), + status: form.querySelector('.binform__status').value, + title: form.querySelector('.binform__title').value.trim(), + }; + if (!meta.date || !meta.seq) { window.zddc.toast('Transmittal needs at least a date and a sequence number.', 'warning'); return; } + C().addTransmittalBin(form.dataset.party, form.dataset.slot, meta); + openForm = null; // render() fires from classify.notify() + return; + } + + var id = closestNodeId(btn); + if (act === 'rename-party') { + var node = C().getNode(id); + var nn = prompt('Rename party (re-derives its transmittal numbers):', node ? node.name : ''); + if (nn && nn.trim()) C().renameNode(id, nn.trim()); + } else if (act === 'del-party') { + if (confirm('Delete this party and all its transmittals? Files placed there become unassigned.')) C().deleteNode(id); + } else if (act === 'del') { + if (confirm('Delete this transmittal? Files placed here become unassigned.')) C().deleteNode(id); + } + } + + // ── drop targets ─────────────────────────────────────────────────────── + // Resolve the drop target under an event: + // tracking → any folder node (.tnode) + // transmittal → a transmittal bin only (.tnode--bin) + function dropTarget(target, axis) { + var sel = axis === 'transmittal' ? '.tnode--bin' : '.tnode'; + var node = target.closest(sel); + if (!node || !node.dataset.id) return null; + return { id: node.dataset.id, row: node.querySelector('.tnode__row') || node }; + } + function clearHover(container) { + var hot = container.querySelectorAll('.drop-hover'); + for (var i = 0; i < hot.length; i++) hot[i].classList.remove('drop-hover'); + } + function setupDropZone(container, axis) { + container.addEventListener('dragover', function (e) { + if (!window.app.modules.dnd.active()) return; + var t = dropTarget(e.target, axis); + clearHover(container); + if (!t) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + t.row.classList.add('drop-hover'); + }); + container.addEventListener('dragleave', function (e) { + if (e.target === container) clearHover(container); + }); + container.addEventListener('drop', function (e) { + var t = dropTarget(e.target, axis); + clearHover(container); + if (!t) return; + e.preventDefault(); + var keys = window.app.modules.dnd.getDrag(); + window.app.modules.dnd.clearDrag(); + if (!keys.length) return; + if (axis === 'tracking') placeTrackingDrop(keys, t.id); + else C().place(keys, t.id, axis); + }); + } + + // Tracking drop: if the target is already a complete leaf, assign directly; + // otherwise prompt for the remaining levels (parsed + nested under it) so a + // file can be dropped on an existing partial tracking number and completed. + function placeTrackingDrop(keys, nodeId) { + if (C().trackingNodeComplete(nodeId)) { C().place(keys, nodeId, 'tracking'); return; } + var label = C().trackingPathLabel(nodeId); + var input = prompt('Dropping under "' + label + '".\n' + + 'Add the remaining tracking levels (e.g. "0001_0 (IFU)"), or leave blank to drop here:', ''); + if (input === null) return; // cancelled + var levels = C().parseFolderLevels(input.trim()); + var target = levels.length ? C().addTrackingPath(nodeId, levels) : nodeId; + C().place(keys, target, 'tracking'); + } + + // Reveal a source key's placement in the target pane (source → target). + function reveal(key) { + var a = C().getAssignment(key); + if (!a) return; + if (a.trackingNodeId) { + showTab('tracking'); collapsed = {}; render(); + flashNode(els.trackingTree, a.trackingNodeId); + } else if (a.transmittalNodeId) { + showTab('transmittal'); render(); + flashNode(els.transmittalTree, a.transmittalNodeId); + } + } + function flashNode(container, id) { + var node = container.querySelector('.tnode[data-id="' + id + '"]'); + if (!node) return; + node.scrollIntoView({ block: 'center' }); + var row = node.querySelector('.tnode__row') || node; + row.classList.add('reveal-flash'); + setTimeout(function () { row.classList.remove('reveal-flash'); }, 1500); + } + + window.app.modules.targetTree = { + init: init, + render: render, + showTab: showTab, + activeAxis: activeAxis, + setNameFilter: setNameFilter, + reveal: reveal, + }; +})(); + +/** + * ZDDC Classifier — copy-out (Classify & Copy mode). + * + * Copies the fully-classified source files into a SEPARATE output directory + * under their canonical ZDDC names and folder layout + * /{received,issued}// + * The source is never modified — every operation is a read (getFile) on the + * source and a write into the chosen output handle. + * + * Duplicate detection: + * - two sources → the same output path = mapping conflict (skipped + reported) + * - target already exists, identical bytes (sha256) = skipped + * - target exists, different bytes = left untouched + reported (no clobber) + * + * Built on the generic FS-Access shape (getDirectoryHandle/getFileHandle/ + * createWritable), so it works against a real handle today and a server-backed + * output handle later without changing this logic. + */ +(function () { + 'use strict'; + + var outputHandle = null; // remembered for the session + + function C() { return window.app.modules.classify; } + + function collectFiles() { + var out = []; + (function walk(nodes) { + (nodes || []).forEach(function (n) { + (n.files || []).forEach(function (f) { out.push(f); }); + walk(n.children); + }); + })(window.app.folderTree || []); + return out; + } + + // Files that are ready to copy: complete target, not excluded. + function plan() { + var c = C(), items = []; + collectFiles().forEach(function (f) { + var d = c.deriveTarget(f); + if (d.excluded || !d.complete) return; + items.push({ file: f, d: d, outRel: d.outPath + '/' + d.filename }); + }); + return items; + } + + // Group by output path; >1 source for a path = a mapping conflict. + function conflictsIn(items) { + var by = {}, conflicts = []; + items.forEach(function (p) { (by[p.outRel] = by[p.outRel] || []).push(p); }); + Object.keys(by).forEach(function (k) { if (by[k].length > 1) conflicts.push(k); }); + return { by: by, conflicts: conflicts }; + } + + function toast(msg, level) { + if (window.zddc && window.zddc.toast) window.zddc.toast(msg, level); + } + function setStatus(text) { + var el = document.getElementById('scanStatus'); + if (!el) return; + el.textContent = text; + el.classList.toggle('scanning', !!text); + } + + async function chooseOutput() { + if (!window.showDirectoryPicker) { + toast('Copying to an output directory needs the File System Access API (use Chromium, or run via zddc-server).', 'error'); + return null; + } + try { + var h = await window.showDirectoryPicker({ mode: 'readwrite', id: 'zddc-classifier-output' }); + outputHandle = h; + C().setOutputName(h.name); + return h; + } catch (e) { + if (e.name !== 'AbortError') toast('Could not open the output directory — ' + (e.message || e), 'error'); + return null; + } + } + + async function ensureDir(root, relPath) { + var parts = relPath.split('/').filter(Boolean); + var cur = root; + for (var i = 0; i < parts.length; i++) { + cur = await cur.getDirectoryHandle(parts[i], { create: true }); + } + return cur; + } + + async function sameContent(existingHandle, srcFileObj) { + var ef = await existingHandle.getFile(); + var sf = await readSource(srcFileObj); + if (ef.size !== sf.size) return false; + var a = await window.zddc.crypto.sha256File(ef); + var b = await window.zddc.crypto.sha256File(sf); + return a === b; + } + + // Resolve a source file's live handle. Fresh-scan files already carry one; + // snapshot-loaded files resolve lazily from the workspace root by path. + async function srcHandle(fileObj) { + if (fileObj.handle) return fileObj.handle; + if (!window.app.rootHandle) throw new Error('source directory not connected'); + return window.app.modules.scanner.resolveFileHandle(window.app.rootHandle, fileObj); + } + + // Read a source file's bytes (a File or Blob). A zip member is extracted + // from its archive (lazily reloaded from the root); a plain file is read + // through its resolved handle. The source is never written either way. + async function readSource(fileObj) { + if (fileObj.isVirtual) { + return window.app.modules.scanner.extractZipMember(window.app.rootHandle, fileObj); + } + return (await srcHandle(fileObj)).getFile(); + } + + // Copy one file. Returns 'copied' | 'skipped' (identical) | 'differ' (left alone). + async function copyOne(out, p) { + var dir = await ensureDir(out, p.d.outPath); + var existing = null; + try { existing = await dir.getFileHandle(p.d.filename); } catch (e) { /* NotFound → fresh copy */ } + if (existing) { + return (await sameContent(existing, p.file)) ? 'skipped' : 'differ'; + } + var srcFile = await readSource(p.file); // READ source (never write it) + var fh = await dir.getFileHandle(p.d.filename, { create: true }); + var w = await fh.createWritable(); + await w.write(srcFile); + await w.close(); + return 'copied'; + } + + async function run() { + if (!C().isEnabled()) return; + var items = plan(); + if (!items.length) { + toast('Nothing to copy yet — no files are fully classified (need both a tracking leaf and a transmittal).', 'warning'); + return; + } + var cf = conflictsIn(items); + var blocked = {}; + cf.conflicts.forEach(function (path) { blocked[path] = true; }); + var todo = items.filter(function (p) { return !blocked[p.outRel]; }); + + if (cf.conflicts.length) { + toast(cf.conflicts.length + ' output-name collision(s) — two source files map to the same name. Skipped:\n' + + cf.conflicts.join('\n'), 'error'); + } + if (!todo.length) return; + + // Snapshot-loaded files have no live handle — re-grant read on the + // workspace source directory (one click) before copying. + if (todo.some(function (p) { return !p.file.handle; })) { + if (!window.app.rootHandle) { + toast('The source directory isn’t connected. Re-open the workspace to reconnect it.', 'error'); + return; + } + var srcOk = await window.app.modules.persist.verifyPermission(window.app.rootHandle, false); + if (!srcOk) { toast('Permission to read the source directory was denied.', 'error'); return; } + } + + var out = outputHandle || await chooseOutput(); + if (!out) return; + if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\nThe source directory is not modified.')) return; + + var s = await copyTo(out, todo); + + var msg = 'Copy complete — ' + s.copied + ' copied, ' + s.skipped + ' identical skipped' + + (s.differ ? (', ' + s.differ + ' already exist with different content (left untouched)') : '') + + (s.errors ? (', ' + s.errors + ' errors') : '') + '.'; + toast(msg, (s.errors || s.differ) ? 'warning' : 'success'); + if (s.differing.length) toast('Existing-but-different (not overwritten):\n' + s.differing.join('\n'), 'warning'); + return s; + } + + // Run the copy loop over a ready list against an output handle. No picker, + // no confirm — that's run()'s job; this is the engine (and the test seam). + async function copyTo(out, todo) { + var s = { copied: 0, skipped: 0, differ: 0, errors: 0, differing: [] }; + for (var i = 0; i < todo.length; i++) { + setStatus('Copying… ' + (i + 1) + '/' + todo.length + ' — ' + todo[i].d.filename); + try { + var r = await copyOne(out, todo[i]); + s[r]++; + if (r === 'differ') s.differing.push(todo[i].outRel); + } catch (e) { + s.errors++; + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Failed to copy ' + todo[i].outRel + ' — ' + (e.message || e), 'error'); + } + } + } + setStatus(''); + return s; + } + + function readyCount() { return plan().length; } + + window.app.modules.copy = { + run: run, + readyCount: readyCount, + chooseOutput: chooseOutput, + // test/advanced seams + plan: plan, + conflictsIn: conflictsIn, + copyTo: copyTo, }; })(); @@ -9840,8 +12992,39 @@ X.B(E,Y);return E}return J}()) } // Export module + // Preview a file on demand (Classify & Copy mode). Snapshot-loaded files + // have no handle yet — resolve it from the workspace root (one-click read + // permission re-grant) before opening the preview window. + async function previewFile(file) { + try { + const sc = window.app.modules.scanner; + if (file.isVirtual) { + // Snapshot-restored zip member — reload its archive from the root. + if (window.app.rootHandle && !sc.getZipCache(file.zipPath)) { + if (window.app.modules.persist && window.app.modules.persist.verifyPermission) { + const ok = await window.app.modules.persist.verifyPermission(window.app.rootHandle, false); + if (!ok) { if (window.zddc) window.zddc.toast('Permission to read the source directory was denied.', 'error'); return; } + } + await sc.ensureZipLoaded(window.app.rootHandle, file.zipPath); + } + } else if (!file.handle && window.app.rootHandle) { + if (window.app.modules.persist && window.app.modules.persist.verifyPermission) { + const ok = await window.app.modules.persist.verifyPermission(window.app.rootHandle, false); + if (!ok) { if (window.zddc) window.zddc.toast('Permission to read the source directory was denied.', 'error'); return; } + } + await sc.resolveFileHandle(window.app.rootHandle, file); + } + await openPreviewWindow(file); + } catch (e) { + if (window.zddc) { + window.zddc.toast('Couldn’t preview ' + (file.originalFilename || 'file') + ' — ' + (e.message || e), 'error'); + } + } + } + window.app.modules.preview = { - init + init, + previewFile }; })(); diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html index 24f3003..2156bc3 100644 --- a/zddc/internal/apps/embedded/index.html +++ b/zddc/internal/apps/embedded/index.html @@ -814,23 +814,75 @@ body.help-open .app-header { with tool-local .toast classes; the old classifier rules can stay alongside until this file is concatenated above them in the build. */ -.zddc-toast { +/* Toast STACK — bottom-right, newest at the bottom. The container is + click-through (pointer-events:none) so the gaps don't block the page; each + toast + button re-enables pointer events. */ +.zddc-toasts { position: fixed; - bottom: 2rem; - right: 2rem; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9000; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + max-height: calc(100vh - 3rem); + overflow-y: auto; + pointer-events: none; +} + +/* "Clear all" — shown above the stack when 2+ toasts are present. */ +.zddc-toasts__clear { + pointer-events: auto; + align-self: flex-end; background: var(--bg); color: var(--text); - padding: 0.875rem 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.2rem 0.6rem; + font-size: 0.78rem; + cursor: pointer; +} +.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); } + +.zddc-toast { + position: relative; + pointer-events: auto; + background: var(--bg); + color: var(--text); + padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */ border-radius: var(--radius); border: 1px solid var(--border); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 9000; - max-width: 400px; + max-width: 420px; font-size: 0.875rem; - cursor: pointer; animation: zddc-toast-in 0.3s ease-out; } +/* Message text — selectable + copyable; long/multi-line errors wrap. */ +.zddc-toast__msg { + user-select: text; + -webkit-user-select: text; + cursor: text; + white-space: pre-wrap; + word-break: break-word; +} + +/* Per-toast dismiss. */ +.zddc-toast__close { + position: absolute; + top: 0.2rem; + right: 0.35rem; + border: none; + background: transparent; + color: var(--text-muted, #888); + font-size: 1.15rem; + line-height: 1; + cursor: pointer; + padding: 0 0.15rem; +} +.zddc-toast__close:hover { color: var(--text); } + .zddc-toast--success { border-left: 4px solid var(--success); } .zddc-toast--error { border-left: 4px solid var(--danger); } .zddc-toast--info { border-left: 4px solid var(--info); } @@ -1619,7 +1671,7 @@ body {
ZDDC - v0.0.27-beta · 2026-06-09 15:30:13 · 237c353 + v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
@@ -2417,74 +2469,122 @@ body { }()); // shared/toast.js — non-blocking notification helper available to every -// tool via window.zddc.toast(msg, level, opts). Originated as classifier's -// local showToast (classifier/js/excel.js); promoted here so tools that -// today use alert() or silent console.error can switch to a uniform -// non-blocking surface. +// tool via window.zddc.toast(msg, level, opts). +// +// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY — +// they stay until the user dismisses them (per-toast × or a "Clear all" +// button) so the message can be read, selected, and copied while +// troubleshooting. info/success toasts auto-dismiss. The message text is +// always selectable. // // Usage: -// window.zddc.toast('Saved.', 'success'); -// window.zddc.toast('Could not load: ' + err.message, 'error'); +// window.zddc.toast('Saved.', 'success'); // auto-dismiss +// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky // window.zddc.toast('Note', 'info', { durationMs: 3000 }); +// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky // // Levels: 'info' (default) | 'success' | 'warning' | 'error'. -// Each tool may also expose app.notify(msg, level) as a thin wrapper — -// see ARCHITECTURE.md for the convention. (function () { 'use strict'; if (!window.zddc) window.zddc = {}; - // Don't overwrite if a tool defined its own first. if (typeof window.zddc.toast === 'function') return; var DEFAULT_DURATION_MS = 5000; var FADE_MS = 300; + // Levels that persist until the user dismisses them (troubleshooting). + var STICKY = { error: true, warning: true }; + + function container() { + var c = document.getElementById('zddc-toasts'); + if (c) return c; + c = document.createElement('div'); + c.id = 'zddc-toasts'; + c.className = 'zddc-toasts'; + document.body.appendChild(c); + return c; + } + + // Show/hide a "Clear all" control when 2+ toasts are stacked. + function refreshClearAll(c) { + var bar = c.querySelector('.zddc-toasts__clear'); + var count = c.querySelectorAll('.zddc-toast').length; + if (count >= 2) { + if (!bar) { + bar = document.createElement('button'); + bar.type = 'button'; + bar.className = 'zddc-toasts__clear'; + bar.textContent = 'Clear all'; + bar.addEventListener('click', function () { + var all = c.querySelectorAll('.zddc-toast'); + for (var i = 0; i < all.length; i++) dismiss(all[i]); + }); + c.insertBefore(bar, c.firstChild); + } + } else if (bar) { + bar.remove(); + } + } + + function dismiss(el) { + if (el._dismissed) return; + el._dismissed = true; + if (el._timer) clearTimeout(el._timer); + el.classList.add('zddc-toast--fade'); + setTimeout(function () { + if (el.parentNode) el.parentNode.removeChild(el); + refreshClearAll(container()); + }, FADE_MS); + } function toast(message, level, opts) { opts = opts || {}; var lvl = (level === 'success' || level === 'error' || level === 'warning') ? level : 'info'; - - // Single-toast policy: dismiss any existing toast immediately - // so the new one is always the most recent. Matches the - // classifier's prior behavior and avoids stack-of-toasts UX. - var existing = document.querySelector('.zddc-toast'); - if (existing) existing.remove(); + var c = container(); var el = document.createElement('div'); el.className = 'zddc-toast zddc-toast--' + lvl; // ARIA: errors get assertive (interrupts SR queue), others polite. el.setAttribute('role', lvl === 'error' ? 'alert' : 'status'); el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite'); - el.textContent = message == null ? '' : String(message); - document.body.appendChild(el); - var dur = typeof opts.durationMs === 'number' ? - opts.durationMs : DEFAULT_DURATION_MS; - var timer = setTimeout(function () { - el.classList.add('zddc-toast--fade'); - setTimeout(function () { - if (el.parentNode) el.parentNode.removeChild(el); - }, FADE_MS); - }, dur); + // Selectable, copyable message text (its own element so clicking to + // select doesn't dismiss the toast — only the × does). + var msg = document.createElement('span'); + msg.className = 'zddc-toast__msg'; + msg.textContent = message == null ? '' : String(message); + el.appendChild(msg); - // Click-to-dismiss. Useful for sticky errors the user wants gone. - el.addEventListener('click', function () { - clearTimeout(timer); - if (el.parentNode) el.parentNode.removeChild(el); - }); + var close = document.createElement('button'); + close.type = 'button'; + close.className = 'zddc-toast__close'; + close.setAttribute('aria-label', 'Dismiss'); + close.textContent = '×'; + close.addEventListener('click', function () { dismiss(el); }); + el.appendChild(close); + c.appendChild(el); + + // Sticky (error/warning, or opts.durationMs === 0) persists; otherwise + // auto-dismiss after the (overridable) duration. + var sticky = opts.durationMs === 0 || + (typeof opts.durationMs !== 'number' && STICKY[lvl]); + if (!sticky) { + var dur = typeof opts.durationMs === 'number' + ? opts.durationMs : DEFAULT_DURATION_MS; + el._timer = setTimeout(function () { dismiss(el); }, dur); + } + + refreshClearAll(c); return el; } window.zddc.toast = toast; - // Route window.alert() calls into the toast helper. Every tool has - // accumulated some `alert(...)` sites for error reporting; rather - // than touch each one, intercept globally so they're non-blocking - // and ARIA-announced consistently. Native alert is preserved on - // window.alertNative for the rare case where a truly modal block - // is needed (e.g. before navigating away with unsaved changes). + // Route window.alert() into the toast helper (non-blocking, ARIA-announced, + // consistent). Native alert preserved on window.alertNative. alert() maps + // to an error toast (sticky) since that's its usual purpose. if (typeof window.alert === 'function' && !window.alertNative) { window.alertNative = window.alert.bind(window); window.alert = function (msg) { diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html index dc4346d..5032969 100644 --- a/zddc/internal/apps/embedded/transmittal.html +++ b/zddc/internal/apps/embedded/transmittal.html @@ -818,23 +818,75 @@ body.help-open .app-header { with tool-local .toast classes; the old classifier rules can stay alongside until this file is concatenated above them in the build. */ -.zddc-toast { +/* Toast STACK — bottom-right, newest at the bottom. The container is + click-through (pointer-events:none) so the gaps don't block the page; each + toast + button re-enables pointer events. */ +.zddc-toasts { position: fixed; - bottom: 2rem; - right: 2rem; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9000; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + max-height: calc(100vh - 3rem); + overflow-y: auto; + pointer-events: none; +} + +/* "Clear all" — shown above the stack when 2+ toasts are present. */ +.zddc-toasts__clear { + pointer-events: auto; + align-self: flex-end; background: var(--bg); color: var(--text); - padding: 0.875rem 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.2rem 0.6rem; + font-size: 0.78rem; + cursor: pointer; +} +.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); } + +.zddc-toast { + position: relative; + pointer-events: auto; + background: var(--bg); + color: var(--text); + padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */ border-radius: var(--radius); border: 1px solid var(--border); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 9000; - max-width: 400px; + max-width: 420px; font-size: 0.875rem; - cursor: pointer; animation: zddc-toast-in 0.3s ease-out; } +/* Message text — selectable + copyable; long/multi-line errors wrap. */ +.zddc-toast__msg { + user-select: text; + -webkit-user-select: text; + cursor: text; + white-space: pre-wrap; + word-break: break-word; +} + +/* Per-toast dismiss. */ +.zddc-toast__close { + position: absolute; + top: 0.2rem; + right: 0.35rem; + border: none; + background: transparent; + color: var(--text-muted, #888); + font-size: 1.15rem; + line-height: 1; + cursor: pointer; + padding: 0 0.15rem; +} +.zddc-toast__close:hover { color: var(--text); } + .zddc-toast--success { border-left: 4px solid var(--success); } .zddc-toast--error { border-left: 4px solid var(--danger); } .zddc-toast--info { border-left: 4px solid var(--info); } @@ -2718,7 +2770,7 @@ dialog.modal--narrow {
ZDDC Transmittal - v0.0.27-beta · 2026-06-09 15:30:13 · 237c353 + v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
JavaScript not available