chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 23s

This commit is contained in:
ZDDC 2026-06-18 10:13:06 -05:00
parent 9ca24eb3f1
commit 8fda925992
7 changed files with 359 additions and 413 deletions

View file

@ -2717,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-16 19:19:05 · 054cf2d</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb</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>

View file

@ -2824,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-16 19:19:06 · 054cf2d</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb</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>

View file

@ -1979,7 +1979,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
}
.tg-input:hover { border-color: var(--border); }
.tg-input:focus { border-color: var(--primary); background: var(--bg); outline: none; }
.tg-tn .tg-input { font-family: var(--mono, monospace); }
.tg-tn .tg-input, .tx-path .tg-input { font-family: var(--mono, monospace); }
.tg-input.is-warn { border-color: var(--warning, #b8860b); }
.tg-orig__link { color: var(--text-muted); white-space: nowrap; text-decoration: none; cursor: pointer; }
.tg-orig__link:hover { text-decoration: underline; }
@ -2488,7 +2488,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-16 19:19:05 · 054cf2d</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
@ -2545,6 +2545,8 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
</label>
<button class="btn btn-sm export-list-btn" id="exportListBtn"
title="Copy the filtered file list (path + file columns, no folders) as TSV — paste into Excel, edit, then paste back via “Paste rows”. Paste a full path into the Current name column to bind that exact file.">⬆ Export list</button>
<button class="btn btn-sm export-list-btn" id="exportPathsBtn"
title="Download the filtered file list as a 1-column CSV of full paths. Feed it to an AI to classify into <party>/<direction>/<transmittal>/<file>.ext, then bring the 2-column result back via “Import paths” above the target list.">⬇ Export paths</button>
</div>
<input type="search" id="treeFilterInput" class="tree-filter" spellcheck="false"
placeholder="Filter files… (e.g. master deliverables list)" aria-label="Filter files">
@ -2567,9 +2569,8 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
<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="Download the classifications as a filename-per-file JSON to edit (e.g. with an AI), then re-import here. NOT a workspace — no scanned tree.">Export for editing</button>
<button id="importDatasetBtn" class="btn btn-secondary btn-sm" title="Load an edited classification JSON back in — replaces the current classifications. (To move a whole scanned workspace between browsers, use “Import workspace” on the welcome screen.)">Import edits</button>
<input type="file" id="importDatasetInput" accept="application/json,.json" hidden>
<button id="importPathsBtn" class="btn btn-secondary btn-sm" title="Import a 2-column CSV (old path, new path). Each new path “<party>/<direction>/<transmittal>/<file>.ext” sets that files tracking number (rename) and routes it into a transmittal. Only files named in the CSV are touched — others keep their current classification. Export the source list first via “Export paths” on the left.">Import paths…</button>
<input type="file" id="importPathsInput" accept=".csv,text/csv,text/plain" 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>
</div>
</div>
@ -2591,13 +2592,12 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
</section>
<section id="transmittalPanel" class="target-panel" hidden>
<div class="target-panel__toolbar">
<button id="addPartyBtn" class="btn btn-sm btn-secondary">+ Party</button>
<span class="target-hint">&lt;party&gt;/{received,issued}/&lt;transmittal&gt;. Drag files (or a whole folder) into a transmittal, then Copy into the archive.</span>
<span class="target-hint">One row per file — type its transmittal folder: &lt;party&gt;/&lt;received|issued&gt;/&lt;YYYY-MM-DD_TN (STATUS) - Title&gt;. Drag files in: drop on a row to put the file in that same folder, ⌘/Ctrl-drop to branch a new transmittal from it.</span>
<button id="checkDuplicatesBtn" class="btn btn-secondary btn-sm" title="Check for files with the same tracking number + revision but different content (flagged ≠ in red)">Check</button>
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy fully-classified files (+ their transmittal folders) into the archive — server or a local folder. Source untouched, resumable, verified.">Copy…</button>
</div>
<input type="search" id="transmittalFilterInput" class="tree-filter target-filter" spellcheck="false"
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
placeholder="Filter the transmittal grid…" aria-label="Filter transmittal grid">
<div id="transmittalTree" class="target-tree"></div>
</section>
</div>
@ -5976,9 +5976,9 @@ X.B(E,Y);return E}return J}())
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
showEmptyCheckbox: document.getElementById('showEmptyCheckbox'),
exportListBtn: document.getElementById('exportListBtn'),
exportDatasetBtn: document.getElementById('exportDatasetBtn'),
importDatasetBtn: document.getElementById('importDatasetBtn'),
importDatasetInput: document.getElementById('importDatasetInput'),
exportPathsBtn: document.getElementById('exportPathsBtn'),
importPathsBtn: document.getElementById('importPathsBtn'),
importPathsInput: document.getElementById('importPathsInput'),
resetDatasetBtn: document.getElementById('resetDatasetBtn'),
treeFilterInput: document.getElementById('treeFilterInput'),
trackingFilterInput: document.getElementById('trackingFilterInput'),
@ -6045,89 +6045,102 @@ X.B(E,Y);return E}return J}())
(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, '_') + '.zddc-classification.json';
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
// CSV cell quoting (RFC4180): quote when the value holds a comma, quote, or
// newline; embedded quotes are doubled.
function csvCell(s) { s = (s == null ? '' : String(s)); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }
// Minimal RFC4180-ish CSV parser → array of rows of string cells. Handles
// quoted fields with embedded commas/quotes/newlines (titles may contain
// commas). CRLF/CR are normalized to LF.
function parseCsv(text) {
var rows = [], row = [], field = '', inQ = false, i = 0;
text = String(text == null ? '' : text).replace(/\r\n?/g, '\n');
for (; i < text.length; i++) {
var ch = text[i];
if (inQ) {
if (ch === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else { inQ = false; } }
else { field += ch; }
} else if (ch === '"') { inQ = true; }
else if (ch === ',') { row.push(field); field = ''; }
else if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else { field += ch; }
}
if (field !== '' || row.length) { row.push(field); rows.push(row); }
return rows;
}
function importDataset(file) {
// Trigger a client-side download of `text` as `name`.
function downloadText(text, name, mime) {
var blob = new Blob([text], { type: mime || 'text/plain' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a'); a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
}
// Import a 2-column CSV (old path, new path) — e.g. an AI-classified list.
// MERGE semantics: only files named in the CSV are touched; others keep their
// current classification. Each new path
// "<party>/<direction>/<transmittal>/<file>.ext" drives two axes — the
// filename sets the tracking number (rename) and the leading segments route a
// transmittal. Either axis can apply independently; per-row problems are
// collected and offered as a downloadable errors CSV (the list can be huge).
function importPaths(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 rows = parseCsv(reader.result);
if (!rows.length) { window.zddc.toast('Import failed — the CSV is empty.', '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++; }
// Old path must resolve to a real scanned file (srcKey set).
var valid = Object.create(null);
eachSourceFile(function (f) { valid[c.srcKeyForFile(f)] = true; });
var imported = 0, errors = [];
rows.forEach(function (cells, idx) {
var oldPath = (cells[0] || '').trim();
var newPath = (cells[1] || '').trim();
// Tolerate a header row (first row whose first cell isn't a file).
if (idx === 0 && !valid[oldPath] && /^(old|path|source|from)\b/i.test(oldPath)) return;
if (!oldPath && !newPath) return; // blank line
if (!oldPath) { errors.push([oldPath, newPath, 'missing old path']); return; }
if (!valid[oldPath]) { errors.push([oldPath, newPath, 'no such file in the current scan']); return; }
if (!newPath) { errors.push([oldPath, newPath, 'missing new path']); return; }
var segs = newPath.split('/').filter(function (s) { return s !== ''; });
if (!segs.length) { errors.push([oldPath, newPath, 'empty new path']); return; }
var filename = segs[segs.length - 1];
var leading = segs.slice(0, -1);
var didTracking = false, didTransmittal = false, rowErr = '';
function note(m) { rowErr = rowErr ? rowErr + '; ' + m : m; }
// Axis 1 — filename → tracking tree (the rename).
var p = window.zddc.parseFilename(filename);
if (p && p.valid) {
var stem = p.trackingNumber + '_' + p.revision + ' (' + p.status + ')';
c.place([oldPath], c.addTrackingPath(null, c.parseFolderLevels(stem)), 'tracking');
if (p.title != null) c.setTitleOverride(oldPath, p.title);
didTracking = true;
} else {
note('filename is not a valid ZDDC name "' + filename + '"');
}
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');
// Axis 2 — <party>/<direction>/<transmittal> → transmittal tree (the
// route). Same parser the By-transmittal grid uses.
if (leading.length >= 1) {
var terr = c.setTransmittalPath([oldPath], leading.join('/'));
if (terr) note(terr); else didTransmittal = true;
}
if (didTracking || didTransmittal) imported++;
if (rowErr) errors.push([oldPath, newPath, rowErr]);
});
window.zddc.toast('Imported ' + ok + ' file' + (ok === 1 ? '' : 's')
+ (bad ? (' — ' + bad + ' had an unparseable filename') : '') + '.', bad ? 'warning' : 'success');
if (errors.length) {
var elines = ['old path,new path,reason'];
errors.forEach(function (e) { elines.push(csvCell(e[0]) + ',' + csvCell(e[1]) + ',' + csvCell(e[2])); });
downloadText(elines.join('\n'), 'classifier-import-errors.csv', 'text/csv');
}
window.zddc.toast('Imported ' + imported + ' file' + (imported === 1 ? '' : 's')
+ (errors.length ? (' — ' + errors.length + ' row' + (errors.length === 1 ? '' : 's')
+ ' had problems (downloaded classifier-import-errors.csv)') : '') + '.',
errors.length ? 'warning' : 'success');
};
reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); };
reader.readAsText(file);
@ -6207,11 +6220,13 @@ X.B(E,Y);return E}return J}())
});
// 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.exportPathsBtn) app.dom.exportPathsBtn.addEventListener('click', function () {
if (app.modules.tree && app.modules.tree.exportPathList) app.modules.tree.exportPathList();
});
if (app.dom.importPathsBtn) app.dom.importPathsBtn.addEventListener('click', function () { app.dom.importPathsInput.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]);
if (app.dom.importPathsInput) app.dom.importPathsInput.addEventListener('change', function () {
if (this.files && this.files[0]) importPaths(this.files[0]);
this.value = ''; // allow re-importing the same file
});
@ -7430,6 +7445,7 @@ X.B(E,Y);return E}return J}())
config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers)
worklist: [], // "From a list" scratch rows: [ { id, trackingNumber, title, revisionCell, source, archiveRevisions } ]
trackingWorkset: Object.create(null), // srcKeys shown as rows in the By-tracking grid (set: key->true)
transmittalWorkset: Object.create(null), // srcKeys shown as rows in the By-transmittal grid (set: key->true)
};
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
@ -7791,6 +7807,7 @@ X.B(E,Y);return E}return J}())
return { id: r.id, party: r.party, trackingNumber: r.trackingNumber, title: r.title, revisionCell: r.revisionCell, source: r.source, archiveRevisions: r.archiveRevisions };
}),
trackingWorkset: Object.keys(state.trackingWorkset),
transmittalWorkset: Object.keys(state.transmittalWorkset),
};
}
function load(obj) {
@ -7803,6 +7820,8 @@ X.B(E,Y);return E}return J}())
state.worklist = (Array.isArray(obj.worklist) ? obj.worklist : []).map(normalizeRow);
state.trackingWorkset = Object.create(null);
(Array.isArray(obj.trackingWorkset) ? obj.trackingWorkset : []).forEach(function (k) { state.trackingWorkset[k] = true; });
state.transmittalWorkset = Object.create(null);
(Array.isArray(obj.transmittalWorkset) ? obj.transmittalWorkset : []).forEach(function (k) { state.transmittalWorkset[k] = true; });
rebuildIndex();
migrateLegacyMdl(obj.worklist); // BEFORE anything can prune; materializes old mdl placements
notify();
@ -7835,6 +7854,7 @@ X.B(E,Y);return E}return J}())
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
state.outputName = null;
state.trackingWorkset = Object.create(null);
state.transmittalWorkset = Object.create(null);
rebuildIndex();
notify();
}
@ -7898,6 +7918,84 @@ X.B(E,Y);return E}return J}())
notify();
}
// ── By-transmittal grid (one editable row per file) ──────────────────────
// The transmittal tab mirrors the By-tracking grid: a flat, per-file surface
// where each file carries ONE text input — its full transmittal folder path
// "<party>/<received|issued>/<YYYY-MM-DD_TN (STATUS) - Title>". The path is
// PARSED into the transmittal tree (find-or-create party/slot/bin); structure
// is still derived, never stored. `transmittalWorkset` keeps a file on the
// grid before (and after) it has a path, exactly like `trackingWorkset`.
function addToTransmittalGrid(keys) {
var changed = false;
(keys || []).forEach(function (k) { if (!state.transmittalWorkset[k]) { state.transmittalWorkset[k] = true; changed = true; } });
if (changed) notify();
}
function transmittalGridKeys() {
var set = Object.create(null);
Object.keys(state.transmittalWorkset).forEach(function (k) { set[k] = true; });
Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].transmittalNodeId) set[k] = true; });
return Object.keys(set);
}
function transmittalHasFiles(binId) {
for (var k in state.assignments) { if (state.assignments[k].transmittalNodeId === binId) return true; }
return false;
}
// Delete a transmittal bin once nothing points at it (so re-routing doesn't
// litter the tree); drop the party too if it has no remaining bins.
function pruneEmptyTransmittal(binId) {
var info = infoFor(binId);
if (!info || info.kind !== 'transmittal' || transmittalHasFiles(binId)) return;
var slotInfo = info.parent ? infoFor(info.parent.id) : null;
var party = slotInfo && slotInfo.parent ? slotInfo.parent : null;
deleteNode(binId); // rebuilds the index + clears danglers
if (party) {
var anyBin = (party.children || []).some(function (slot) { return (slot.children || []).length; });
if (!anyBin) deleteNode(party.id);
}
}
function removeFromTransmittalGrid(key) {
var a = state.assignments[key], old = a ? a.transmittalNodeId : null;
delete state.transmittalWorkset[key];
place([key], null, 'transmittal');
if (old) pruneEmptyTransmittal(old);
notify();
}
// Route keys to the transmittal named by a "<party>/<slot>/<folder>" path,
// creating party/slot/bin as needed. Blank path clears the placement (the row
// stays, unrouted). Returns '' on success or a short error message; on error
// nothing is changed. Empties out (and prunes) any bin a key leaves behind.
function setTransmittalPath(keys, path) {
keys = keys || [];
path = (path == null ? '' : String(path)).trim();
var oldBins = Object.create(null);
keys.forEach(function (k) { var a = state.assignments[k]; if (a && a.transmittalNodeId) oldBins[a.transmittalNodeId] = true; });
if (!path) {
place(keys, null, 'transmittal');
keys.forEach(function (k) { state.transmittalWorkset[k] = true; });
Object.keys(oldBins).forEach(pruneEmptyTransmittal);
notify();
return '';
}
var segs = path.split('/').filter(function (s) { return s !== ''; });
if (segs.length < 3) return 'path must be <party>/<direction>/<transmittal>';
var party = segs[0], slot = segs[1].toLowerCase(), folder = segs.slice(2).join('/');
if (slot !== 'issued' && slot !== 'received') return 'direction must be "issued" or "received"';
var pf = zddc.parseFolder(folder);
if (!pf || !pf.valid) return 'not a valid transmittal folder "YYYY-MM-DD_TN (STATUS) - Title"';
var tnParts = pf.trackingNumber.split('-');
var seq = tnParts.pop(), type = tnParts.pop();
var bid = findOrAddTransmittalBin(findOrAddParty(party), slot, {
date: pf.date, type: type || 'TRN', seq: seq || '', status: pf.status, title: pf.title,
});
if (!bid) return 'could not create the transmittal';
place(keys, bid, 'transmittal');
keys.forEach(function (k) { state.transmittalWorkset[k] = true; });
delete oldBins[bid]; // keep the bin we just filled
Object.keys(oldBins).forEach(pruneEmptyTransmittal);
notify();
return '';
}
// ── pattern config ───────────────────────────────────────────────────────
function normalizeConfig(c) {
var d = defaultConfig();
@ -8352,6 +8450,9 @@ X.B(E,Y);return E}return J}())
// By-tracking grid
addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid,
trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity, forgetFile: forgetFile,
// By-transmittal grid
addToTransmittalGrid: addToTransmittalGrid, removeFromTransmittalGrid: removeFromTransmittalGrid,
transmittalGridKeys: transmittalGridKeys, setTransmittalPath: setTransmittalPath,
setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist,
removeWorklistRow: removeWorklistRow,
getWorklist: getWorklist, getWorklistRow: getWorklistRow,
@ -10316,6 +10417,27 @@ X.B(E,Y);return E}return J}())
if (!built.count) { window.zddc.toast('No files to export — nothing passes the current filters.', 'info'); return; }
copyOrDownload(built.tsv, built.count);
}
// Download the filtered file list as a 1-column CSV of full (root-relative)
// paths — the same keys “Import paths” matches on. Meant to be handed to an AI
// that returns a 2-column old→new mapping.
function exportPathList() {
var c = window.app.modules.classify;
var files = filteredFileObjects().slice().sort(function (a, b) {
return cmpName(c.srcKeyForFile(a), c.srcKeyForFile(b));
});
if (!files.length) { window.zddc.toast('No files to export — nothing passes the current filters.', 'info'); return; }
function cell(s) { s = (s == null ? '' : String(s)); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }
var lines = ['path'];
files.forEach(function (f) { lines.push(cell(c.srcKeyForFile(f))); });
try {
var blob = new Blob([lines.join('\n')], { type: 'text/csv' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a'); a.href = url; a.download = 'classifier-paths.csv';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
window.zddc.toast('Exported ' + files.length + ' path' + (files.length === 1 ? '' : 's') + ' to classifier-paths.csv.', 'success');
} catch (e) { window.zddc.toast('Could not export the path list — ' + (e.message || e), 'error'); }
}
function copyOrDownload(text, count) {
function ok() { window.zddc.toast('Copied ' + count + ' file' + (count === 1 ? '' : 's') + ' (path + file) — paste into Excel.', 'success'); }
function download() {
@ -11155,6 +11277,7 @@ X.B(E,Y);return E}return J}())
setShowFilters,
setNameFilter,
exportFilteredList,
exportPathList,
filteredFiles: filteredFileObjects,
_buildExportTsv: buildExportTsv
};
@ -11301,23 +11424,22 @@ X.B(E,Y);return E}return J}())
/**
* ZDDC Classifier — target-tree pane (Classify & Copy mode).
*
* Renders the two orthogonal target trees the user maps files onto:
* - "By tracking number": folders that join with "-" into the tracking
* number; the leaf folder ("A (IFR)") is the revision+status.
* - "By transmittal": <party>/{received,issued}/<transmittal folder>.
* Two orthogonal per-file grids (both on the shared seltable) the user maps
* files onto — one editable row per file:
* - "By tracking number": Tracking# / Rev (Status) / Title cells compose the
* ZDDC filename (the rename).
* - "By transmittal": one text input = the file's full transmittal folder path
* "<party>/<received|issued>/<YYYY-MM-DD_TN (STATUS) - Title>" (the route);
* committing it find-or-creates the party/slot/bin in classify.js.
*
* Structure here, placements in classify.js. Drag-and-drop assignment is wired
* in source-dnd.js / phase 3; this module owns rendering + folder/bin CRUD and
* shows the derived filename for each placed file.
* Structure + placements live in classify.js; everything shown here is derived,
* never stored. Drops are handled per-grid (setupGridDrop / setupTransmittalDrop)
* so a ⌘/Ctrl transmittal drop can branch a new folder.
*/
(function () {
'use strict';
var SLOTS = ['received', 'issued'];
var els = {};
var collapsed = {}; // nodeId -> true when collapsed (default expanded)
var openForm = null; // { partyId, slot } when a bin form is open
var initialized = false;
var currentTab = 'tracking'; // 'tracking' | 'transmittal' — active tab
var listScanned = false; // a Load has run this session (drives the "new" badge)
@ -11339,7 +11461,6 @@ X.B(E,Y);return E}return J}())
addFilteredBtn: document.getElementById('addFilteredBtn'),
renameBtn: document.getElementById('renameBtn'),
trackingColsBtn: document.getElementById('trackingColsBtn'),
addPartyBtn: document.getElementById('addPartyBtn'),
stats: document.getElementById('classifyStats'),
};
@ -11370,16 +11491,9 @@ X.B(E,Y);return E}return J}())
if (text) { e.preventDefault(); openPasteDialog(text); }
});
if (els.trackingColsBtn) els.trackingColsBtn.addEventListener('click', openColumnChooser);
els.addPartyBtn.addEventListener('click', function () {
var name = prompt('Party name (also the transmittal-number prefix):', '');
if (name && name.trim()) C().addParty(name.trim());
});
els.transmittalTree.addEventListener('click', onTransmittalClick);
els.transmittalTree.addEventListener('change', onFileNameChange);
setupGridDrop(els.trackingTree);
setupDropZone(els.transmittalTree, 'transmittal');
setupTransmittalDrop(els.transmittalTree);
C().on(render);
if (window.app.modules.store && window.app.modules.store.on) {
@ -11401,24 +11515,6 @@ X.B(E,Y);return E}return J}())
})(window.app.folderTree || []);
return out;
}
// One pass: group files by the node they're placed in, per axis.
function buildPlaced(files) {
var c = C(), byT = {}, byX = {}, byTn = {};
files.forEach(function (f) {
var a = c.getAssignment(c.srcKeyForFile(f));
if (!a) return;
if (a.trackingNodeId) {
(byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f);
// Also index by tracking NUMBER so a "From a list" row can show
// the files placed under it (a row is a tracking number, not a node).
var tn = c.deriveTarget(f).tracking;
if (tn) (byTn[tn] = byTn[tn] || []).push(f);
}
if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f);
});
return { tracking: byT, transmittal: byX, byTracking: byTn };
}
function showTab(which) {
currentTab = (which === 'transmittal') ? 'transmittal' : 'tracking';
els.trackingTab.classList.toggle('active', currentTab === 'tracking');
@ -11438,9 +11534,8 @@ X.B(E,Y);return E}return J}())
function render() {
if (!initialized || !C().isEnabled()) return;
var files = allFiles();
var placed = buildPlaced(files);
renderTrackingGrid(els.trackingTree);
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
renderTransmittalGrid(els.transmittalTree);
renderStats(files);
}
@ -11484,66 +11579,13 @@ X.B(E,Y);return E}return J}())
return e;
}
function nodeActions(extra) {
var wrap = el('span', 'tnode__actions');
(extra || []).forEach(function (a) {
var b = el('button', 'tnode__act', a.label);
b.dataset.act = a.act;
b.title = a.title || '';
wrap.appendChild(b);
});
return wrap;
}
// Placed files inside a transmittal bin. Each row is draggable (drag onto
// another bin to MOVE it) and carries an ✕ to remove it from the transmittal.
function fileList(files) {
var box = el('div', 'tnode__files');
files.forEach(function (f) {
var d = C().deriveTarget(f);
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
row.dataset.key = d.key;
row.draggable = true;
row.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); });
var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : ''));
orig.title = 'Drag to another transmittal to move · click to preview';
row.appendChild(orig);
row.appendChild(el('span', 'tfile__arrow', '→'));
// Editable derived filename — edit it to re-file the item.
var name = el('input', 'tfile__name' + (d.errors.length ? ' tfile__name--err' : ''));
name.type = 'text';
name.value = d.filename || '';
name.placeholder = '(incomplete)';
name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item';
row.appendChild(name);
var rm = el('button', 'tnode__act tfile__remove', '✕');
rm.dataset.act = 'untransmit';
rm.title = 'Remove from this transmittal';
row.appendChild(rm);
box.appendChild(row);
});
return box;
}
// ── name filter (the autofilter box above the target trees) ────────────
// ── name filter (the autofilter box above the target grids) ────────────
// Mirrored into each grid's own global filter (seltable.setFilter) on render.
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 || '');
}
// ── By-tracking: flat editable grid (one row per file), on the shared
// seltable — so it gets multi-sort + per-column autofilters + resizable,
// persisted widths for free. Only `hidden` (the Columns ▾ chooser) is
@ -11831,93 +11873,85 @@ X.B(E,Y);return E}return J}())
}, 0);
}
// Transmittal tree
function renderTransmittalInto(container, parties, placedMap) {
container.textContent = '';
if (!parties.length) {
container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.'));
// ── By-transmittal: flat editable grid (one row per file), mirroring the
// By-tracking grid. Each row's single text input is the file's full
// transmittal folder path "<party>/<received|issued>/<folder>"; committing
// it routes the file (classify.setTransmittalPath find-or-creates the
// party/slot/bin). Drops are handled at the container level so a ⌘/Ctrl
// drop can branch a new transmittal (setupTransmittalDrop). ──────────────
function txPath(f) { return C().deriveTarget(f).outPath || ''; }
function txStatusCell(td, f) {
var ok = !!txPath(f);
var badge = el('span', ok ? 'tfile__badge tfile__badge--ok' : 'tfile__badge tg-wanted', ok ? '✓' : '◇');
badge.title = ok ? ('Routed to ' + txPath(f)) : 'No transmittal folder yet — type one, or drop onto a routed row.';
td.appendChild(badge);
}
function txPathCell(td, f) {
var c = C(), key = c.srcKeyForFile(f);
editCell(td, 'tg-input', txPath(f), 'Acme/received/2026-06-18_Acme-TRN-0001 (IFC) - Title', function (v) {
var err = c.setTransmittalPath([key], v);
if (err) { window.zddc.toast('Transmittal not set — ' + err, 'warning'); render(); }
});
}
function txRemoveCell(td, f) {
var c = C(), key = c.srcKeyForFile(f);
var rm = el('button', 'tnode__act tg-x__btn', '✕'); rm.title = 'Remove from the transmittal grid';
rm.addEventListener('click', function () { c.removeFromTransmittalGrid(key); });
td.appendChild(rm);
}
function transmittalGridRows() {
var out = [];
C().transmittalGridKeys().forEach(function (k) {
var f = fileByKey(k); if (f) out.push({ kind: 'file', file: f, id: 'f:' + k });
});
return out;
}
function transmittalColumns() {
return [
{ key: 'status', title: 'Status', cls: 'tg-status', filterable: false,
get: function (r) { return txPath(r.file) ? 'ok' : 'awaiting'; },
render: function (r, td) { txStatusCell(td, r.file); } },
{ key: 'orig', title: 'Original name', cls: 'tg-orig',
get: function (r) { return joinName(r.file); },
render: function (r, td) { gridOrigCell(td, r.file); } },
{ key: 'path', title: 'Transmittal folder', cls: 'tx-path',
get: function (r) { return txPath(r.file); },
render: function (r, td) { txPathCell(td, r.file); } },
{ key: 'x', title: '', cls: 'tg-x', sortable: false, filterable: false,
render: function (r, td) { txRemoveCell(td, r.file); } },
];
}
var transmittalGrid = null;
function ensureTransmittalGrid(container) {
if (transmittalGrid) return transmittalGrid;
transmittalGrid = window.app.modules.seltable.create({
container: container,
rows: transmittalGridRows,
rowId: function (r) { return r.id; },
columns: transmittalColumns(),
persistKey: 'zddc.classifier.transmittalCols',
});
transmittalGrid.render();
return transmittalGrid;
}
function renderTransmittalGrid(container) {
if (!transmittalGridRows().length) {
transmittalGrid = null;
container.textContent = '';
container.classList.remove('seltable');
container.appendChild(el('div', 'target-empty',
'No files here yet — drag files in, then type each ones transmittal folder '
+ '(<party>/<received|issued>/<YYYY-MM-DD_TN (STATUS) - Title>). Drop onto a routed '
+ 'row to put the file in that same folder; ⌘/Ctrl-drop to branch a new transmittal from it.'));
return;
}
parties.forEach(function (p) { var e = partyNode(p, placedMap); if (e) container.appendChild(e); });
if (rfActive() && !container.children.length) {
container.appendChild(el('div', 'target-empty', 'No matches in the transmittal tree.'));
}
}
function partyNode(party, placedMap) {
var partyMatch = rfHit(party.name);
var slotEls = [], anyBin = false;
SLOTS.forEach(function (slot) {
var slotNode = (party.children || []).filter(function (s) { return s.slot === slot; })[0];
var sw = el('div', 'tslot');
sw.dataset.party = party.id;
sw.dataset.slot = slot;
var sr = el('div', 'tslot__row');
sr.appendChild(el('span', 'tslot__name', slot));
var addBtn = el('button', 'tnode__act', '+ Transmittal');
addBtn.dataset.act = 'addbin';
sr.appendChild(addBtn);
sw.appendChild(sr);
if (openForm && openForm.partyId === party.id && openForm.slot === slot) {
sw.appendChild(binForm(party.id, slot));
}
(slotNode ? slotNode.children : []).forEach(function (bin) {
var be = binNode(bin, placedMap, partyMatch);
if (be) { sw.appendChild(be); anyBin = true; }
});
slotEls.push(sw);
});
if (rfActive() && !partyMatch && !anyBin) return null;
var wrap = el('div', 'tnode tnode--party');
wrap.dataset.id = party.id;
var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__icon', '🏢'));
row.appendChild(el('span', 'tnode__name', party.name));
row.appendChild(nodeActions([
{ act: 'rename-party', label: '✎', title: 'Rename party' },
{ act: 'del-party', label: '🗑', title: 'Delete party' },
]));
wrap.appendChild(row);
slotEls.forEach(function (sw) { wrap.appendChild(sw); });
return wrap;
}
function binNode(bin, placedMap, ancMatched) {
var matched = ancMatched || rfHit(bin.name || '');
var placed = placedMap[bin.id] || [];
var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed;
if (rfActive() && !matched && !shownFiles.length) return null;
var wrap = el('div', 'tnode tnode--bin');
wrap.dataset.id = bin.id;
var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([
{ act: 'rename-bin', label: '✎', title: 'Rename transmittal' },
{ act: 'del', label: '🗑', title: 'Delete transmittal' },
]));
wrap.appendChild(row);
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
return wrap;
ensureTransmittalGrid(container);
transmittalGrid.setFilter(rfTerms.join(' '));
}
var STATUSES = ['---', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'REC', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'TBD'];
function binForm(partyId, slot) {
var form = el('div', 'binform');
form.dataset.party = partyId;
form.dataset.slot = slot;
var date = el('input', 'binform__date'); date.type = 'date';
try { date.value = new Date().toISOString().slice(0, 10); } catch (_) { /* ok */ }
var type = document.createElement('select'); type.className = 'binform__type';
['TRN', 'SUB'].forEach(function (t) { var o = el('option', null, t); o.value = t; type.appendChild(o); });
var seq = el('input', 'binform__seq'); seq.type = 'text'; seq.placeholder = 'seq (e.g. 0007)';
var status = document.createElement('select'); status.className = 'binform__status';
STATUSES.forEach(function (s) { var o = el('option', null, s); o.value = s; status.appendChild(o); });
var title = el('input', 'binform__title'); title.type = 'text'; title.placeholder = 'title (optional)';
var add = el('button', 'btn btn-sm btn-primary', 'Add'); add.dataset.act = 'binadd';
var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel'); cancel.dataset.act = 'bincancel';
[date, type, seq, status, title, add, cancel].forEach(function (n) { form.appendChild(n); });
return form;
function setGridStatus(text) {
var s = document.getElementById('scanStatus');
if (s) { s.textContent = text; s.classList.toggle('scanning', !!text); }
}
function setGridStatus(text) {
@ -12237,163 +12271,75 @@ X.B(E,Y);return E}return J}())
}
// ── events ─────────────────────────────────────────────────────────────
function closestNodeId(target) {
var n = target.closest('[data-id]');
return n ? n.dataset.id : null;
}
function fileByKey(key) {
var files = allFiles();
for (var i = 0; i < files.length; i++) { if (C().srcKeyForFile(files[i]) === key) return files[i]; }
return null;
}
// Click a placed-file row (anywhere but its editable name) → preview it.
function previewFromTarget(e) {
// Preview link on a revision cell (its placed file).
var pl = e.target.closest('[data-preview-key]');
if (pl) {
e.preventDefault();
var pf = fileByKey(pl.dataset.previewKey);
if (pf && window.app.modules.preview && window.app.modules.preview.previewFile) {
window.app.modules.preview.previewFile(pf);
}
return true;
}
if (e.target.closest('[data-act]')) return false; // action button — not a preview
if (e.target.closest('.tfile__name')) return false;
var tf = e.target.closest('.tfile');
if (!tf || !tf.dataset.key) return false;
var f = fileByKey(tf.dataset.key);
if (f && window.app.modules.preview && window.app.modules.preview.previewFile) {
window.app.modules.preview.previewFile(f);
}
return true;
}
// Edited a placed-file's ZDDC filename → re-derive its tracking placement
// (creating the folder path if needed) + its title override.
function onFileNameChange(e) {
var input = e.target.closest('.tfile__name');
if (input) commitFilenameEdit(input);
}
function commitFilenameEdit(input) {
var tf = input.closest('.tfile');
if (!tf || !tf.dataset.key) return;
var parsed = window.zddc.parseFilename((input.value || '').trim());
if (!parsed || !parsed.valid) {
window.zddc.toast('Not a valid ZDDC filename — expected "TRACKING_REV (STATUS) - Title.ext".', 'warning');
render(); // restore the derived value
return;
}
var stem = parsed.trackingNumber + '_' + parsed.revision + ' (' + parsed.status + ')';
var leaf = C().addTrackingPath(null, C().parseFolderLevels(stem));
C().place([tf.dataset.key], leaf, 'tracking');
if (parsed.title != null) C().setTitleOverride(tf.dataset.key, parsed.title);
// place/setTitleOverride fire classify.notify → re-render.
}
function onTransmittalClick(e) {
if (previewFromTarget(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
if (act === 'addbin') {
var slotEl = btn.closest('.tslot');
openForm = { partyId: slotEl.dataset.party, slot: slotEl.dataset.slot };
render();
return;
// ── By-transmittal drops ─────────────────────────────────────────────────
// Handled at the container level (not seltable's per-row onRowDrop) so the
// drop event's modifier key is available:
// plain drop on a routed row → the dropped files JOIN that row's folder.
// ⌘/Ctrl drop on a routed row → prompt, prefilled with that folder's path,
// so the user edits it into a NEW transmittal the files go to (the
// original folder is untouched — find-or-create dedups an unedited path).
// drop on empty space / an unrouted row → just add the files as grid rows.
function setupTransmittalDrop(container) {
function rowUnder(e) { var tr = e.target.closest && e.target.closest('.seltable__row'); return (tr && container.contains(tr)) ? tr : null; }
function clearHover() {
Array.prototype.forEach.call(container.querySelectorAll('.drop-hover'), function (n) { n.classList.remove('drop-hover'); });
container.classList.remove('tg-drop-hover');
}
if (act === 'untransmit') {
var tf = btn.closest('.tfile');
if (tf && tf.dataset.key) C().place([tf.dataset.key], null, 'transmittal');
return;
}
if (act === 'rename-bin') {
var bid = closestNodeId(btn);
var bn = C().getNode(bid);
var nn = prompt('Rename transmittal (this becomes its folder name):', bn ? bn.name : '');
if (nn && nn.trim()) C().renameNode(bid, nn.trim());
return;
}
if (act === 'bincancel') { openForm = null; render(); return; }
if (act === 'binadd') {
var form = btn.closest('.binform');
var meta = {
date: form.querySelector('.binform__date').value,
type: form.querySelector('.binform__type').value,
seq: form.querySelector('.binform__seq').value.trim(),
status: form.querySelector('.binform__status').value,
title: form.querySelector('.binform__title').value.trim(),
};
if (!meta.date || !meta.seq) { window.zddc.toast('Transmittal needs at least a date and a sequence number.', 'warning'); return; }
C().addTransmittalBin(form.dataset.party, form.dataset.slot, meta);
openForm = null; // render() fires from classify.notify()
return;
}
var id = closestNodeId(btn);
if (act === 'rename-party') {
var node = C().getNode(id);
var nn = prompt('Rename party (re-derives its transmittal numbers):', node ? node.name : '');
if (nn && nn.trim()) C().renameNode(id, nn.trim());
} else if (act === 'del-party') {
if (confirm('Delete this party and all its transmittals? Files placed there become unassigned.')) C().deleteNode(id);
} else if (act === 'del') {
if (confirm('Delete this transmittal? Files placed here become unassigned.')) C().deleteNode(id);
}
}
// ── drop targets ───────────────────────────────────────────────────────
// Resolve the drop target under an event:
// tracking → any folder node (.tnode)
// transmittal → a transmittal bin only (.tnode--bin)
function dropTarget(target, axis) {
if (axis === 'transmittal') {
var bin = target.closest('.tnode--bin');
if (!bin || !bin.dataset.id) return null;
return { id: bin.dataset.id, row: bin.querySelector('.tnode__row') || bin };
}
var cell = target.closest('.ttable__cell[data-id], .ttable__rev[data-id]');
if (!cell) return null;
return { id: cell.dataset.id, row: cell };
}
function clearHover(container) {
var hot = container.querySelectorAll('.drop-hover');
for (var i = 0; i < hot.length; i++) hot[i].classList.remove('drop-hover');
}
function setupDropZone(container, axis) {
container.addEventListener('dragover', function (e) {
if (!window.app.modules.dnd.active()) return;
var t = dropTarget(e.target, axis);
clearHover(container);
if (!t) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
t.row.classList.add('drop-hover');
});
container.addEventListener('dragleave', function (e) {
if (e.target === container) clearHover(container);
e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
clearHover();
var tr = rowUnder(e);
if (tr) tr.classList.add('drop-hover'); else container.classList.add('tg-drop-hover');
});
container.addEventListener('dragleave', function (e) { if (e.target === container) clearHover(); });
container.addEventListener('drop', function (e) {
var t = dropTarget(e.target, axis);
clearHover(container);
if (!t) return;
e.preventDefault();
var tr = rowUnder(e), meta = e.metaKey || e.ctrlKey;
clearHover();
var keys = window.app.modules.dnd.getDrag();
window.app.modules.dnd.clearDrag();
if (!keys.length) return;
C().place(keys, t.id, axis);
onTransmittalDrop(keys, tr ? tr.dataset.id : null, meta);
});
}
function onTransmittalDrop(keys, rowId, meta) {
var c = C(), targetPath = '';
if (rowId && rowId.indexOf('f:') === 0) {
var tf = fileByKey(rowId.slice(2));
if (tf) targetPath = c.deriveTarget(tf).outPath || '';
}
if (targetPath) {
if (meta) {
var edited = prompt('New transmittal folder — edit to branch a copy, or keep to join:', targetPath);
if (edited == null) return; // cancelled
var err = c.setTransmittalPath(keys, edited.trim());
if (err) window.zddc.toast('Could not route — ' + err, 'warning');
} else {
c.setTransmittalPath(keys, targetPath); // join the same folder
}
return;
}
c.addToTransmittalGrid(keys); // empty space / unrouted row → add blank rows to fill
}
// Reveal a source key's placement in the target pane (source → target).
function reveal(key) {
var a = C().getAssignment(key);
if (!a) return;
// Both tabs are per-file grids whose rows are keyed "f:<srcKey>".
if (a.trackingNodeId) {
showTab('tracking'); collapsed = {}; render();
flashNode(els.trackingTree, a.trackingNodeId);
showTab('tracking'); render();
flashNode(els.trackingTree, 'f:' + key);
} else if (a.transmittalNodeId) {
showTab('transmittal'); render();
flashNode(els.transmittalTree, a.transmittalNodeId);
flashNode(els.transmittalTree, 'f:' + key);
}
}
function flashNode(container, id) {

View file

@ -1793,7 +1793,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-16 19:19:05 · 054cf2d</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb</span></span>
</div>
</div>
<div class="header-right">

View file

@ -2770,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-16 19:19:05 · 054cf2d</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-18 15:12:58 · 9ca24eb</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;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-16 19:19:05 · 054cf2d
transmittal=v0.0.27-beta · 2026-06-16 19:19:05 · 054cf2d
classifier=v0.0.27-beta · 2026-06-16 19:19:05 · 054cf2d
landing=v0.0.27-beta · 2026-06-16 19:19:05 · 054cf2d
form=v0.0.27-beta · 2026-06-16 19:19:05 · 054cf2d
tables=v0.0.27-beta · 2026-06-16 19:19:06 · 054cf2d
browse=v0.0.27-beta · 2026-06-16 19:19:06 · 054cf2d
archive=v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
transmittal=v0.0.27-beta · 2026-06-18 15:12:58 · 9ca24eb
classifier=v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
landing=v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
form=v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
tables=v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
browse=v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb

View file

@ -1780,7 +1780,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-beta · 2026-06-16 19:19:06 · 054cf2d</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb</span></span>
</div>
</div>
<div class="header-right">