Compare commits
9 commits
59ffd861f9
...
589d804716
| Author | SHA1 | Date | |
|---|---|---|---|
| 589d804716 | |||
| 203674ee4c | |||
| e1c479dba5 | |||
| 15ce7098a0 | |||
| aa34a4b3e7 | |||
| c61cac7c8f | |||
| 9851cc4463 | |||
| 4425a599f0 | |||
| 139171481e |
17 changed files with 4887 additions and 503 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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"><party>/{received,issued}/<transmittal>. 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>
|
||||
|
|
|
|||
|
|
@ -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 file’s 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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue