+
+ Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”.
+
+
+
+
+
+
+
+ <party>/{received,issued}/<transmittal>. Drag files (or a whole folder) into a transmittal.
+
+
+
+
+
+
+
+
+
+
-
-
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.
+
+
New workspace → pick a folder. It scans once and saves to this browser, so you can close the tab and pick up later.
+
Preview a file (single-click it in the left tree) to see what it actually is.
+
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).
+
Copy when ready → choose an output directory; renamed copies are written as <party>/<transmittal>/<name>, with duplicates detected.
+
+
+
+
② 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.
+
+
Click Use Local Directory (top bar) to open a folder.
+
Switch the toggle to Rename in place.
+
Edit cells (or paste columns from Excel); names already in ZDDC format are parsed automatically and validated as you type.
+
Save All renames the files where they sit.
+
+
+
@@ -2028,16 +2450,7 @@ body.is-elevated::after {
This application requires the File System Access API, available only in Chromium-based browsers (Chrome, Edge, Brave, Opera).
-
-
Files already named to ZDDC format are parsed automatically
-
Edit cells directly, or copy columns to and from Excel
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 {