Compare commits

...

9 commits

Author SHA1 Message Date
589d804716 chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 9s
2026-06-10 13:32:10 -05:00
203674ee4c feat(classifier): persist & copy files inside .zip archives
Zip members were live-only: expandable while the source was connected, but the
workspace snapshot dropped the archive (.zip became a plain file), so a
classification made inside one vanished on reopen — and copy couldn't extract it
anyway (it tried to walk the archive path as a real directory).

Now zips are first-class:
- snapshotTree/loadSnapshot persist the scanned archive subtree — zip-root +
  virtual folders + members carry isVirtual/zipPath/zipEntryPath, so the tree
  rebuilds on reopen and assignments inside an archive survive. An archive that
  was never opened persists as a lazy 'zip' node that reopens on demand.
- scanner.ensureZipLoaded(rootHandle, zipPath) reloads an archive from the
  workspace root when the in-memory cache is cold (post-restore); scanZipNode
  falls back to it when a restored zip node has no live file object.
- copy.js reads a member via scanner.extractZipMember (Blob from the archive)
  instead of a non-existent file handle; preview.js reloads the archive for a
  restored member before opening it.

This also reconciles export/import with the snapshot: both now keep zip members,
so a round-trip no longer leaves dangling in-archive assignments.

Tests: zip subtree snapshot round-trip; copy extracts a member to the output (45).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:27:00 -05:00
e1c479dba5 fix(classifier): search opens only the path to a hit, not the whole tree
Searching no longer force-expands every folder. computeVisible now returns an
"open" set holding just the connector folders on the path down to each match;
those open to expose the hit, while off-path branches and terminal nodes keep
their real collapse state (and honest ▶/▼ arrows). Reshaping the tree is the
user's call — the root's expand-all is one click away.

Test: a deep file hit opens its branch and leaves the sibling collapsed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:10:27 -05:00
15ce7098a0 fix(classifier): Show toggles preserve tree state; add Reset to raw input
- Toggling Show Unassigned/Assigned/Excluded/Empty no longer force-expands the
  whole tree. Auto-expand is now reserved for the NAME search (where revealing a
  match means expanding to it); the Show toggles only hide/show, leaving your
  expand/collapse state untouched. Also preserve scrollTop across re-render so a
  toggle doesn't jump the view to the top.
- Add a "Reset" button (danger-styled, beside Export/Import) that discards every
  classification — tracking + transmittal trees, assignments, excludes, title
  overrides — and returns to just the raw scanned input. Your files are never
  touched. Destructive + irreversible, so it confirms with an "Export first"
  warning and no-ops (info toast) when there's nothing to reset.

Tests: Show toggle preserves collapse vs. name-search auto-expand (classify 42).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:02:12 -05:00
aa34a4b3e7 feat(classifier): Show Empty toggle; tidy the folder-tree header
- Add a "Show Empty" checkbox (classify mode) — when off, folders whose whole
  subtree contains no files are hidden, decluttering messy scans.
- Move the Show Unassigned/Assigned/Excluded/Empty filters out of the cramped
  pane header into a dedicated "Show …" toolbar row beneath it (wraps cleanly).
- Drop the "X folders selected" text from the folder-tree header (selection
  still works; updateSelectedCount guards the now-absent element).

Test: Show Empty off hides file-less folders (classify.spec.js -> 41).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:52:03 -05:00
c61cac7c8f feat(classifier): live filter box above each file tree (reveals matches + path)
Adds an autofilter input above the source tree and above each target tree.
Typing substring-matches (ANDing space-separated terms) against the full file
path/name (and folder/node names) and reveals every match with the folder
hierarchy leading to it — non-matching branches collapse out, matching branches
auto-expand. So you can type "master deliverables list" and jump straight to it.

- Source tree (tree.js): one-pass visible-set over path+name; composes with the
  Show Unassigned/Assigned/Excluded toggles; auto-expands to reveal hits.
- Target trees (target-tree.js): tracking + transmittal nodes are filter-aware
  (match node names + each placed file's original/derived name); one shared
  query mirrored across both tab inputs.

Tests: source-tree path reveal + tracking-tree node filter (classify.spec.js -> 36).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:37:36 -05:00
9851cc4463 feat(classifier): switch dataset export/import to a filename-per-file format
Replaces the ID-based dataset export/import (which required an external editor
to build a nested tree and keep node ids consistent) with a flat, AI-friendly
list: one record per input file carrying its full ZDDC filename — and an
optional transmittal {party, slot, date, type, seq, status, title}.

- Export: one {source, originalName, filename, excluded, transmittal?} record
  per source file (filename = the derived ZDDC name, "" if unassigned).
- Import: parses each filename and rebuilds the tracking tree (parseFolderLevels
  + addTrackingPath, sharing ancestors); excluded files are marked; transmittals
  are reconstructed with party/bin dedup. No node ids for the editor to manage.

New classify helpers: transmittalRecord (export), findOrAddParty /
findOrAddTransmittalBin (import dedup). serialize/load stay for workspace
persistence. Test rewritten for the filename round-trip (classify.spec.js -> 34).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:52:44 -05:00
4425a599f0 feat(classifier): export/import the classification dataset as JSON
Adds Export / Import buttons to the Classify & Copy header so the full dataset
(tracking + transmittal trees, per-file assignments, output name) round-trips
through a JSON file — export it, edit externally (e.g. with an AI), re-import.

- Export downloads a self-documenting JSON (canonical classify.serialize() state
  + an informational sourceFiles inventory + a _format note). Lossless: empty
  tree branches and unassigned state survive.
- Import validates, confirms before replacing a non-empty current dataset, and
  loads via classify.load() (ignores the wrapper/_format/sourceFiles keys).

Test: serialize → JSON → load preserves trees (incl. an empty branch) +
assignments (classify.spec.js -> 34 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:18:34 -05:00
139171481e feat(classifier): three-state filters, expand/collapse-all, drop-prompt, preview + editable filenames
Classify & Copy interaction pass (replaces the single "Hide Assigned" toggle):

- Source-tree filters: three "Show Unassigned / Show Assigned / Show Excluded"
  checkboxes (classify mode only) with live per-tab counts; "Hide Compliant" is
  now rename-mode only. Folders with nothing visible collapse out.
- Target tree: ctrl/cmd-click a toggle to expand/collapse the whole subtree.
- Tracking drop-to-any-level: dropping on a node that isn't already a complete
  leaf prompts for the remaining levels (e.g. "0001_0 (IFU)"), which are parsed
  and nested under the drop target. Dropping on a finished leaf assigns directly.
- Placed-file rows: click to preview; the derived filename is now an inline
  input — edit it (full "TRACKING_REV (STATUS) - Title.ext") and the item is
  re-filed onto the parsed tracking path (created if needed) + title override.

New classify helpers: trackingNodeComplete, trackingPathLabel. tree.setShowFilters
replaces setHideAssigned. Tests updated/added (classify.spec.js -> 33 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:08:30 -05:00
17 changed files with 4887 additions and 503 deletions

View file

@ -76,6 +76,8 @@
}
.folder-tree-pane.collapsed .pane-header-controls,
.folder-tree-pane.collapsed .classify-filters,
.folder-tree-pane.collapsed .tree-filter,
.folder-tree-pane.collapsed .folder-tree,
.folder-tree-pane.collapsed .pane-header h3 {
display: none;
@ -141,11 +143,30 @@
.pane-header-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-end;
flex-wrap: wrap;
gap: 0.3rem 0.75rem;
align-items: center;
justify-content: flex-end;
}
/* Classify-mode filter row, laid out as a toolbar under the pane header. */
.tree-toolbar {
display: flex; flex-wrap: wrap; align-items: center;
gap: 0.2rem 0.7rem; padding: 0.3rem 1rem;
border-bottom: 1px solid var(--border);
}
.tree-toolbar__label { color: var(--text-muted); font-size: 0.8rem; font-weight: 600; }
.classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; }
/* Live filter box above a file tree. */
.tree-filter {
width: 100%; box-sizing: border-box; margin: 0.25rem 0;
padding: 0.25rem 0.5rem; font: inherit; font-size: 0.85rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text);
}
.tree-filter:focus { outline: none; border-color: var(--primary); }
.folder-stats,
.file-stats {
display: flex;
@ -478,7 +499,13 @@
/* placed-file row in the target pane is clickable (reveal in source) */
.tfile { cursor: pointer; }
.tfile:hover .tfile__name { text-decoration: underline; }
.tfile:hover .tfile__orig { text-decoration: underline; } /* click row (not the name input) → preview */
input.tfile__name {
flex: 1 1 auto; min-width: 10rem; font: inherit; color: var(--text);
border: 1px solid transparent; background: transparent; border-radius: 3px; padding: 0 0.2rem;
}
input.tfile__name:hover { border-color: var(--border); }
input.tfile__name:focus { border-color: var(--primary); background: var(--bg); outline: none; }
/* cross-tree reveal flash */
.reveal-flash, .match-highlight { animation: cl-flash 1.5s ease-out; }

View file

@ -143,8 +143,18 @@
sha256Checkbox: document.getElementById('sha256Checkbox'),
hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'),
hideCompliantLabel: document.getElementById('hideCompliantLabel'),
hideAssignedCheckbox: document.getElementById('hideAssignedCheckbox'),
hideAssignedLabel: document.getElementById('hideAssignedLabel'),
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'),
@ -189,7 +199,7 @@
// 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.hideAssignedLabel) app.dom.hideAssignedLabel.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();
@ -199,6 +209,122 @@
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
*/
@ -223,14 +349,19 @@
// Hide compliant toggle
app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
// Hide assigned toggle (classify mode — filters the source tree)
if (app.dom.hideAssignedCheckbox) {
app.dom.hideAssignedCheckbox.addEventListener('change', function () {
if (app.modules.tree && app.modules.tree.setHideAssigned) {
app.modules.tree.setHideAssigned(this.checked);
}
});
// 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);
@ -239,6 +370,29 @@
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);

View file

@ -486,6 +486,57 @@
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; }
@ -506,6 +557,9 @@
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

View file

@ -90,7 +90,7 @@
async function sameContent(existingHandle, srcFileObj) {
var ef = await existingHandle.getFile();
var sf = await (await srcHandle(srcFileObj)).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);
@ -105,6 +105,16 @@
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);
@ -113,7 +123,7 @@
if (existing) {
return (await sameContent(existing, p.file)) ? 'skipped' : 'differ';
}
var srcFile = await (await srcHandle(p.file)).getFile(); // READ source (never write it)
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);

View file

@ -526,12 +526,22 @@
// permission re-grant) before opening the preview window.
async function previewFile(file) {
try {
if (!file.handle && !file.isVirtual && window.app.rootHandle) {
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 window.app.modules.scanner.resolveFileHandle(window.app.rootHandle, file);
await sc.resolveFileHandle(window.app.rootHandle, file);
}
await openPreviewWindow(file);
} catch (e) {

View file

@ -369,11 +369,23 @@
// 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' || !node._zipFileObj) return;
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, node._zipFileObj); // builds children, runFiles/runDirs, sets 'done'
await scanZipIntoNode(node, fileObj); // builds children, runFiles/runDirs, sets 'done'
} catch (e) {
reportScanError(node.path, e);
node.scanState = 'done';
@ -754,22 +766,30 @@
// ── Workspace snapshot (scan once, resume without re-walking the FS) ────
// Serialize the completed scan to compact JSON (short keys: large trees).
// Zip-root nodes are NOT preserved as expandable folders — the .zip stays a
// plain file in its parent (classifying inside archives is out of scope for
// a persisted workspace).
// 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) { return { o: f.originalFilename, e: f.extension, p: f.folderPath }; }
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);
var realKids = (n.children || []).filter(function (c) { return !c.isZipRoot; });
if (realKids.length) o.c = realKids.map(serNode);
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/scanning/zip) → 'pending' to re-read. 'done'
// is the default and omitted.
// 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 (st && st !== 'done') o.s = (st === 'children') ? 'children' : 'pending';
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);
@ -780,7 +800,7 @@
// workspace root handle at copy/preview time.
function loadSnapshot(snap) {
function deFile(sf) {
return {
var fo = {
handle: null, folderHandle: null,
originalFilename: sf.o, extension: sf.e,
size: null, lastModified: null,
@ -788,11 +808,18 @@
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 node = makeNode({ name: sn.n, kind: 'directory' }, sn.p, 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;
node.scanState = sn.s || 'done'; // 'pending'/'children' resume on reconnect
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); });
@ -819,6 +846,29 @@
// ── 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);
@ -886,6 +936,8 @@
loadSnapshot,
resolveFileHandle,
resolveDirHandle,
ensureZipLoaded,
extractZipMember,
resumeScan
};
})();

View file

@ -50,6 +50,8 @@
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');
@ -161,50 +163,84 @@
files.forEach(function (f) {
var d = C().deriveTarget(f);
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
row.title = d.errors.length ? d.errors.join('; ') : 'Click to find this file in the source tree';
row.dataset.key = d.key; // for cross-tree reveal
row.appendChild(el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')));
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', '→'));
row.appendChild(el('span', 'tfile__name', d.filename || '(incomplete)'));
// 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;
}
// Tracking tree (recursive)
// ── 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) { container.appendChild(trackingNode(n, placedMap)); });
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) {
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 ? '·' : (collapsed[n.id] ? '▸' : '▾'));
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));
var placed = placedMap[n.id] || [];
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 (placed.length) wrap.appendChild(fileList(placed));
if (!isLeaf && !collapsed[n.id]) {
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
if (!isLeaf && expanded && childEls.length) {
var kids = el('div', 'tnode__children');
(n.children || []).forEach(function (c) { kids.appendChild(trackingNode(c, placedMap)); });
childEls.forEach(function (ce) { kids.appendChild(ce); });
wrap.appendChild(kids);
}
return wrap;
@ -217,20 +253,14 @@
container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.'));
return;
}
parties.forEach(function (p) { container.appendChild(partyNode(p, placedMap)); });
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 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);
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');
@ -247,22 +277,39 @@
sw.appendChild(binForm(party.id, slot));
}
(slotNode ? slotNode.children : []).forEach(function (bin) {
sw.appendChild(binNode(bin, placedMap));
var be = binNode(bin, placedMap, partyMatch);
if (be) { sw.appendChild(be); anyBin = true; }
});
wrap.appendChild(sw);
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) {
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)'));
var placed = placedMap[bin.id] || [];
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 (placed.length) wrap.appendChild(fileList(placed));
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
return wrap;
}
@ -290,21 +337,65 @@
var n = target.closest('.tnode');
return n ? n.dataset.id : null;
}
function revealInSource(e) {
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 && window.app.modules.tree.revealFile) {
window.app.modules.tree.revealFile(tf.dataset.key);
return true;
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 false;
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 (revealInSource(e)) return;
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') { collapsed[id] = !collapsed[id]; render(); return; }
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)', '');
@ -318,7 +409,7 @@
}
}
function onTransmittalClick(e) {
if (revealInSource(e)) return;
if (previewFromTarget(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
@ -391,10 +482,26 @@
e.preventDefault();
var keys = window.app.modules.dnd.getDrag();
window.app.modules.dnd.clearDrag();
if (keys.length) C().place(keys, t.id, axis);
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);
@ -421,6 +528,7 @@
render: render,
showTab: showTab,
activeAxis: activeAxis,
setNameFilter: setNameFilter,
reveal: reveal,
};
})();

View file

@ -35,35 +35,119 @@
return dot;
}
// ── "Hide Assigned" filter (classify mode) ─────────────────────────────
// The goal in either target tab is to assign-or-exclude every file, so this
// collapses the left tree down to only what's left to deal with on the
// ACTIVE axis: hide files already assigned in the current tab (or excluded),
// and any folder whose whole (scanned) subtree is thereby empty.
var hideAssigned = false;
function setHideAssigned(on) { hideAssigned = !!on; render(); }
// ── 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';
}
function fileDealtWith(file) {
// 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) return false;
if (a.excluded) return true;
return activeAxis() === 'transmittal' ? !!a.transmittalNodeId : !!a.trackingNodeId;
if (a && a.excluded) return 'excluded';
var assigned = a && (activeAxis() === 'transmittal' ? a.transmittalNodeId : a.trackingNodeId);
return assigned ? 'assigned' : 'unassigned';
}
function subtreeRemaining(folder) {
var n = 0;
subtreeFiles(folder).forEach(function (f) { if (!fileDealtWith(f)) n++; });
return n;
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();
}
// Hide a folder only when it's fully scanned (so we never hide one that may
// still reveal files) and nothing in its subtree remains to be dealt with.
function folderHidden(folder) {
if (!classifyOn() || !hideAssigned) return false;
if (folder.scanState && folder.scanState !== 'done') return false;
return subtreeRemaining(folder) === 0;
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] + ')';
});
}
/**
@ -71,8 +155,13 @@
*/
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 = '<div class="tree-empty">No folders found</div>';
@ -80,12 +169,17 @@
}
window.app.folderTree.forEach(folder => {
if (folderHidden(folder)) return;
if (!folderShown(folder)) return;
const element = createFolderElement(folder);
container.appendChild(element);
});
if (!container.children.length) {
container.innerHTML = '<div class="tree-empty">'
+ (filterActive() ? 'No files match “' + nameFilter + '”.' : 'Nothing matches the current filters.') + '</div>';
}
updateSelectedCount();
container.scrollTop = prevScroll;
}
/**
@ -190,7 +284,7 @@
// 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;
@ -268,12 +362,13 @@
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 (folderHidden(child)) return;
if (!folderShown(child)) return;
const childElement = createFolderElement(child, level + 1);
childrenDiv.appendChild(childElement);
});
@ -281,12 +376,12 @@
}
// Classify mode: list this folder's own files (draggable leaves) when
// expanded, so they can be dropped onto the target trees.
if (classifyOn() && folder.expanded && folder.files && folder.files.length > 0) {
// 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 (hideAssigned && fileDealtWith(file)) return;
if (!fileShown(file)) return;
filesDiv.appendChild(createFileElement(file, level + 1));
});
div.appendChild(filesDiv);
@ -541,9 +636,10 @@
* 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`;
}
/**
@ -864,6 +960,7 @@
expandAll,
selectAll,
revealFile,
setHideAssigned
setShowFilters,
setNameFilter
};
})();

View file

@ -61,14 +61,29 @@
<input type="checkbox" id="hideCompliantCheckbox">
Hide Compliant
</label>
<label class="checkbox-label" id="hideAssignedLabel" hidden
title="Hide files already assigned in the active tab (or excluded), and folders left empty">
<input type="checkbox" id="hideAssignedCheckbox">
Hide Assigned
</label>
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
</div>
</div>
<div id="classifyFilters" class="classify-filters tree-toolbar" hidden>
<span class="tree-toolbar__label">Show</span>
<label class="checkbox-label" title="Files not yet assigned in the active tab">
<input type="checkbox" id="showUnassignedCheckbox" checked>
Unassigned <span class="filter-count" id="showUnassignedCount"></span>
</label>
<label class="checkbox-label" title="Files already assigned in the active tab">
<input type="checkbox" id="showAssignedCheckbox" checked>
Assigned <span class="filter-count" id="showAssignedCount"></span>
</label>
<label class="checkbox-label" title="Excluded files">
<input type="checkbox" id="showExcludedCheckbox" checked>
Excluded <span class="filter-count" id="showExcludedCount"></span>
</label>
<label class="checkbox-label" title="Folders that contain no files">
<input type="checkbox" id="showEmptyCheckbox" checked>
Empty
</label>
</div>
<input type="search" id="treeFilterInput" class="tree-filter" spellcheck="false"
placeholder="Filter files… (e.g. master deliverables list)" aria-label="Filter files">
<div id="folderTree" class="folder-tree">
<!-- Dynamically populated -->
</div>
@ -147,6 +162,10 @@
<div class="pane-header-right">
<span id="classifyStats" class="file-stats"></span>
<span class="header-divider">|</span>
<button id="exportDatasetBtn" class="btn btn-secondary btn-sm" title="Export the classification dataset (trees + assignments) as JSON">Export</button>
<button id="importDatasetBtn" class="btn btn-secondary btn-sm" title="Import a classification dataset JSON (replaces the current one)">Import</button>
<input type="file" id="importDatasetInput" accept="application/json,.json" hidden>
<button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button>
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to an output directory (source untouched)">Copy…</button>
</div>
</div>
@ -156,6 +175,8 @@
<button id="addTrackingRootBtn" class="btn btn-sm btn-secondary">+ Root folder</button>
<span class="target-hint">Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”.</span>
</div>
<input type="search" id="trackingFilterInput" class="tree-filter target-filter" spellcheck="false"
placeholder="Filter the tracking tree…" aria-label="Filter tracking tree">
<div id="trackingTree" class="target-tree"></div>
</section>
<section id="transmittalPanel" class="target-panel" hidden>
@ -163,6 +184,8 @@
<button id="addPartyBtn" class="btn btn-sm btn-secondary">+ Party</button>
<span class="target-hint">&lt;party&gt;/{received,issued}/&lt;transmittal&gt;. Drag files (or a whole folder) into a transmittal.</span>
</div>
<input type="search" id="transmittalFilterInput" class="tree-filter target-filter" spellcheck="false"
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
<div id="transmittalTree" class="target-tree"></div>
</section>
</div>

View file

@ -575,7 +575,7 @@ test('Hide Assigned: hides files dealt-with on the active axis and folders left
expect(before).toBe(2); // nothing hidden yet
const after = await page.evaluate(() => {
window.app.modules.tree.setHideAssigned(true);
window.app.modules.tree.setShowFilters({ unassigned: true, assigned: false, excluded: false });
return {
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map(e => e.textContent),
folderA: !!document.querySelector('#folderTree .folder-item[data-path="A"]'),
@ -617,3 +617,269 @@ test('add-folder builds a nested chain sharing common ancestors', async ({ page
expect(r[0].children.map((n) => n.name)).toEqual(['0001', '0002']);
expect(r[0].children[0].children[0].name).toBe('0 (IFU)'); // leaf rev under each number
});
test('trackingNodeComplete: true only for a leaf with a valid status', async ({ page }) => {
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const parent = c.addTrackingNode(null, 'CPO');
const num = c.addTrackingNode(parent, '0001');
const leaf = c.addTrackingNode(num, '0 (IFU)');
const bare = c.addTrackingNode(c.addTrackingNode(null, 'X'), '0001'); // leaf, no status
return {
root: c.trackingNodeComplete(parent), // has children
num: c.trackingNodeComplete(num), // has a child leaf
leaf: c.trackingNodeComplete(leaf), // leaf + valid status
bare: c.trackingNodeComplete(bare), // leaf, no "(STATUS)"
};
});
expect(r).toEqual({ root: false, num: false, leaf: true, bare: false });
});
test('editing a placed files filename re-files it onto the parsed tracking path', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'OLD'), 'A (IFR)');
const file = { folderPath: 'Root/Sub', originalFilename: 'doc', extension: 'pdf' };
const key = c.srcKeyForFile(file);
c.place([key], leaf, 'tracking');
window.app.folderTree = [{
name: 'Sub', path: 'Sub', expanded: true, scanState: 'done', children: [], files: [file],
}];
window.app.modules.targetTree.render();
const input = document.querySelector('#trackingTree .tfile__name');
input.value = 'CPO-0002_0 (IFU) - New Title.pdf';
input.dispatchEvent(new Event('change', { bubbles: true }));
const d = c.deriveTarget(file);
return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title, complete: d.complete };
});
expect(r.tracking).toBe('CPO-0002');
expect(r.revision).toBe('0');
expect(r.status).toBe('IFU');
expect(r.title).toBe('New Title');
});
test('dataset (filename-based): import reconstruction rebuilds tracking + shared transmittals', async ({ page }) => {
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const z = window.zddc;
c.reset();
// Mirrors app.importDataset's per-record reconstruction: two docs sharing
// one transmittal package, plus an excluded junk file.
const recs = [
{ source: 'a.pdf', filename: 'CPO-0001_0 (IFU) - Doc A.pdf', excluded: false,
transmittal: { party: 'Acme', slot: 'received', date: '2025-10-31', type: 'TRN', seq: '0043', status: 'IFC', title: 'Pkg' } },
{ source: 'b.pdf', filename: 'CPO-0002_0 (IFU) - Doc B.pdf', excluded: false,
transmittal: { party: 'Acme', slot: 'received', date: '2025-10-31', type: 'TRN', seq: '0043', status: 'IFC', title: 'Pkg' } },
{ source: 'junk.tmp', filename: '', excluded: true },
];
recs.forEach((rec) => {
if (rec.excluded) { c.setExcluded([rec.source], true); return; }
const p = z.parseFilename(rec.filename);
c.place([rec.source], c.addTrackingPath(null, c.parseFolderLevels(p.trackingNumber + '_' + p.revision + ' (' + p.status + ')')), 'tracking');
c.setTitleOverride(rec.source, p.title);
const t = rec.transmittal;
const bid = c.findOrAddTransmittalBin(c.findOrAddParty(t.party), t.slot, t);
c.place([rec.source], bid, 'transmittal');
});
const da = c.deriveTarget({ folderPath: '', originalFilename: 'a', extension: 'pdf' }); // key 'a.pdf'
const tree = c.getTransmittalTree();
return {
tracking: da.tracking, rev: da.revision, status: da.status, title: da.title,
parties: tree.length,
bins: tree[0] ? tree[0].children.filter((s) => s.slot === 'received')[0].children.length : -1,
excluded: c.getAssignment('junk.tmp').excluded,
};
});
expect(r.tracking).toBe('CPO-0001');
expect(r.rev).toBe('0');
expect(r.status).toBe('IFU');
expect(r.title).toBe('Doc A');
expect(r.parties).toBe(1); // one Acme party
expect(r.bins).toBe(1); // shared transmittal → single bin (dedup)
expect(r.excluded).toBe(true);
});
test('source-tree filter reveals matches with their folder hierarchy', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [{
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
{ name: 'Electrical', path: 'Project/Electrical', expanded: false, scanState: 'done', children: [], files: [
{ originalFilename: 'Master Deliverables List', extension: 'xlsx', folderPath: 'Project/Electrical' },
{ originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' },
] },
{ name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [
{ originalFilename: 'Site Plan', extension: 'pdf', folderPath: 'Project/Civil' },
] },
],
}];
window.app.modules.tree.render();
window.app.modules.tree.setNameFilter('master deliverables');
return {
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent),
folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path),
};
});
expect(r.files).toEqual(['Master Deliverables List.xlsx']); // only the match shown
expect(r.folders).toEqual(['Project', 'Project/Electrical']); // path revealed; Civil hidden
});
test('tracking-tree filter reveals matching nodes and hides the rest', async ({ page }) => {
await page.click('#modeClassifyBtn');
const names = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)'));
c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)'));
window.app.modules.targetTree.render();
window.app.modules.targetTree.setNameFilter('CPO');
return Array.from(document.querySelectorAll('#trackingTree .tnode__name')).map((e) => e.textContent);
});
expect(names).toContain('CPO');
expect(names).toContain('0001');
expect(names).not.toContain('XYZ');
});
test('Show Empty off hides folders that contain no files', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [
{ name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [],
files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' }] },
{ name: 'EmptyDir', path: 'EmptyDir', expanded: true, scanState: 'done', children: [], files: [] },
];
window.app.modules.tree.render();
const before = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path).sort();
window.app.modules.tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false });
const after = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path).sort();
return { before, after };
});
expect(r.before).toEqual(['Docs', 'EmptyDir']); // both shown by default
expect(r.after).toEqual(['Docs']); // empty folder hidden when Show Empty off
});
test('toggling a Show filter preserves collapse state (no force-expand)', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [{
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
{ name: 'Sub', path: 'Project/Sub', expanded: false, scanState: 'done', children: [],
files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Project/Sub' }] },
],
}];
const tree = window.app.modules.tree;
tree.render();
const collapsed = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
// A Show toggle must not expand the collapsed parent…
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false });
const afterToggle = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
// …whereas a name search still reveals the match by auto-expanding.
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: true });
tree.setNameFilter('a.pdf');
const afterSearch = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
return { collapsed, afterToggle, afterSearch };
});
expect(r.collapsed).toEqual(['Project']); // child hidden — parent collapsed
expect(r.afterToggle).toEqual(['Project']); // Show toggle leaves it collapsed
expect(r.afterSearch).toEqual(['Project', 'Project/Sub']); // name search auto-expands to the match
});
test('search opens only the branch with a hit, leaving siblings collapsed', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [{
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
{ name: 'Electrical', path: 'Project/Electrical', expanded: false, scanState: 'done', children: [], files: [
{ originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' },
] },
{ name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [
{ originalFilename: 'Site Plan', extension: 'pdf', folderPath: 'Project/Civil' },
] },
],
}];
const tree = window.app.modules.tree;
tree.render();
tree.setNameFilter('switchgear'); // a file deep in the Electrical branch
return {
folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path),
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent),
};
});
// Path to the hit opens; the unrelated Civil sibling is not force-opened (stays out).
expect(r.folders).toEqual(['Project', 'Project/Electrical']);
expect(r.files).toEqual(['Switchgear Spec.pdf']);
});
test('snapshot: a scanned zip subtree round-trips with its virtual members', async ({ page }) => {
const r = await page.evaluate(() => {
const sc = window.app.modules.scanner;
window.app.folderTree = [{
name: 'Root', path: 'Root', scanState: 'done', files: [], children: [{
name: 'docs.zip', path: 'Root/docs.zip', isZipRoot: true, zipPath: 'Root/docs.zip',
scanState: 'done', children: [], files: [{
originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip',
isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf',
}],
}],
}];
const json = JSON.stringify(sc.snapshotTree());
window.app.folderTree = [];
sc.loadSnapshot(JSON.parse(json));
const zip = window.app.folderTree[0].children[0];
const m = zip.files[0];
return {
isZipRoot: zip.isZipRoot, zipPath: zip.zipPath, done: zip.scanState === 'done',
virtual: m.isVirtual, mZip: m.zipPath, entry: m.zipEntryPath, handleNull: m.handle === null,
};
});
expect(r.isZipRoot).toBe(true); // archive preserved as an expandable folder
expect(r.zipPath).toBe('Root/docs.zip');
expect(r.done).toBe(true);
expect(r.virtual).toBe(true); // member flagged virtual…
expect(r.mZip).toBe('Root/docs.zip'); // …with enough to re-extract
expect(r.entry).toBe('spec.pdf');
expect(r.handleNull).toBe(true);
});
test('copy: a zip member is extracted from its archive and written out', async ({ page }) => {
await page.click('#modeClassifyBtn');
const res = await page.evaluate(async () => {
const c = window.app.modules.classify, copy = window.app.modules.copy;
const f = {
originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip',
isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf', handle: null,
};
window.app.folderTree = [{ name: 'Root', path: 'Root', files: [], children: [
{ name: 'docs.zip', path: 'Root/docs.zip', isZipRoot: true, files: [f], children: [] },
] }];
// Stub archive extraction — return the member's bytes as a Blob.
window.app.rootHandle = {};
window.app.modules.scanner.extractZipMember = async () => new File(['ZIPBYTES'], 'spec.pdf');
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
c.place([c.srcKeyForFile(f)], leaf, 'tracking'); c.place([c.srcKeyForFile(f)], bin, 'transmittal');
const outStore = {};
const mkOut = (prefix) => ({
name: prefix || 'out',
getDirectoryHandle: async (n) => mkOut((prefix ? prefix + '/' : '') + n),
getFileHandle: async (n, opts) => {
const full = (prefix ? prefix + '/' : '') + n;
if (!opts || !opts.create) { if (!(full in outStore)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } }
return {
getFile: async () => new File([outStore[full] != null ? outStore[full] : ''], n),
createWritable: async () => ({ write: async (d) => { outStore[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }),
};
},
});
const s = await copy.copyTo(mkOut(''), copy.plan());
return { copied: s.copied, content: Object.values(outStore)[0], wrote: Object.keys(outStore).some((k) => k.endsWith('spec.pdf')) };
});
expect(res.copied).toBe(1);
expect(res.wrote).toBe(true);
expect(res.content).toBe('ZIPBYTES');
});

View file

@ -814,23 +814,75 @@ body.help-open .app-header {
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
/* Toast STACK — bottom-right, newest at the bottom. The container is
click-through (pointer-events:none) so the gaps don't block the page; each
toast + button re-enables pointer events. */
.zddc-toasts {
position: fixed;
bottom: 2rem;
right: 2rem;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
max-height: calc(100vh - 3rem);
overflow-y: auto;
pointer-events: none;
}
/* "Clear all" — shown above the stack when 2+ toasts are present. */
.zddc-toasts__clear {
pointer-events: auto;
align-self: flex-end;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
cursor: pointer;
}
.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); }
.zddc-toast {
position: relative;
pointer-events: auto;
background: var(--bg);
color: var(--text);
padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
max-width: 420px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
/* Message text — selectable + copyable; long/multi-line errors wrap. */
.zddc-toast__msg {
user-select: text;
-webkit-user-select: text;
cursor: text;
white-space: pre-wrap;
word-break: break-word;
}
/* Per-toast dismiss. */
.zddc-toast__close {
position: absolute;
top: 0.2rem;
right: 0.35rem;
border: none;
background: transparent;
color: var(--text-muted, #888);
font-size: 1.15rem;
line-height: 1;
cursor: pointer;
padding: 0 0.15rem;
}
.zddc-toast__close:hover { color: var(--text); }
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
@ -2665,7 +2717,7 @@ td[data-field="trackingNumber"] {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:32:03 · 203674e</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
@ -5037,74 +5089,122 @@ X.B(E,Y);return E}return J}())
}());
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
// tool via window.zddc.toast(msg, level, opts).
//
// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY —
// they stay until the user dismisses them (per-toast × or a "Clear all"
// button) so the message can be read, selected, and copied while
// troubleshooting. info/success toasts auto-dismiss. The message text is
// always selectable.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Saved.', 'success'); // auto-dismiss
// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
// Levels that persist until the user dismisses them (troubleshooting).
var STICKY = { error: true, warning: true };
function container() {
var c = document.getElementById('zddc-toasts');
if (c) return c;
c = document.createElement('div');
c.id = 'zddc-toasts';
c.className = 'zddc-toasts';
document.body.appendChild(c);
return c;
}
// Show/hide a "Clear all" control when 2+ toasts are stacked.
function refreshClearAll(c) {
var bar = c.querySelector('.zddc-toasts__clear');
var count = c.querySelectorAll('.zddc-toast').length;
if (count >= 2) {
if (!bar) {
bar = document.createElement('button');
bar.type = 'button';
bar.className = 'zddc-toasts__clear';
bar.textContent = 'Clear all';
bar.addEventListener('click', function () {
var all = c.querySelectorAll('.zddc-toast');
for (var i = 0; i < all.length; i++) dismiss(all[i]);
});
c.insertBefore(bar, c.firstChild);
}
} else if (bar) {
bar.remove();
}
}
function dismiss(el) {
if (el._dismissed) return;
el._dismissed = true;
if (el._timer) clearTimeout(el._timer);
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
refreshClearAll(container());
}, FADE_MS);
}
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var c = container();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Selectable, copyable message text (its own element so clicking to
// select doesn't dismiss the toast — only the × does).
var msg = document.createElement('span');
msg.className = 'zddc-toast__msg';
msg.textContent = message == null ? '' : String(message);
el.appendChild(msg);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
var close = document.createElement('button');
close.type = 'button';
close.className = 'zddc-toast__close';
close.setAttribute('aria-label', 'Dismiss');
close.textContent = '×';
close.addEventListener('click', function () { dismiss(el); });
el.appendChild(close);
c.appendChild(el);
// Sticky (error/warning, or opts.durationMs === 0) persists; otherwise
// auto-dismiss after the (overridable) duration.
var sticky = opts.durationMs === 0 ||
(typeof opts.durationMs !== 'number' && STICKY[lvl]);
if (!sticky) {
var dur = typeof opts.durationMs === 'number'
? opts.durationMs : DEFAULT_DURATION_MS;
el._timer = setTimeout(function () { dismiss(el); }, dur);
}
refreshClearAll(c);
return el;
}
window.zddc.toast = toast;
// Route window.alert() calls into the toast helper. Every tool has
// accumulated some `alert(...)` sites for error reporting; rather
// than touch each one, intercept globally so they're non-blocking
// and ARIA-announced consistently. Native alert is preserved on
// window.alertNative for the rare case where a truly modal block
// is needed (e.g. before navigating away with unsaved changes).
// Route window.alert() into the toast helper (non-blocking, ARIA-announced,
// consistent). Native alert preserved on window.alertNative. alert() maps
// to an error toast (sticky) since that's its usual purpose.
if (typeof window.alert === 'function' && !window.alertNative) {
window.alertNative = window.alert.bind(window);
window.alert = function (msg) {

View file

@ -814,23 +814,75 @@ body.help-open .app-header {
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
/* Toast STACK — bottom-right, newest at the bottom. The container is
click-through (pointer-events:none) so the gaps don't block the page; each
toast + button re-enables pointer events. */
.zddc-toasts {
position: fixed;
bottom: 2rem;
right: 2rem;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
max-height: calc(100vh - 3rem);
overflow-y: auto;
pointer-events: none;
}
/* "Clear all" — shown above the stack when 2+ toasts are present. */
.zddc-toasts__clear {
pointer-events: auto;
align-self: flex-end;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
cursor: pointer;
}
.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); }
.zddc-toast {
position: relative;
pointer-events: auto;
background: var(--bg);
color: var(--text);
padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
max-width: 420px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
/* Message text — selectable + copyable; long/multi-line errors wrap. */
.zddc-toast__msg {
user-select: text;
-webkit-user-select: text;
cursor: text;
white-space: pre-wrap;
word-break: break-word;
}
/* Per-toast dismiss. */
.zddc-toast__close {
position: absolute;
top: 0.2rem;
right: 0.35rem;
border: none;
background: transparent;
color: var(--text-muted, #888);
font-size: 1.15rem;
line-height: 1;
cursor: pointer;
padding: 0 0.15rem;
}
.zddc-toast__close:hover { color: var(--text); }
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
@ -2772,7 +2824,7 @@ li.CodeMirror-hint-active {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:14 · 237c353</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:32:03 · 203674e</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
@ -5173,74 +5225,122 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}());
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
// tool via window.zddc.toast(msg, level, opts).
//
// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY —
// they stay until the user dismisses them (per-toast × or a "Clear all"
// button) so the message can be read, selected, and copied while
// troubleshooting. info/success toasts auto-dismiss. The message text is
// always selectable.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Saved.', 'success'); // auto-dismiss
// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
// Levels that persist until the user dismisses them (troubleshooting).
var STICKY = { error: true, warning: true };
function container() {
var c = document.getElementById('zddc-toasts');
if (c) return c;
c = document.createElement('div');
c.id = 'zddc-toasts';
c.className = 'zddc-toasts';
document.body.appendChild(c);
return c;
}
// Show/hide a "Clear all" control when 2+ toasts are stacked.
function refreshClearAll(c) {
var bar = c.querySelector('.zddc-toasts__clear');
var count = c.querySelectorAll('.zddc-toast').length;
if (count >= 2) {
if (!bar) {
bar = document.createElement('button');
bar.type = 'button';
bar.className = 'zddc-toasts__clear';
bar.textContent = 'Clear all';
bar.addEventListener('click', function () {
var all = c.querySelectorAll('.zddc-toast');
for (var i = 0; i < all.length; i++) dismiss(all[i]);
});
c.insertBefore(bar, c.firstChild);
}
} else if (bar) {
bar.remove();
}
}
function dismiss(el) {
if (el._dismissed) return;
el._dismissed = true;
if (el._timer) clearTimeout(el._timer);
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
refreshClearAll(container());
}, FADE_MS);
}
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var c = container();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Selectable, copyable message text (its own element so clicking to
// select doesn't dismiss the toast — only the × does).
var msg = document.createElement('span');
msg.className = 'zddc-toast__msg';
msg.textContent = message == null ? '' : String(message);
el.appendChild(msg);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
var close = document.createElement('button');
close.type = 'button';
close.className = 'zddc-toast__close';
close.setAttribute('aria-label', 'Dismiss');
close.textContent = '×';
close.addEventListener('click', function () { dismiss(el); });
el.appendChild(close);
c.appendChild(el);
// Sticky (error/warning, or opts.durationMs === 0) persists; otherwise
// auto-dismiss after the (overridable) duration.
var sticky = opts.durationMs === 0 ||
(typeof opts.durationMs !== 'number' && STICKY[lvl]);
if (!sticky) {
var dur = typeof opts.durationMs === 'number'
? opts.durationMs : DEFAULT_DURATION_MS;
el._timer = setTimeout(function () { dismiss(el); }, dur);
}
refreshClearAll(c);
return el;
}
window.zddc.toast = toast;
// Route window.alert() calls into the toast helper. Every tool has
// accumulated some `alert(...)` sites for error reporting; rather
// than touch each one, intercept globally so they're non-blocking
// and ARIA-announced consistently. Native alert is preserved on
// window.alertNative for the rare case where a truly modal block
// is needed (e.g. before navigating away with unsaved changes).
// Route window.alert() into the toast helper (non-blocking, ARIA-announced,
// consistent). Native alert preserved on window.alertNative. alert() maps
// to an error toast (sticky) since that's its usual purpose.
if (typeof window.alert === 'function' && !window.alertNative) {
window.alertNative = window.alert.bind(window);
window.alert = function (msg) {

File diff suppressed because it is too large Load diff

View file

@ -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 {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:32:03 · 203674e</span></span>
</div>
</div>
<div class="header-right">
@ -2417,74 +2469,122 @@ body {
}());
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
// tool via window.zddc.toast(msg, level, opts).
//
// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY —
// they stay until the user dismisses them (per-toast × or a "Clear all"
// button) so the message can be read, selected, and copied while
// troubleshooting. info/success toasts auto-dismiss. The message text is
// always selectable.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Saved.', 'success'); // auto-dismiss
// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
// Levels that persist until the user dismisses them (troubleshooting).
var STICKY = { error: true, warning: true };
function container() {
var c = document.getElementById('zddc-toasts');
if (c) return c;
c = document.createElement('div');
c.id = 'zddc-toasts';
c.className = 'zddc-toasts';
document.body.appendChild(c);
return c;
}
// Show/hide a "Clear all" control when 2+ toasts are stacked.
function refreshClearAll(c) {
var bar = c.querySelector('.zddc-toasts__clear');
var count = c.querySelectorAll('.zddc-toast').length;
if (count >= 2) {
if (!bar) {
bar = document.createElement('button');
bar.type = 'button';
bar.className = 'zddc-toasts__clear';
bar.textContent = 'Clear all';
bar.addEventListener('click', function () {
var all = c.querySelectorAll('.zddc-toast');
for (var i = 0; i < all.length; i++) dismiss(all[i]);
});
c.insertBefore(bar, c.firstChild);
}
} else if (bar) {
bar.remove();
}
}
function dismiss(el) {
if (el._dismissed) return;
el._dismissed = true;
if (el._timer) clearTimeout(el._timer);
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
refreshClearAll(container());
}, FADE_MS);
}
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var c = container();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Selectable, copyable message text (its own element so clicking to
// select doesn't dismiss the toast — only the × does).
var msg = document.createElement('span');
msg.className = 'zddc-toast__msg';
msg.textContent = message == null ? '' : String(message);
el.appendChild(msg);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
var close = document.createElement('button');
close.type = 'button';
close.className = 'zddc-toast__close';
close.setAttribute('aria-label', 'Dismiss');
close.textContent = '×';
close.addEventListener('click', function () { dismiss(el); });
el.appendChild(close);
c.appendChild(el);
// Sticky (error/warning, or opts.durationMs === 0) persists; otherwise
// auto-dismiss after the (overridable) duration.
var sticky = opts.durationMs === 0 ||
(typeof opts.durationMs !== 'number' && STICKY[lvl]);
if (!sticky) {
var dur = typeof opts.durationMs === 'number'
? opts.durationMs : DEFAULT_DURATION_MS;
el._timer = setTimeout(function () { dismiss(el); }, dur);
}
refreshClearAll(c);
return el;
}
window.zddc.toast = toast;
// Route window.alert() calls into the toast helper. Every tool has
// accumulated some `alert(...)` sites for error reporting; rather
// than touch each one, intercept globally so they're non-blocking
// and ARIA-announced consistently. Native alert is preserved on
// window.alertNative for the rare case where a truly modal block
// is needed (e.g. before navigating away with unsaved changes).
// Route window.alert() into the toast helper (non-blocking, ARIA-announced,
// consistent). Native alert preserved on window.alertNative. alert() maps
// to an error toast (sticky) since that's its usual purpose.
if (typeof window.alert === 'function' && !window.alertNative) {
window.alertNative = window.alert.bind(window);
window.alert = function (msg) {

View file

@ -818,23 +818,75 @@ body.help-open .app-header {
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
/* Toast STACK — bottom-right, newest at the bottom. The container is
click-through (pointer-events:none) so the gaps don't block the page; each
toast + button re-enables pointer events. */
.zddc-toasts {
position: fixed;
bottom: 2rem;
right: 2rem;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
max-height: calc(100vh - 3rem);
overflow-y: auto;
pointer-events: none;
}
/* "Clear all" — shown above the stack when 2+ toasts are present. */
.zddc-toasts__clear {
pointer-events: auto;
align-self: flex-end;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
cursor: pointer;
}
.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); }
.zddc-toast {
position: relative;
pointer-events: auto;
background: var(--bg);
color: var(--text);
padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
max-width: 420px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
/* Message text — selectable + copyable; long/multi-line errors wrap. */
.zddc-toast__msg {
user-select: text;
-webkit-user-select: text;
cursor: text;
white-space: pre-wrap;
word-break: break-word;
}
/* Per-toast dismiss. */
.zddc-toast__close {
position: absolute;
top: 0.2rem;
right: 0.35rem;
border: none;
background: transparent;
color: var(--text-muted, #888);
font-size: 1.15rem;
line-height: 1;
cursor: pointer;
padding: 0 0.15rem;
}
.zddc-toast__close:hover { color: var(--text); }
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
@ -2718,7 +2770,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:32:03 · 203674e</span></span>
</div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action;
@ -5364,74 +5416,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) {

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
transmittal=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
classifier=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
landing=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
form=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
tables=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
browse=v0.0.27-beta · 2026-06-09 15:30:14 · 237c353
archive=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
transmittal=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
classifier=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
landing=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
form=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
tables=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
browse=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e

View file

@ -1722,7 +1722,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-10 14:42:21 · 8f839fc</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:32:03 · 203674e</span></span>
</div>
</div>
<div class="header-right">