From f66b9c5d55425ad0dc2337e4f2ff051131c46f05 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 12 Jun 2026 10:48:26 -0500 Subject: [PATCH] =?UTF-8?q?feat(classifier):=20"From=20a=20list"=20?= =?UTF-8?q?=E2=80=94=20a=20scratch=20worklist=20that=20materializes=20trac?= =?UTF-8?q?king=20placements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the "By existing" catalog into a "From a list" scratch worklist that can be populated three ways and cleared without losing work. Core model — retire the separate `mdl` assignment axis. Dropping a file on a row now MATERIALIZES a real "By tracking number" placement (assignFromRow → addTrackingPath + place(…,'tracking') + title override), reusing the typed- filename path. Consequences: - Clearing the list can't lose classifications (no assignment references a row); dropped files show under By tracking number. - The two name axes collapse into one (place/deriveTarget/fileCategory simplified). - Workspace migration: load() materializes any legacy `mdlNodeId` into a tracking placement BEFORE anything can prune, so saved workspaces keep their work. - A tracking number's last segment is an ancestor and the revision is always the leaf, so a blank-revision drop lands on a `pending` placeholder leaf; editing the row's tracking number or revision re-stamps the row's files onto the new leaf and prunes the emptied one. UI: - One editable Tracking number column (no per-field split) + editable Title — so you can drop on an entry and bump e.g. the sequence for a new drawing. - Source column (MDL / arch / pasted) with an amber "new"/"unverified" badge when a pasted number matches nothing scanned — the typo catcher. - Toolbar: Load… (now appends) · Paste rows… · ⚡ Match names · Clear list · Hide assigned. Tab renamed "From a list"; goal line + hint teach the scratch-pad. - Paste dialog (Ctrl-V on the panel too) with a live parse preview; parsePastedRows handles 3-col, a split status column, a pasted full filename, header + bad rows. - Match names reviews proposeMatches (filename ⊇ a known tracking number) and assigns the checked pairs. Tests: materialize+clear, tracking-edit re-stamp + prune, legacy migration, parsePastedRows, proposeMatches; 66 classify+classifier green. Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/css/layout.css | 40 ++++- classifier/js/classify.js | 277 +++++++++++++++++++++++++++-------- classifier/js/target-tree.js | 255 ++++++++++++++++++++++++++------ classifier/js/tree.js | 6 +- classifier/template.html | 21 ++- tests/classify.spec.js | 153 +++++++++++++++---- 6 files changed, 608 insertions(+), 144 deletions(-) diff --git a/classifier/css/layout.css b/classifier/css/layout.css index bee877a..0d44531 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -581,10 +581,26 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o /* The "By existing" catalog is now a normal in-flow tab panel. */ #mdlTree { flex: 1; min-height: 0; } #mdlTree .seltable { height: 100%; } -.mdl-rev__input { - width: 8rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border); +/* Editable "From a list" cells — fill the column (the table is width:auto, so + the column sizes to its header/content, and the input never widens it). */ +.mdl-rev__input, .fromlist-tn__input, .fromlist-title__input { + width: 100%; min-width: 4rem; box-sizing: border-box; + padding: 0.15rem 0.35rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem; } +.fromlist-tn__input { font-family: var(--mono, monospace); } +.mdl-rev__input.is-warn, .fromlist-tn__input.is-warn { border-color: var(--warning, #b8860b); } +.fromlist-src { white-space: nowrap; } +.src-badge { + display: inline-block; margin-right: 0.25rem; padding: 0 0.3rem; border-radius: 0.7rem; + font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; + border: 1px solid var(--border); color: var(--text-muted); +} +.src-badge--mdl { color: var(--primary); border-color: var(--primary); } +.src-badge--arch { color: var(--text-secondary, var(--text-muted)); } +.src-badge--pasted { color: var(--text-muted); } +.src-badge--new { color: #fff; background: var(--warning, #b8860b); border-color: var(--warning, #b8860b); } +.target-toggle { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); cursor: pointer; } .seltable__extra { white-space: normal; } .mdlfile__name { font-size: 0.78rem; } #mdlPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; } @@ -592,6 +608,26 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o #mdlPanel .tfile__remove { opacity: 0.6; } #mdlPanel .tfile:hover .tfile__remove { opacity: 1; } +/* Paste + Match dialogs (inside the .copy-choice modal shell) */ +.scratch-modal__body { margin: 0 0 1rem; } +.scratch-paste__ta { + width: 100%; box-sizing: border-box; resize: vertical; font-family: var(--mono, monospace); + font-size: 0.8rem; padding: 0.4rem 0.5rem; border: 1px solid var(--border); border-radius: var(--radius); + background: var(--bg); color: var(--text); +} +.scratch-paste__preview, .scratch-match__list { max-height: 38vh; overflow: auto; margin-top: 0.6rem; } +.scratch-preview__table { width: 100%; border-collapse: collapse; font-size: 0.78rem; } +.scratch-preview__table th, .scratch-preview__table td { text-align: left; padding: 0.15rem 0.4rem; border-bottom: 1px solid var(--border); white-space: nowrap; } +.scratch-preview__table th { color: var(--text-muted); font-size: 0.66rem; text-transform: uppercase; } +.scratch-preview__skip { color: var(--danger); font-size: 0.76rem; padding: 0.1rem 0; } +.scratch-preview__more { color: var(--text-muted); font-size: 0.76rem; padding: 0.2rem 0; } +.scratch-match__fuzzy { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); } +.scratch-match__row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; padding: 0.15rem 0; cursor: pointer; } +.scratch-match__file { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.scratch-match__arrow { color: var(--text-muted); } +.scratch-match__tn { font-family: var(--mono, monospace); } +.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; width: 3rem; text-align: right; } + /* ── MDL-from-archive overlay ───────────────────────────────────────────── */ .mdl-overlay { position: fixed; inset: 0; z-index: 1100; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; } .mdl-overlay__box { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 10px 40px rgba(0,0,0,0.3); width: 100%; max-width: 1000px; height: 80vh; display: flex; flex-direction: column; } diff --git a/classifier/js/classify.js b/classifier/js/classify.js index 933b345..feb0add 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -125,7 +125,7 @@ function assignmentFor(key) { var a = state.assignments[key]; if (!a) { - a = { trackingNodeId: null, transmittalNodeId: null, mdlNodeId: null, excluded: false, titleOverride: null, titleFromDeliverable: true }; + a = { trackingNodeId: null, transmittalNodeId: null, excluded: false, titleOverride: null }; state.assignments[key] = a; } return a; @@ -134,22 +134,19 @@ function getAssignment(key) { return state.assignments[key] || null; } function cleanAssignment(key) { var a = state.assignments[key]; - if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.mdlNodeId && !a.excluded && !a.titleOverride) { + if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) { delete state.assignments[key]; } } // Place keys onto a node along one axis ('tracking' | 'transmittal'). - // nodeId null clears that axis. + // nodeId null clears that axis. (The "From a list" tab also produces + // 'tracking' placements — see assignFromRow.) function place(keys, nodeId, axis) { - var field = axis === 'transmittal' ? 'transmittalNodeId' : axis === 'mdl' ? 'mdlNodeId' : 'trackingNodeId'; + var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId'; keys.forEach(function (k) { var a = assignmentFor(k); a[field] = nodeId || null; - // Tracking and MDL are alternative NAME sources — placing on one - // clears the other so the file has a single name origin. - if (axis === 'mdl' && nodeId) a.trackingNodeId = null; - else if (axis === 'tracking' && nodeId) a.mdlNodeId = null; a.excluded = false; // placing un-excludes cleanAssignment(k); }); @@ -160,7 +157,7 @@ keys.forEach(function (k) { var a = assignmentFor(k); a.excluded = !!excluded; - if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; a.mdlNodeId = null; } + if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; } cleanAssignment(k); }); clearHashConflicts(); @@ -200,9 +197,8 @@ }); }); }); - (state.mdlList || []).forEach(function (row) { - nodeIndex[row.id] = { node: row, kind: 'mdl', parent: null }; - }); + // Scratch-list rows are NOT placement targets — drops materialize real + // tracking-tree nodes (assignFromRow), so the list isn't indexed here. } function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; } function infoFor(id) { return nodeIndex[id] || null; } @@ -335,26 +331,10 @@ }; if (out.excluded) return out; - // Axis 1 — NAME. An MDL deliverable (alternative to the tracking tree) - // supplies the tracking number + title; its revision comes from the - // classifier-local revision cell. Otherwise the tracking tree. - if (a.mdlNodeId) { - var mi = infoFor(a.mdlNodeId); - if (mi && mi.kind === 'mdl') { - var row = mi.node; - out.tracking = row.trackingNumber || ''; - var ml = parseLeafLabel(row.revisionCell || ''); - out.revision = ml.revision; out.status = ml.status; - out.trackingLeaf = true; - if (!a.titleOverride && a.titleFromDeliverable !== false && row.title) out.title = row.title; - if (!out.tracking) out.errors.push('deliverable has no tracking number'); - if (!out.revision) out.errors.push('set a revision for this deliverable (e.g. "A (IFR)")'); - else if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"'); - else if (!out.status) out.errors.push('revision needs a "(STATUS)" — e.g. "A (IFR)"'); - } else { - out.errors.push('deliverable no longer loaded'); - } - } else if (a.trackingNodeId) { + // Axis 1 — NAME, always the tracking tree. The "From a list" tab drops + // also produce tracking-tree placements (assignFromRow), so there is a + // single name origin. + if (a.trackingNodeId) { var ti = infoFor(a.trackingNodeId); if (ti && ti.kind === 'tracking') { var chain = trackingChain(ti); // [root … node] @@ -454,10 +434,33 @@ state.transmittalTree = obj.transmittalTree || []; state.outputName = obj.outputName || null; state.config = normalizeConfig(obj.config); - state.mdlList = Array.isArray(obj.mdlList) ? obj.mdlList : []; + state.mdlList = (Array.isArray(obj.mdlList) ? obj.mdlList : []).map(normalizeRow); rebuildIndex(); + migrateLegacyMdl(obj.mdlList); // BEFORE anything can prune; materializes old mdl placements notify(); } + // Pre-"From a list" workspaces stored a separate `mdlNodeId` axis pointing at + // a row id. Materialize each into a real tracking placement so the + // classification survives the model change, then drop the dead fields. + function migrateLegacyMdl(rawRows) { + var byOldId = Object.create(null); + (rawRows || []).forEach(function (r) { if (r && r.id) byOldId[r.id] = r; }); + Object.keys(state.assignments).forEach(function (k) { + var a = state.assignments[k]; + if (!a) return; + if (a.mdlNodeId) { + var r = byOldId[a.mdlNodeId]; + if (!a.trackingNodeId && r && r.trackingNumber) { + var leaf = addTrackingPath(null, parseFolderLevels(r.trackingNumber + '_' + (r.revisionCell || PENDING_REV))); + a.trackingNodeId = leaf; + if (!a.titleOverride && r.title && a.titleFromDeliverable !== false) a.titleOverride = r.title; + } + delete a.mdlNodeId; + } + if ('titleFromDeliverable' in a) delete a.titleFromDeliverable; + cleanAssignment(k); + }); + } // Reset clears the CLASSIFICATION but keeps the pattern config — it's a // per-project setting, not part of the data being cleared. function reset() { @@ -484,41 +487,192 @@ function getTrackingFields() { return state.config.trackingFields; } function setConfig(c) { state.config = normalizeConfig(c); notify(); } - // ── MDL deliverables (the "By MDL" drop-target axis) ───────────────────── - function setMdlList(rows) { - state.mdlList = (rows || []).map(function (r) { - return { - id: r.id || uid(), party: r.party || '', - trackingNumber: r.trackingNumber || '', title: r.title || '', - inMdl: !!r.inMdl, - archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [], - revisionCell: r.revisionCell || '', - }; + // ── "From a list" scratch worklist ─────────────────────────────────────── + // A temporary list of known/typed tracking numbers (from the archive/MDL, a + // paste, or a name-match). Dropping a file on a row MATERIALIZES a real + // tracking-tree placement (assignFromRow) — the list is pure input, so it can + // be cleared without losing any classification. `placed` is a transient + // row→keys hint (not the source of truth, not serialized) used to re-stamp a + // row's files when its tracking number / revision is edited. + function rowSource(r) { + if (r && r.source) return { mdl: !!r.source.mdl, archive: !!r.source.archive, pasted: !!r.source.pasted }; + return { mdl: !!(r && r.inMdl), archive: !!(r && Array.isArray(r.archiveRevisions) && r.archiveRevisions.length), pasted: false }; + } + function normalizeRow(r) { + r = r || {}; + return { + id: r.id || uid(), party: r.party || '', + trackingNumber: (r.trackingNumber || '').trim(), title: r.title || '', + revisionCell: r.revisionCell || '', + source: rowSource(r), + archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [], + placed: Object.create(null), + }; + } + function setMdlList(rows) { state.mdlList = (rows || []).map(normalizeRow); notify(); } + function appendMdlRows(rows) { + var byTn = Object.create(null); + state.mdlList.forEach(function (r) { if (r.trackingNumber) byTn[r.trackingNumber] = r; }); + (rows || []).forEach(function (raw) { + var r = normalizeRow(raw), ex = r.trackingNumber ? byTn[r.trackingNumber] : null; + if (ex) { + if (!ex.revisionCell && r.revisionCell) ex.revisionCell = r.revisionCell; + if (!ex.title && r.title) ex.title = r.title; + ex.source.mdl = ex.source.mdl || r.source.mdl; + ex.source.archive = ex.source.archive || r.source.archive; + ex.source.pasted = ex.source.pasted || r.source.pasted; + if (r.archiveRevisions.length && !ex.archiveRevisions.length) ex.archiveRevisions = r.archiveRevisions; + } else { + state.mdlList.push(r); + if (r.trackingNumber) byTn[r.trackingNumber] = r; + } }); - // Drop placements pointing at deliverables no longer loaded. - var valid = Object.create(null); - state.mdlList.forEach(function (r) { valid[r.id] = true; }); - Object.keys(state.assignments).forEach(function (k) { - var a = state.assignments[k]; - if (a.mdlNodeId && !valid[a.mdlNodeId]) { a.mdlNodeId = null; cleanAssignment(k); } - }); - rebuildIndex(); notify(); } + function clearMdlList() { state.mdlList = []; notify(); } // rows only — assignments survive function getMdlList() { return state.mdlList; } - function getMdlRow(id) { var i = infoFor(id); return (i && i.kind === 'mdl') ? i.node : null; } + function getMdlRow(id) { return state.mdlList.filter(function (r) { return r.id === id; })[0] || null; } + + // Build (creating folders as needed) the tracking-tree leaf a row points at: + // "_". A tracking number's last segment is an + // ancestor and the revision is ALWAYS the leaf, so a blank revision gets a + // PENDING_REV placeholder leaf (the file shows incomplete until set; the + // placeholder is pruned when the real revision lands). + var PENDING_REV = 'pending'; + function leafForRow(row) { + var tn = (row.trackingNumber || '').trim(); + if (!tn) return null; + var rev = (row.revisionCell || '').trim() || PENDING_REV; + return addTrackingPath(null, parseFolderLevels(tn + '_' + rev)); + } + function assignFromRow(keys, row) { + var leaf = leafForRow(row); + if (!leaf || !keys || !keys.length) return; + place(keys, leaf, 'tracking'); + keys.forEach(function (k) { + row.placed[k] = true; + if (row.title && row.title.trim()) { + var a = state.assignments[k]; + if (a && !a.titleOverride) setTitleOverride(k, row.title); + } + }); + notify(); + } + function nodeHasFiles(nodeId) { + for (var k in state.assignments) { if (state.assignments[k].trackingNodeId === nodeId) return true; } + return false; + } + // Delete a now-empty tracking leaf and any ancestors it leaves empty (so a + // re-stamp doesn't litter the By-tracking tree with stale folders). + function pruneEmptyTrackingChain(nodeId) { + var info = infoFor(nodeId); + while (info && info.kind === 'tracking') { + if ((info.node.children || []).length || nodeHasFiles(info.node.id)) break; + var parent = info.parent; // parent NODE (or null) + deleteNode(info.node.id); // rebuilds the index + info = parent ? infoFor(parent.id) : null; + } + } + // Re-point a row's already-dropped files at the row's current leaf (after its + // tracking number or revision was edited). Skips keys the user has since + // un-placed elsewhere; prunes the leaves it empties. + function restampRow(row) { + var keys = Object.keys(row.placed || {}); + if (!keys.length) return; + var leaf = leafForRow(row); + if (!leaf) return; + var old = Object.create(null); + keys.forEach(function (k) { + var a = state.assignments[k]; + if (a && a.trackingNodeId) { if (a.trackingNodeId !== leaf) old[a.trackingNodeId] = true; a.trackingNodeId = leaf; } + else delete row.placed[k]; // user un-placed it elsewhere — don't resurrect + }); + clearHashConflicts(); + Object.keys(old).forEach(pruneEmptyTrackingChain); + notify(); + } + function unassignRowFile(row, key) { + var a = state.assignments[key], old = a ? a.trackingNodeId : null; + if (row && row.placed) delete row.placed[key]; + place([key], null, 'tracking'); + if (old) pruneEmptyTrackingChain(old); + } + function setRowTracking(rowId, tn) { + var r = getMdlRow(rowId); if (!r) return; + r.trackingNumber = (tn == null ? '' : String(tn)).trim(); + restampRow(r); notify(); + } + function setRowTitle(rowId, title) { + var r = getMdlRow(rowId); if (!r) return; + r.title = (title == null ? '' : String(title)); + Object.keys(r.placed || {}).forEach(function (k) { if (state.assignments[k]) setTitleOverride(k, r.title); }); + notify(); + } function setRevisionCell(rowId, value) { setRevisionCells([rowId], value); } function setRevisionCells(rowIds, value) { var set = Object.create(null); (rowIds || []).forEach(function (i) { set[i] = true; }); var changed = false; - state.mdlList.forEach(function (r) { if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); changed = true; } }); + state.mdlList.forEach(function (r) { + if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); restampRow(r); changed = true; } + }); if (changed) notify(); } - function setTitleFromDeliverable(key, fromDeliverable) { - var a = assignmentFor(key); - a.titleFromDeliverable = !!fromDeliverable; - cleanAssignment(key); - notify(); + + // ── paste parsing + name matching (pure helpers, unit-tested) ───────────── + // Parse Excel/TSV text into scratch rows. Columns: Tracking ⇥ Rev(Status) ⇥ + // Title; a 4th bare-status column merges into the revision; a lone cell that + // parses as a full ZDDC filename is split; a header row is skipped. + function parsePastedRows(text) { + function unq(s) { + s = (s == null ? '' : String(s)).trim(); + if (s.length >= 2 && s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') s = s.slice(1, -1).replace(/""/g, '"'); + return s.trim(); + } + var lines = String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n'); + var rows = [], skipped = [], sawData = false; + lines.forEach(function (raw, i) { + if (!raw.trim()) return; + var cells = raw.split('\t').map(unq); + var c0 = cells[0] || ''; + if (!sawData && cells.length > 1 && /^(tracking|number|no\.?|doc(ument)?|drawing|item)\b/i.test(c0) && c0.indexOf('-') === -1) { + return; // header row + } + var tracking = '', rev = '', title = ''; + if (cells.length === 1) { + var p = window.zddc.parseFilename(c0); + if (p && p.valid && p.trackingNumber) { tracking = p.trackingNumber; rev = p.revision + (p.status ? ' (' + p.status + ')' : ''); title = p.title || ''; } + else tracking = c0; + } else { + tracking = c0; + if (cells.length >= 4 && cells[2] && window.zddc.isValidStatus(cells[2])) { rev = (cells[1] + ' (' + cells[2] + ')').trim(); title = cells[3] || ''; } + else { rev = cells[1] || ''; title = cells[2] || ''; } + } + if (!tracking) { skipped.push({ line: i + 1, reason: 'no tracking number', text: raw }); return; } + sawData = true; + rows.push({ trackingNumber: tracking, revisionCell: rev.trim(), title: title, source: { pasted: true } }); + }); + return { rows: rows, skipped: skipped }; + } + function normTok(s) { return String(s == null ? '' : s).toUpperCase().replace(/[^A-Z0-9]/g, ''); } + // Propose row matches for source files by finding a row whose tracking number + // appears in the filename. opts.fuzzy also matches on the digit-run. + function proposeMatches(files, rows, opts) { + opts = opts || {}; + var out = []; + (files || []).forEach(function (f) { + var full = zddc.joinExtension(f.originalFilename, f.extension); + var nameNorm = normTok(full), nameDigits = nameNorm.replace(/[^0-9]/g, ''), best = null; + (rows || []).forEach(function (r) { + var tn = r.trackingNumber || ''; if (!tn) return; + var tnNorm = normTok(tn), conf = 0; + if (full.indexOf(tn) !== -1) conf = 1; + else if (tnNorm && nameNorm.indexOf(tnNorm) !== -1) conf = 0.8; + else if (opts.fuzzy) { var d = tnNorm.replace(/[^0-9]/g, ''); if (d && nameDigits.indexOf(d) !== -1) conf = 0.5; } + if (conf && (!best || conf > best.confidence)) best = { row: r, confidence: conf }; + }); + if (best) out.push({ file: f, row: best.row, confidence: best.confidence }); + }); + return out; } // ── add-folder pattern expansion ───────────────────────────────────────── @@ -695,9 +849,12 @@ transmittalRecord: transmittalRecord, findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin, getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields, - setMdlList: setMdlList, getMdlList: getMdlList, getMdlRow: getMdlRow, + setMdlList: setMdlList, appendMdlRows: appendMdlRows, clearMdlList: clearMdlList, + getMdlList: getMdlList, getMdlRow: getMdlRow, + assignFromRow: assignFromRow, unassignRowFile: unassignRowFile, + setRowTracking: setRowTracking, setRowTitle: setRowTitle, setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells, - setTitleFromDeliverable: setTitleFromDeliverable, + parsePastedRows: parsePastedRows, proposeMatches: proposeMatches, getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, getTransmittalTree: function () { return state.transmittalTree; }, // derive + reverse diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index 77da723..2a7db34 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -20,8 +20,10 @@ var openForm = null; // { partyId, slot } when a bin form is open var initialized = false; var currentTab = 'tracking'; // 'tracking' | 'existing' | 'transmittal' — active tab - var mdlTable = null; // the seltable controller for the catalog - var mdlPlaced = {}; // latest placed.mdl map (read by the placed-file cell) + var mdlTable = null; // the seltable controller for the "From a list" tab + var mdlPlaced = {}; // trackingNumber -> placed files (read by the Files cell) + var hideAssigned = false; // "Hide assigned" toggle in the From-a-list toolbar + var listScanned = false; // a Load has run this session (drives the "new" badge) function init() { if (initialized) return; @@ -37,6 +39,10 @@ transmittalTree: document.getElementById('transmittalTree'), mdlTree: document.getElementById('mdlTree'), loadMdlBtn: document.getElementById('loadMdlBtn'), + pasteRowsBtn: document.getElementById('pasteRowsBtn'), + matchNamesBtn: document.getElementById('matchNamesBtn'), + clearListBtn: document.getElementById('clearListBtn'), + hideAssignedToggle: document.getElementById('hideAssignedToggle'), addTrackingRootBtn: document.getElementById('addTrackingRootBtn'), addPartyBtn: document.getElementById('addPartyBtn'), stats: document.getElementById('classifyStats'), @@ -46,6 +52,25 @@ if (els.existingTab) els.existingTab.addEventListener('click', function () { showTab('existing'); }); els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); }); if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl); + if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); }); + if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog); + if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () { + if (!C().getMdlList().length) return; + C().clearMdlList(); + window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info'); + }); + if (els.hideAssignedToggle) els.hideAssignedToggle.addEventListener('change', function () { + hideAssigned = !!els.hideAssignedToggle.checked; + if (mdlTable) mdlTable.renderBody(); + }); + // Ctrl-V anywhere on the From-a-list panel opens the paste dialog prefilled. + if (els.mdlPanel) els.mdlPanel.addEventListener('paste', function (e) { + if (currentTab !== 'existing') return; + if (e.target && e.target.closest('input, textarea')) return; // let real inputs paste + var t = (e.clipboardData || window.clipboardData); + var text = t ? t.getData('text') : ''; + if (text) { e.preventDefault(); openPasteDialog(text); } + }); els.addTrackingRootBtn.addEventListener('click', function () { var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n' + 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', ''); @@ -86,15 +111,20 @@ } // One pass: group files by the node they're placed in, per axis. function buildPlaced(files) { - var c = C(), byT = {}, byX = {}, byM = {}; + 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); + 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); - if (a.mdlNodeId) (byM[a.mdlNodeId] = byM[a.mdlNodeId] || []).push(f); }); - return { tracking: byT, transmittal: byX, mdl: byM }; + return { tracking: byT, transmittal: byX, byTracking: byTn }; } function showTab(which) { @@ -110,8 +140,8 @@ // with the active tab — re-render the left tree. reRenderSource(); } - // The active axis is the catalog ('mdl') on the "By existing" tab, else the tab's. - function activeAxis() { return currentTab === 'existing' ? 'mdl' : (currentTab === 'transmittal' ? 'transmittal' : 'tracking'); } + // "From a list" drops materialize tracking placements, so its axis is 'tracking'. + function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; } function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); } // Expand a brace pattern into folder names and create them (confirming a @@ -137,7 +167,7 @@ var placed = buildPlaced(files); renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking); renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal); - renderMdlInto(placed.mdl); + renderMdlInto(placed.byTracking); renderStats(files); } @@ -471,47 +501,43 @@ return form; } - // ── By MDL (deliverables as drop targets via the shared seltable) ─────── - function renderMdlInto(placedMdl) { - mdlPlaced = placedMdl || {}; + // ── "From a list" (scratch worklist via the shared seltable) ──────────── + function renderMdlInto(placedByTracking) { + mdlPlaced = placedByTracking || {}; if (!C().getMdlList().length) { mdlTable = null; els.mdlTree.textContent = ''; - els.mdlTree.appendChild(el('div', 'target-empty', 'Nothing loaded yet — “Load…”, tick the directories to scan, and their existing files + MDL deliverables appear here (one row per tracking number, latest revision).')); + els.mdlTree.appendChild(el('div', 'target-empty', 'Empty — “Load…” numbers from the archive/MDL, “Paste rows…” from Excel, or “⚡ Match names”. Then drag files onto a row to name them. The list is a scratch pad — clearing it keeps every assignment (see By tracking number).')); return; } ensureMdlTable(); mdlTable.renderBody(); } + function rowPlaced(r) { var f = mdlPlaced[r.trackingNumber]; return f && f.length ? f : null; } function ensureMdlTable() { if (mdlTable) return mdlTable; var c = C(); - // One column per configured tracking-number field (split positionally), - // then Title, MDL (✓), Archive revisions (informational), and the editable - // classifier-local Revision. Each column has its own autofilter. - var cols = c.getTrackingFields().map(function (f, i) { - return { key: 'f' + i, title: f.name, get: function (r) { return (r.trackingNumber || '').split('-')[i] || ''; } }; - }); - cols.push({ key: 'title', title: 'Title', get: function (r) { return r.title || ''; } }); - cols.push({ key: 'mdl', title: 'MDL', cls: 'catalog-mdl', get: function (r) { return r.inMdl ? '✓' : ''; } }); - cols.push({ key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } }); - cols.push({ - key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; }, - render: function (r, td) { - var inp = document.createElement('input'); - inp.type = 'text'; inp.className = 'mdl-rev__input'; inp.value = r.revisionCell || ''; - inp.placeholder = 'A (IFR)'; inp.setAttribute('data-no-select', ''); - inp.addEventListener('change', function () { c.setRevisionCell(r.id, inp.value.trim()); }); - td.appendChild(inp); - }, - }); + var cols = [ + { key: 'tn', title: 'Tracking number', cls: 'fromlist-tn', get: function (r) { return r.trackingNumber || ''; }, + render: function (r, td) { editCell(td, 'fromlist-tn__input', r.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(r.id, v); }, tnWarn(r)); } }, + { key: 'title', title: 'Title', cls: 'fromlist-title', get: function (r) { return r.title || ''; }, + render: function (r, td) { editCell(td, 'fromlist-title__input', r.title, 'title', function (v) { c.setRowTitle(r.id, v); }); } }, + { key: 'src', title: 'Source', cls: 'fromlist-src', get: function (r) { var s = r.source || {}; return [s.mdl ? 'mdl' : '', s.archive ? 'arch' : '', s.pasted ? 'pasted' : ''].filter(Boolean).join(' '); }, + render: function (r, td) { renderSource(r, td); } }, + { key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } }, + { key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; }, + render: function (r, td) { editCell(td, 'mdl-rev__input', r.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(r.id, v); }); } }, + ]; mdlTable = window.app.modules.seltable.create({ container: els.mdlTree, extraTitle: 'Files', - rows: function () { return c.getMdlList(); }, + rows: function () { + var list = c.getMdlList(); + return hideAssigned ? list.filter(function (r) { return !rowPlaced(r); }) : list; + }, rowId: function (r) { return r.id; }, columns: cols, - onRowDrop: function (rowId, keys) { c.place(keys, rowId, 'mdl'); }, + onRowDrop: function (rowId, keys) { var row = c.getMdlRow(rowId); if (row) c.assignFromRow(keys, row); }, onActivate: function (ids) { if (!ids.length) return; var v = prompt('Set the revision on ' + ids.length + ' selected row(s) (e.g. "A (IFR)"):', ''); @@ -522,8 +548,37 @@ mdlTable.render(); return mdlTable; } + // An editable seltable cell: an that commits on change. `warn` is an + // optional tooltip that flags (without blocking) a questionable value. + function editCell(td, cls, value, placeholder, onCommit, warn) { + var inp = document.createElement('input'); + inp.type = 'text'; inp.className = cls + (warn ? ' is-warn' : ''); inp.value = value || ''; + inp.placeholder = placeholder || ''; inp.spellcheck = false; inp.setAttribute('data-no-select', ''); + if (warn) inp.title = warn; + inp.addEventListener('change', function () { onCommit(inp.value.trim()); }); + td.appendChild(inp); + } + function tnWarn(r) { + var tn = (r.trackingNumber || '').trim(); if (!tn) return ''; + var n = tn.split('-').length, want = C().getTrackingFields().length; + return n < want - 1 || n > want ? ('Has ' + n + ' segments; the pattern expects ' + want + '.') : ''; + } + function renderSource(row, td) { + var s = row.source || {}; + if (s.mdl) td.appendChild(el('span', 'src-badge src-badge--mdl', 'MDL')); + if (s.archive) td.appendChild(el('span', 'src-badge src-badge--arch', 'arch')); + if (s.pasted && !s.mdl && !s.archive) { + // A pasted number matching nothing known: a likely typo / a brand-new number. + var isNew = listScanned; + var b = el('span', 'src-badge src-badge--new', isNew ? 'new' : 'unverified'); + b.title = isNew ? 'This tracking number isn’t in the scanned archive/MDL — you’re inventing it.' : 'Not checked against the archive/MDL — Load a directory to verify.'; + td.appendChild(b); + } else if (s.pasted) { + td.appendChild(el('span', 'src-badge src-badge--pasted', 'pasted')); + } + } function renderMdlPlaced(row, td) { - var c = C(), files = mdlPlaced[row.id] || []; + var c = C(), files = rowPlaced(row) || []; files.forEach(function (f) { var d = c.deriveTarget(f); var a = c.getAssignment(d.key) || {}; @@ -533,13 +588,14 @@ var nm = el('span', 'mdlfile__name', d.filename || '(set a revision)'); nm.title = 'from ' + f.originalFilename + (f.extension ? '.' + f.extension : ''); line.appendChild(nm); - var tgl = el('button', 'tnode__act', a.titleFromDeliverable === false ? 'Title: file' : 'Title: MDL'); - tgl.title = 'Use the deliverable’s title or the file’s own'; - tgl.addEventListener('click', function () { c.setTitleFromDeliverable(d.key, a.titleFromDeliverable === false); }); + var usingRow = a.titleOverride != null && row.title && a.titleOverride === row.title.trim(); + var tgl = el('button', 'tnode__act', usingRow ? 'Title: row' : 'Title: file'); + tgl.title = 'Use the row’s title or the file’s own'; + tgl.addEventListener('click', function () { c.setTitleOverride(d.key, usingRow ? '' : row.title); }); line.appendChild(tgl); var rm = el('button', 'tnode__act tfile__remove', '✕'); - rm.title = 'Remove from this deliverable'; - rm.addEventListener('click', function () { c.place([d.key], null, 'mdl'); }); + rm.title = 'Remove this file from the row'; + rm.addEventListener('click', function () { c.unassignRowFile(row, d.key); }); line.appendChild(rm); td.appendChild(line); }); @@ -642,13 +698,123 @@ } } function finishLoad(rows) { - C().setMdlList(rows); + listScanned = true; + C().appendMdlRows(rows); // APPEND — the list accumulates across batches showTab('existing'); window.zddc.toast(rows.length - ? ('Catalog: ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.') + ? ('Added ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.') : 'No files or deliverables in the selected directories.', rows.length ? 'success' : 'warning'); } + // ── paste + match dialogs (reuse the .copy-choice modal shell) ────────── + function scratchModal(titleText, hintText) { + var done = false; + function close() { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); } + function onKey(e) { if (e.key === 'Escape') close(); } + var back = el('div', 'copy-choice__backdrop'); + var box = el('div', 'copy-choice copy-choice--wide'); + box.appendChild(el('h3', null, titleText)); + if (hintText) box.appendChild(el('p', null, hintText)); + var body = el('div', 'scratch-modal__body'); box.appendChild(body); + var foot = el('div', 'copy-choice__btns'); box.appendChild(foot); + back.appendChild(box); + back.addEventListener('click', function (e) { if (e.target === back) close(); }); + document.addEventListener('keydown', onKey); + document.body.appendChild(back); + return { body: body, foot: foot, close: close }; + } + function openPasteDialog(prefill) { + var c = C(); + var m = scratchModal('Paste rows from Excel', 'Columns: Tracking · Rev (Status) · Title — tab-separated, as Excel copies. A header row is skipped; a pasted full filename is split.'); + var ta = document.createElement('textarea'); + ta.className = 'scratch-paste__ta'; ta.rows = 6; ta.spellcheck = false; + ta.placeholder = 'ACME-AR-DWG-0001\tA (IFR)\tFloor plan'; + ta.value = prefill || ''; + m.body.appendChild(ta); + var preview = el('div', 'scratch-paste__preview'); m.body.appendChild(preview); + var add = el('button', 'btn btn-primary', 'Add rows'); add.disabled = true; + var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close); + m.foot.appendChild(add); m.foot.appendChild(cancel); + var parsed = { rows: [], skipped: [] }; + function refresh() { + parsed = c.parsePastedRows(ta.value); + preview.textContent = ''; + if (parsed.rows.length) { + var tbl = el('table', 'scratch-preview__table'); + var head = el('tr'); ['Tracking number', 'Revision', 'Title'].forEach(function (h) { head.appendChild(el('th', null, h)); }); tbl.appendChild(head); + parsed.rows.slice(0, 50).forEach(function (r) { + var tr = el('tr'); + tr.appendChild(el('td', null, r.trackingNumber)); + tr.appendChild(el('td', null, r.revisionCell || '')); + tr.appendChild(el('td', null, r.title || '')); + tbl.appendChild(tr); + }); + preview.appendChild(tbl); + if (parsed.rows.length > 50) preview.appendChild(el('div', 'scratch-preview__more', '…and ' + (parsed.rows.length - 50) + ' more')); + } + parsed.skipped.forEach(function (s) { preview.appendChild(el('div', 'scratch-preview__skip', 'Line ' + s.line + ' skipped — ' + s.reason)); }); + add.disabled = !parsed.rows.length; + add.textContent = parsed.rows.length ? ('Add ' + parsed.rows.length + ' row' + (parsed.rows.length === 1 ? '' : 's')) : 'Add rows'; + } + add.addEventListener('click', function () { + var n = parsed.rows.length; + c.appendMdlRows(parsed.rows); + m.close(); showTab('existing'); + window.zddc.toast('Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.', 'success'); + }); + ta.addEventListener('input', refresh); + refresh(); ta.focus(); + } + function openMatchDialog() { + var c = C(); + var rows = c.getMdlList(); + if (!rows.length) { window.zddc.toast('Load or paste some tracking numbers first.', 'warning'); return; } + var files = allFiles().filter(function (f) { + var a = c.getAssignment(c.srcKeyForFile(f)); + return !(a && (a.trackingNodeId || a.excluded)); + }); + if (!files.length) { window.zddc.toast('No unassigned files to match.', 'info'); return; } + var m = scratchModal('Match names', 'Files whose name contains a known tracking number. Review, then assign the checked matches.'); + var opts = { fuzzy: false }; + var fuzzyLbl = el('label', 'scratch-match__fuzzy'); + var fuzzy = document.createElement('input'); fuzzy.type = 'checkbox'; + fuzzyLbl.appendChild(fuzzy); fuzzyLbl.appendChild(document.createTextNode(' Looser matching (digits only)')); + m.body.appendChild(fuzzyLbl); + var list = el('div', 'scratch-match__list'); m.body.appendChild(list); + var accept = el('button', 'btn btn-primary', 'Assign'); + var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close); + m.foot.appendChild(accept); m.foot.appendChild(cancel); + var proposals = []; + function refresh() { + proposals = c.proposeMatches(files, rows, opts); + list.textContent = ''; + if (!proposals.length) { list.appendChild(el('div', 'scratch-preview__skip', 'No matches found.')); accept.disabled = true; accept.textContent = 'Assign'; return; } + proposals.forEach(function (p, i) { + var rowEl = el('label', 'scratch-match__row'); + var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true; cb.dataset.i = i; + rowEl.appendChild(cb); + rowEl.appendChild(el('span', 'scratch-match__file', zddc.joinExtension(p.file.originalFilename, p.file.extension))); + rowEl.appendChild(el('span', 'scratch-match__arrow', '→')); + rowEl.appendChild(el('span', 'scratch-match__tn', p.row.trackingNumber)); + rowEl.appendChild(el('span', 'scratch-match__conf', Math.round(p.confidence * 100) + '%')); + list.appendChild(rowEl); + }); + accept.disabled = false; accept.textContent = 'Assign ' + proposals.length; + } + accept.addEventListener('click', function () { + var n = 0; + Array.prototype.forEach.call(list.querySelectorAll('input[type=checkbox]'), function (cb) { + if (!cb.checked) return; + var p = proposals[Number(cb.dataset.i)]; + if (p) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; } + }); + m.close(); showTab('existing'); + window.zddc.toast('Assigned ' + n + ' file' + (n === 1 ? '' : 's') + ' by name match.', n ? 'success' : 'info'); + }); + fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); }); + refresh(); + } + // ── events ───────────────────────────────────────────────────────────── function closestNodeId(target) { var n = target.closest('[data-id]'); @@ -850,10 +1016,7 @@ function reveal(key) { var a = C().getAssignment(key); if (!a) return; - if (a.mdlNodeId) { - showTab('existing'); - if (mdlTable) { mdlTable.renderBody(); } - } else if (a.trackingNodeId) { + if (a.trackingNodeId) { showTab('tracking'); collapsed = {}; render(); flashNode(els.trackingTree, a.trackingNodeId); } else if (a.transmittalNodeId) { diff --git a/classifier/js/tree.js b/classifier/js/tree.js index c87d760..ddd9342 100644 --- a/classifier/js/tree.js +++ b/classifier/js/tree.js @@ -69,8 +69,8 @@ var tt = window.app.modules.targetTree; return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking'; } - function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : ax === 'mdl' ? 'mdlNodeId' : 'trackingNodeId'; } - // Bucket a file relative to the active axis (tracking | transmittal | mdl): + function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId'; } + // Bucket a file relative to the active axis (tracking | transmittal): // 'excluded' | 'assigned' (on this axis) | 'partial' (assigned on a DIFFERENT // axis only — the to-do for this tab) | 'unassigned' (no axis). function fileCategory(file) { @@ -79,7 +79,7 @@ if (a && a.excluded) return 'excluded'; var ax = activeAxis(); if (a && a[axisField(ax)]) return 'assigned'; - var others = ['tracking', 'transmittal', 'mdl'].filter(function (x) { return x !== ax; }); + var others = ['tracking', 'transmittal'].filter(function (x) { return x !== ax; }); var any = a && others.some(function (x) { return a[axisField(x)]; }); return any ? 'partial' : 'unassigned'; } diff --git a/classifier/template.html b/classifier/template.html index 0e50bc3..bde4bbe 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -159,11 +159,11 @@
-

Each file needs a tracking number (revision + status + title) and a transmittal folder. Name it — build one under By tracking number or reuse one under By existing — then route it under By transmittal.

+

Each file needs a tracking number (revision + status + title) and a transmittal folder. Name it — build one under By tracking number, or drag onto a row under From a list (loaded from the archive/MDL or pasted from Excel) — then route it under By transmittal.

- +
@@ -200,14 +200,19 @@ placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
- + diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 2098bf6..3149322 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -1243,7 +1243,7 @@ test('seltable: autofilter + ctrl-shift selection builds complex sets', async ({ expect(r.ctrlShiftRange).toBe('c,d'); // ctrl-shift range runs over the FILTERED order }); -test('classify: an MDL placement names a file; revision from the cell, transmittal for the path', async ({ page }) => { +test('From a list: a drop materializes a real tracking placement; row revision + transmittal complete it', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { const c = window.app.modules.classify; @@ -1251,32 +1251,36 @@ test('classify: an MDL placement names a file; revision from the cell, transmitt const f = { originalFilename: 'messy scan 47', extension: 'pdf', folderPath: 'R' }; window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }]; const key = c.srcKeyForFile(f); - c.setMdlList([{ id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Switchgear Spec' }]); - c.place([key], 'm1', 'mdl'); - const beforeRev = c.deriveTarget(f); // no revision yet - c.setRevisionCell('m1', 'A (IFR)'); - const named = c.deriveTarget(f); // named, but no transmittal → not complete + c.setMdlList([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Switchgear Spec' }]); + c.assignFromRow([key], c.getMdlRow('m1')); // blank revision → partial + const placedTracking = !!(c.getAssignment(key) || {}).trackingNodeId; // a REAL tracking placement + const beforeRev = c.deriveTarget(f); + c.setRevisionCell('m1', 'A (IFR)'); // re-stamps onto the leaf + const named = c.deriveTarget(f); const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); c.place([key], bin, 'transmittal'); const full = c.deriveTarget(f); - c.setTitleFromDeliverable(key, false); // use the file's own title instead + c.setTitleOverride(key, ''); // use the file's own title instead const fileTitle = c.deriveTarget(f); return { - beforeRevErr: beforeRev.errors.length > 0, + placedTracking, beforeRevErr: beforeRev.errors.length > 0, + beforeTracking: beforeRev.tracking, named: named.filename, namedComplete: named.complete, fullName: full.filename, fullComplete: full.complete, fileTitleName: fileTitle.filename, }; }); - expect(r.beforeRevErr).toBe(true); // a deliverable with no revision can't name a file - expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf'); // tracking+title from MDL, rev from cell - expect(r.namedComplete).toBe(false); // still needs a transmittal for the output path + expect(r.placedTracking).toBe(true); // not a separate axis — a tracking placement + expect(r.beforeTracking).toBe('ACM-PRJ-EL-SPC-0001'); // full tracking number preserved while rev pending + expect(r.beforeRevErr).toBe(true); // no revision yet → incomplete + expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf'); + expect(r.namedComplete).toBe(false); // still needs a transmittal expect(r.fullComplete).toBe(true); expect(r.fullName).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf'); expect(r.fileTitleName).toContain('messy scan 47'); // title toggle → the file's own title }); -test('By existing: shows latest rev, drop on a row names the file, bulk revision applies', async ({ page }) => { +test('From a list: clearing the list keeps classifications; the row drives the seltable', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; @@ -1284,25 +1288,124 @@ test('By existing: shows latest rev, drop on a row names the file, bulk revision const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' }; window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }]; const key = c.srcKeyForFile(f); - // Catalog rows = files ∪ MDL deliverables, deduped per tracking number. c.setMdlList([ - { id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'] }, - { id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', inMdl: false, archiveRevisions: ['0 (IFC)'] }, + { id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'], revisionCell: 'C (IFC)' }, + { id: 'm2', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', archiveRevisions: ['0 (IFC)'] }, ]); - tt.showTab('existing'); // shows the catalog panel + builds the seltable into #mdlTree + tt.showTab('existing'); const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]'); - // Latest rev only: B (IFC) > A (IFR), so the cell shows B (IFC), not A (IFR). - const latestShown = !!row && row.textContent.includes('B (IFC)') && !row.textContent.includes('A (IFR)'); + const latestShown = !!row && row.textContent.includes('B (IFC)'); // latest archive rev shown window.app.modules.dnd.setDrag([key]); - row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop the file on m1 - const placed = (c.getAssignment(key) || {}).mdlNodeId; - c.setRevisionCells(['m1', 'm2'], 'A (IFR)'); // ctrl-Enter bulk path - return { hasRow: !!row, latestShown, placed, named: c.deriveTarget(f).filename }; + row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop on m1 (rev C set) + const named = c.deriveTarget(f).filename; + c.clearMdlList(); // list emptied — assignment must survive + return { + hasRow: !!row, latestShown, + placedAfterDrop: !!(c.getAssignment(key) || {}).trackingNodeId, + named, + listLen: c.getMdlList().length, + stillPlaced: !!(c.getAssignment(key) || {}).trackingNodeId, + stillNamed: c.deriveTarget(f).filename, + }; }); expect(r.hasRow).toBe(true); - expect(r.latestShown).toBe(true); // only the latest archive revision shown - expect(r.placed).toBe('m1'); // drop = tracking number only - expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // revision from the bulk-set column feeds the name + expect(r.latestShown).toBe(true); + expect(r.placedAfterDrop).toBe(true); + expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_C (IFC) - Spec.pdf'); // tracking + row revision + title + expect(r.listLen).toBe(0); // list cleared + expect(r.stillPlaced).toBe(true); // classification survives the clear + expect(r.stillNamed).toBe('ACM-PRJ-EL-SPC-0001_C (IFC) - Spec.pdf'); +}); + +test('From a list: editing the tracking number (bump sequence) re-stamps placed files', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const r = await page.evaluate(() => { + const c = window.app.modules.classify; + c.reset(); + const f = { originalFilename: 'plan', extension: 'pdf', folderPath: 'R' }; + window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }]; + const key = c.srcKeyForFile(f); + c.setMdlList([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-DWG-0007', title: 'Plan', revisionCell: 'A (IFR)' }]); + c.assignFromRow([key], c.getMdlRow('m1')); + const before = c.deriveTarget(f).filename; + c.setRowTracking('m1', 'ACM-PRJ-EL-DWG-0008'); // it's the next drawing + const after = c.deriveTarget(f).filename; + // the old leaf chain should be pruned (no stray 0007 folder) + const roots = c.getTrackingTree(); + const hasStale0007 = JSON.stringify(roots).indexOf('0007') !== -1; + return { before, after, hasStale0007 }; + }); + expect(r.before).toBe('ACM-PRJ-EL-DWG-0007_A (IFR) - Plan.pdf'); + expect(r.after).toBe('ACM-PRJ-EL-DWG-0008_A (IFR) - Plan.pdf'); // file moved with the bump + expect(r.hasStale0007).toBe(false); // old leaf pruned +}); + +test('From a list: load() migrates a legacy mdlNodeId placement into a tracking placement', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const r = await page.evaluate(() => { + const c = window.app.modules.classify; + c.reset(); + const f = { originalFilename: 'old', extension: 'pdf', folderPath: 'R' }; + window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }]; + const key = c.srcKeyForFile(f); + // A pre-"From a list" serialized workspace: the file points at an mdl row. + c.load({ + assignments: { [key]: { mdlNodeId: 'old1', titleFromDeliverable: true, transmittalNodeId: null, excluded: false, titleOverride: null } }, + mdlList: [{ id: 'old1', trackingNumber: 'ACM-PRJ-EL-SPC-0009', title: 'Legacy', revisionCell: 'B (IFC)' }], + }); + const a = c.getAssignment(key) || {}; + return { + noMdlNodeId: !('mdlNodeId' in a), + hasTracking: !!a.trackingNodeId, + named: c.deriveTarget(f).filename, + }; + }); + expect(r.noMdlNodeId).toBe(true); // dead field dropped + expect(r.hasTracking).toBe(true); // materialized into the tracking tree + expect(r.named).toBe('ACM-PRJ-EL-SPC-0009_B (IFC) - Legacy.pdf'); // classification preserved +}); + +test('parsePastedRows handles 3-col, 4-col(status), a filename, a header, and bad rows', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const r = await page.evaluate(() => { + const c = window.app.modules.classify; + const text = [ + 'Tracking Number\tRev\tTitle', // header → skipped + 'ACM-PRJ-EL-SPC-0001\tA (IFR)\tFloor plan', // 3-col + 'ACM-PRJ-EL-SPC-0002\tB\tIFC\tSection', // 4-col (status split) + 'ACM-PRJ-EL-SPC-0003_C (IFA) - Detail.pdf', // single full filename + '\tjust a rev\t', // no tracking → skipped + ].join('\n'); + return c.parsePastedRows(text); + }); + expect(r.rows.map(x => x.trackingNumber)).toEqual(['ACM-PRJ-EL-SPC-0001', 'ACM-PRJ-EL-SPC-0002', 'ACM-PRJ-EL-SPC-0003']); + expect(r.rows[0].revisionCell).toBe('A (IFR)'); + expect(r.rows[1].revisionCell).toBe('B (IFC)'); // status column merged + expect(r.rows[2].revisionCell).toBe('C (IFA)'); // split from the filename + expect(r.rows[2].title).toBe('Detail'); + expect(r.skipped.length).toBe(1); // the no-tracking row +}); + +test('proposeMatches finds a row whose tracking number is in the filename', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const r = await page.evaluate(() => { + const c = window.app.modules.classify; + const files = [ + { originalFilename: 'ACM-PRJ-EL-SPC-0001 rev A', extension: 'pdf' }, // exact substring + { originalFilename: 'random scan', extension: 'pdf' }, // no match + { originalFilename: 'doc ACMPRJELSPC0002 final', extension: 'pdf' }, // normalized + ]; + const rows = [ + { trackingNumber: 'ACM-PRJ-EL-SPC-0001' }, + { trackingNumber: 'ACM-PRJ-EL-SPC-0002' }, + ]; + const m = c.proposeMatches(files, rows, {}); + return m.map(p => ({ file: p.file.originalFilename, tn: p.row.trackingNumber, conf: p.confidence })); + }); + expect(r.length).toBe(2); // the no-match file is dropped + expect(r[0]).toEqual({ file: 'ACM-PRJ-EL-SPC-0001 rev A', tn: 'ACM-PRJ-EL-SPC-0001', conf: 1 }); + expect(r[1].tn).toBe('ACM-PRJ-EL-SPC-0002'); // matched via normalization + expect(r[1].conf).toBeCloseTo(0.8); }); test('By existing: walkDirInto unions files + mdl deliverables, deduped to the latest revision', async ({ page }) => {