diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index aa4ee08..e1cc055 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.27-beta · 2026-06-16 19:19:05 · 054cf2d + v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index 9694dd0..4b4cb1c 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
ZDDC Browse - v0.0.27-beta · 2026-06-16 19:19:06 · 054cf2d + v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index 525a2e8..c69907a 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -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
ZDDC Classifier - v0.0.27-beta · 2026-06-16 19:19:05 · 054cf2d + v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
@@ -2545,6 +2545,8 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o + @@ -2567,9 +2569,8 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
| - - - + +
@@ -2591,13 +2592,12 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o @@ -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 {
ZDDC - v0.0.27-beta · 2026-06-16 19:19:05 · 054cf2d + v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html index 1432fbd..2190093 100644 --- a/zddc/internal/apps/embedded/transmittal.html +++ b/zddc/internal/apps/embedded/transmittal.html @@ -2770,7 +2770,7 @@ dialog.modal--narrow {
ZDDC Transmittal - v0.0.27-beta · 2026-06-16 19:19:05 · 054cf2d + v0.0.27-beta · 2026-06-18 15:12:58 · 9ca24eb
JavaScript not available