-
- <party>/{received,issued}/<transmittal>. Drag files (or a whole folder) into a transmittal, then Copy into the archive.
+ One row per file — type its transmittal folder: <party>/<received|issued>/<YYYY-MM-DD_TN (STATUS) - Title>. Drag files in: drop on a row to put the file in that same folder, ⌘/Ctrl-drop to branch a new transmittal from it.
+ placeholder="Filter the transmittal grid…" aria-label="Filter transmittal grid">
@@ -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
+ // "///.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 — // → 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
+ // "//". 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 "//" 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 //';
+ 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": /{received,issued}/.
+ * 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
+ * "//" (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 "//"; 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 one’s transmittal folder '
+ + '(//). 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:".
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) {
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html
index 522326c..43b24d1 100644
--- a/zddc/internal/apps/embedded/index.html
+++ b/zddc/internal/apps/embedded/index.html
@@ -1793,7 +1793,7 @@ body {