From f82d6919b44a120c09b481a3c62b222c32634cc4 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 16 Jun 2026 09:53:04 -0500 Subject: [PATCH] =?UTF-8?q?feat(classifier):=20one=20classify=20surface=20?= =?UTF-8?q?=E2=80=94=20By-tracking=20grid=20with=20Rename,=20transmittal?= =?UTF-8?q?=20with=20Copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two-mode tool (Classify & copy / Rename in place) into a single surface. The top mode toggle and the standalone Rename spreadsheet are gone; the By-tracking grid is now the one editable table, and the two operations are framed as the two physical things you can do with a classified file: - By tracking number → "Rename…": a tracking number + rev + title with no transmittal IS a rename, so this renames the name-complete grid files IN PLACE on disk (rename.js, lifted from the spreadsheet — HTTP move / FS copy+remove, resumable). Blocking red no-backup warning; a renamed file is now correctly named so it leaves the grid (classify.forgetFile). - By transmittal → "Copy…": the existing resumable/verified archive copy, moved onto this tab; enabled once files are fully classified (tracking + transmittal). "From a list" is folded into the By-tracking grid, not a separate tab. The grid now holds two boring row kinds (one row ↔ 0-or-1 file): - file rows (workset / placed), edited via setFileIdentity; - placeholder rows = list rows with no file yet (Load…/Paste rows…/Match names), edited via the worklist setters; drop or match a file and it becomes a file row. Dropping N files on a placeholder fans out over consecutive placeholders (fillFromRow) — the start of the Excel-style block fill. New "⊕ Add filtered files" pulls every file the left-tree filter shows into the grid. Source / Latest rev fold in as optional (default-hidden) columns. Chrome: removed the mode toggle + spreadsheet pane from the template; severed the spreadsheet/sort/filter/resize/selection inits in app.js (modules stay bundled, store still drives folder selection + reset); setMode() is now a no-arg enabler; welcome tutorial rewritten to the single flow. Tests: bootstrap via app.setMode() (no mode button); mode-switch test asserts the single surface; worklist test drives placeholder rows in #trackingTree. 69 classify + 56 classifier/tables/cap green. Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/build.sh | 1 + classifier/css/layout.css | 4 + classifier/js/app.js | 68 +++----- classifier/js/classify.js | 23 ++- classifier/js/rename.js | 83 +++++++++ classifier/js/target-tree.js | 319 ++++++++++++++++++++--------------- classifier/js/tree.js | 1 + classifier/template.html | 134 +++------------ tests/classify.spec.js | 105 ++++++------ 9 files changed, 388 insertions(+), 350 deletions(-) create mode 100644 classifier/js/rename.js diff --git a/classifier/build.sh b/classifier/build.sh index 2133b69..864a783 100755 --- a/classifier/build.sh +++ b/classifier/build.sh @@ -64,6 +64,7 @@ concat_files \ "js/dir-picker.js" \ "js/target-tree.js" \ "js/copy.js" \ + "js/rename.js" \ "js/spreadsheet.js" \ "js/selection.js" \ "js/preview.js" \ diff --git a/classifier/css/layout.css b/classifier/css/layout.css index b13196f..e3a39ea 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -752,6 +752,10 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o .tg-orig__link { color: var(--text-muted); white-space: nowrap; text-decoration: none; cursor: pointer; } .tg-orig__link:hover { text-decoration: underline; } .tg-status, .tg-x { text-align: center; } +/* Placeholder rows (a list item with no file yet) — a hollow "wanted" marker and + a muted expected-name hint that doubles as the drop target. */ +.tg-wanted { color: var(--text-muted); } +.tg-expected { color: var(--text-muted); font-style: italic; } .tg-x__btn { opacity: 0.5; } .seltable__row:hover .tg-x__btn { opacity: 1; } .tg-drop-hover { outline: 2px dashed var(--primary); outline-offset: -3px; background: var(--primary-light); } diff --git a/classifier/js/app.js b/classifier/js/app.js index bf673d8..bff9eda 100644 --- a/classifier/js/app.js +++ b/classifier/js/app.js @@ -193,22 +193,19 @@ * onto target trees, copy renamed copies out). The source tree (left) stays * in both modes; only the right pane swaps. */ - function setMode(mode) { - const classify = mode === 'classify'; - app.dom.modeRenameBtn.classList.toggle('active', !classify); - app.dom.modeClassifyBtn.classList.toggle('active', classify); - if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify; - if (app.dom.targetPane) app.dom.targetPane.hidden = !classify; - // Mode-specific source-tree filters: "Hide Compliant" is for the rename - // grid; "Hide Assigned" is for the classify workflow. - if (app.dom.hideCompliantLabel) app.dom.hideCompliantLabel.hidden = classify; - if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = !classify; - app.modules.classify.setEnabled(classify); - if (classify && app.modules.targetTree) { + // There is only one surface now (the classify grid + transmittal tree); the + // old Rename-in-place spreadsheet was folded into the By-tracking grid's + // "Rename…" action. setMode is kept as a no-arg enabler for back-compat with + // the workspace/open flows that call it. + function setMode() { + if (app.dom.targetPane) app.dom.targetPane.hidden = false; + if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = false; + app.modules.classify.setEnabled(true); + if (app.modules.targetTree) { app.modules.targetTree.init(); app.modules.targetTree.render(); } - // Re-render the source tree so its per-file markers appear/disappear. + // Re-render the source tree so its per-file markers appear. if (app.modules.tree) app.modules.tree.render(); } @@ -339,18 +336,9 @@ // Drag and drop on welcome screen setupWelcomeDragDrop(); - // Bulk actions - app.dom.saveAllBtn.addEventListener('click', handleSaveAll); - app.dom.cancelAllBtn.addEventListener('click', handleCancelAll); - - // Export hashes - app.dom.exportHashesBtn.addEventListener('click', handleExportHashes); - - // SHA256 toggle - app.dom.sha256Checkbox.addEventListener('change', handleSha256Toggle); - - // Hide compliant toggle - app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle); + // (The old Rename-in-place spreadsheet — Save All / Cancel All / SHA256 / + // Export hashes — was removed; its rename is now the By-tracking "Rename…".) + if (app.dom.hideCompliantCheckbox) app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle); // Classify-mode source-tree filters: show/hide unassigned, assigned, excluded. function pushClassifyFilters() { @@ -375,9 +363,6 @@ // Collapse tree button app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree); - // Workflow mode switch - if (app.dom.modeRenameBtn) app.dom.modeRenameBtn.addEventListener('click', function () { setMode('rename'); }); - if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); }); if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); }); if (app.dom.checkDuplicatesBtn) app.dom.checkDuplicatesBtn.addEventListener('click', function () { app.modules.copy.audit(); }); @@ -543,12 +528,7 @@ showMainUI(); if (!shellInited) { shellInited = true; - app.modules.spreadsheet.init(); // Subscribe to store - app.modules.selection.init(); - app.modules.preview.init(); // After selection so it can listen for rowfocused - app.modules.resize.init(); - app.modules.filter.init(); - app.modules.sort.init(); + app.modules.preview.init(); // file preview (click a row / original-name link) app.modules.tree.setupKeyboardShortcuts(); if (app.modules.targetTree) app.modules.targetTree.init(); } @@ -558,9 +538,8 @@ async function openDirectory(dirHandle) { app.rootHandle = dirHandle; enterAppShell(); - // Default to Classify & Copy (the primary workflow). The user can switch - // to "Rename in place" via the toggle for the spreadsheet. - setMode('classify'); + setMode(); // the single classify surface + // Now scan directory (this will trigger store updates and renders) await app.modules.scanner.scanDirectory(dirHandle); } @@ -669,18 +648,8 @@ * Handle keyboard shortcuts */ function handleKeyDown(e) { - // Ctrl+S - Save All - if (e.ctrlKey && e.key === 's') { - e.preventDefault(); - if (!app.dom.saveAllBtn.disabled) { - handleSaveAll(); - } - } - - // Escape - Cancel editing - if (e.key === 'Escape') { - app.modules.spreadsheet.cancelEditing(); - } + // (Spreadsheet Ctrl+S / Escape handlers removed with the Rename-in-place + // pane. The By-tracking grid commits edits on change.) } /** @@ -712,6 +681,7 @@ * Update stats display */ function updateStats() { + if (!app.dom.totalFiles) return; // spreadsheet pane removed — nothing to update const files = app.modules.store.getDisplayFiles(); const totalFiles = files.length; const modifiedFiles = files.filter(f => f.isDirty).length; diff --git a/classifier/js/classify.js b/classifier/js/classify.js index a83806e..a34f1e8 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -493,6 +493,19 @@ Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].trackingNodeId) set[k] = true; }); return Object.keys(set); } + // A file was renamed on disk to its canonical name — it's done, so drop it from + // the grid model entirely (under its OLD key): clear both placements, the + // workset, the assignment, and any worklist-row binding. The compliant file + // object itself stays in the scanned tree. No notify — the caller batches. + function forgetFile(key) { + var a = state.assignments[key], oldTrack = a ? a.trackingNodeId : null; + delete state.trackingWorkset[key]; + place([key], null, 'tracking'); + place([key], null, 'transmittal'); + delete state.assignments[key]; + state.worklist.forEach(function (r) { if (r.placed) delete r.placed[key]; if (r.bound) delete r.bound[key]; }); + if (oldTrack) pruneEmptyTrackingChain(oldTrack); + } // Re-materialize a file's tracking placement from a full identity. The caller // passes ALL three fields (current values for the ones it didn't edit), read // from deriveTarget — so this module needs no file objects. A blank revision @@ -578,6 +591,13 @@ notify(); } function clearWorklist() { state.worklist = []; notify(); } // rows only — assignments survive + // Remove ONE worklist row (a placeholder's ✕). Any files it placed keep their + // assignments — only the scratch row goes away. + function removeWorklistRow(id) { + var before = state.worklist.length; + state.worklist = state.worklist.filter(function (r) { return r.id !== id; }); + if (state.worklist.length !== before) notify(); + } function getWorklist() { return state.worklist; } function getWorklistRow(id) { return state.worklist.filter(function (r) { return r.id === id; })[0] || null; } @@ -962,8 +982,9 @@ getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields, // By-tracking grid addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid, - trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity, + trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity, forgetFile: forgetFile, setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist, + removeWorklistRow: removeWorklistRow, getWorklist: getWorklist, getWorklistRow: getWorklistRow, assignFromRow: assignFromRow, unassignRowFile: unassignRowFile, setRowTracking: setRowTracking, setRowTitle: setRowTitle, diff --git a/classifier/js/rename.js b/classifier/js/rename.js new file mode 100644 index 0000000..10acb56 --- /dev/null +++ b/classifier/js/rename.js @@ -0,0 +1,83 @@ +/** + * ZDDC Classifier — in-place rename engine. + * + * Renames SOURCE files to their canonical ZDDC names, ON DISK, IN PLACE. + * DESTRUCTIVE — there is no backup. HTTP-backed handles (zddc-server) take the + * atomic server-side move (single round-trip); local File System Access handles + * copy+remove (the API has no native rename verb). The source folder is the only + * thing written. + * + * Resumable + boring: a file already at its target name is skipped, so a re-run + * after an interruption only renames what's left. One file in, one file out. + * + * Lifted out of the old Rename-in-place spreadsheet so the By-Tracking grid can + * drive the same, already-proven rename path. + */ +(function () { + 'use strict'; + + // The folder handle holding `file`. Fresh-scan files carry it; snapshot-loaded + // files resolve it (and their own handle) lazily from the workspace root. + async function folderHandleFor(file) { + if (file.folderHandle) return file.folderHandle; + if (window.app.modules.scanner && window.app.modules.scanner.resolveFileHandle && window.app.rootHandle) { + await window.app.modules.scanner.resolveFileHandle(window.app.rootHandle, file); + if (file.folderHandle) return file.folderHandle; + } + throw new Error('source folder not connected'); + } + + // Rename one file in place to `newName`. Returns 'renamed' | 'skipped'. + // Mutates the in-memory file object to its NEW identity (originalFilename / + // extension / handle) so the rest of the app sees the renamed file. + async function renameTo(file, newName) { + var oldName = window.zddc.joinExtension(file.originalFilename, file.extension); + if (oldName === newName) return 'skipped'; + if (file.isVirtual) throw new Error('cannot rename a file inside a ZIP — extract it first'); + + var folder = await folderHandleFor(file); + var perm = await folder.queryPermission({ mode: 'readwrite' }); + if (perm !== 'granted') { + var granted = await folder.requestPermission({ mode: 'readwrite' }); + if (granted !== 'granted') throw new Error('write permission denied'); + } + + var src = window.zddc.source; + if (src && src.isHttpHandle && src.isHttpHandle(folder)) { + var base = new URL(folder.url()).pathname; + await src.moveFile(base + encodeURIComponent(oldName), base + encodeURIComponent(newName)); + file.handle = await folder.getFileHandle(newName); + } else { + var oldHandle = await folder.getFileHandle(oldName); + var data = await oldHandle.getFile(); + var newHandle = await folder.getFileHandle(newName, { create: true }); + var w = await newHandle.createWritable(); + await w.write(data); + await w.close(); + await folder.removeEntry(oldName); + file.handle = newHandle; + } + + var split = window.zddc.splitExtension(newName); + file.originalFilename = split.name; + file.extension = split.extension; + return 'renamed'; + } + + // Rename a batch of { file, newName } items. Returns { renamed, skipped, errors }. + // onProgress(done, total, name) is called before each file. + async function runInPlace(items, onProgress) { + var s = { renamed: 0, skipped: 0, errors: 0 }; + for (var i = 0; i < items.length; i++) { + if (onProgress) onProgress(i + 1, items.length, items[i].newName); + try { s[await renameTo(items[i].file, items[i].newName)]++; } + catch (e) { + s.errors++; + if (window.zddc && window.zddc.toast) window.zddc.toast('Rename failed for ' + items[i].newName + ' — ' + (e.message || e), 'error'); + } + } + return s; + } + + window.app.modules.rename = { renameTo: renameTo, runInPlace: runInPlace }; +})(); diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index c77e61c..e3c657a 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -19,10 +19,7 @@ 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' | 'worklist' | 'transmittal' — active tab - var worklistGrid = null; // the seltable controller for the "From a list" tab - var worklistPlaced = {}; // trackingNumber -> placed files (read by the Files cell) - var hideAssigned = false; // "Hide assigned" toggle in the From-a-list toolbar + var currentTab = 'tracking'; // 'tracking' | 'transmittal' — active tab var listScanned = false; // a Load has run this session (drives the "new" badge) function init() { @@ -30,49 +27,43 @@ initialized = true; els = { trackingTab: document.getElementById('trackingTab'), - worklistTab: document.getElementById('worklistTab'), transmittalTab: document.getElementById('transmittalTab'), trackingPanel: document.getElementById('trackingPanel'), transmittalPanel: document.getElementById('transmittalPanel'), - worklistPanel: document.getElementById('worklistPanel'), trackingTree: document.getElementById('trackingTree'), transmittalTree: document.getElementById('transmittalTree'), - worklistTable: document.getElementById('worklistTable'), loadWorklistBtn: document.getElementById('loadWorklistBtn'), pasteRowsBtn: document.getElementById('pasteRowsBtn'), matchNamesBtn: document.getElementById('matchNamesBtn'), clearListBtn: document.getElementById('clearListBtn'), - hideAssignedToggle: document.getElementById('hideAssignedToggle'), + addFilteredBtn: document.getElementById('addFilteredBtn'), + renameBtn: document.getElementById('renameBtn'), trackingColsBtn: document.getElementById('trackingColsBtn'), addPartyBtn: document.getElementById('addPartyBtn'), stats: document.getElementById('classifyStats'), }; els.trackingTab.addEventListener('click', function () { showTab('tracking'); }); - if (els.worklistTab) els.worklistTab.addEventListener('click', function () { showTab('worklist'); }); els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); }); if (els.loadWorklistBtn) els.loadWorklistBtn.addEventListener('click', loadWorklist); if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); }); if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog); + if (els.addFilteredBtn) els.addFilteredBtn.addEventListener('click', addFilteredFiles); + if (els.renameBtn) els.renameBtn.addEventListener('click', renameInPlace); if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () { var list = C().getWorklist(); - if (!list.length) return; + if (!list.length) { window.zddc.toast('No list rows to clear.', 'info'); return; } // Warn before stranding files that still need a revision: they stay - // assigned (on a "pending" leaf under By tracking number), but the - // row you'd use to finish them here is about to disappear. + // assigned (on a "pending" leaf), but the placeholder row goes away. var pending = 0; list.forEach(function (r) { if (!(r.revisionCell || '').trim()) pending += Object.keys(r.placed || {}).length; }); - if (pending && !confirm(pending + ' file' + (pending === 1 ? '' : 's') + ' still need a revision. They stay assigned (a “pending” folder under By tracking number), but the list row to finish them here goes away. Clear anyway?')) return; + if (pending && !confirm(pending + ' file' + (pending === 1 ? '' : 's') + ' still need a revision. They stay assigned (a “pending” folder), but the list rows to finish them here go away. Clear anyway?')) return; C().clearWorklist(); - window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info'); + window.zddc.toast('List rows cleared — every assignment is kept.', 'info'); }); - if (els.hideAssignedToggle) els.hideAssignedToggle.addEventListener('change', function () { - hideAssigned = !!els.hideAssignedToggle.checked; - if (worklistGrid) worklistGrid.renderBody(); - }); - // Ctrl-V anywhere on the From-a-list panel opens the paste dialog prefilled. - if (els.worklistPanel) els.worklistPanel.addEventListener('paste', function (e) { - if (currentTab !== 'worklist') return; + // Ctrl-V on the By-tracking panel opens the paste dialog prefilled. + if (els.trackingPanel) els.trackingPanel.addEventListener('paste', function (e) { + if (currentTab !== 'tracking') 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') : ''; @@ -129,19 +120,16 @@ } function showTab(which) { - currentTab = (which === 'transmittal' || which === 'worklist') ? which : 'tracking'; + currentTab = (which === 'transmittal') ? 'transmittal' : 'tracking'; els.trackingTab.classList.toggle('active', currentTab === 'tracking'); - if (els.worklistTab) els.worklistTab.classList.toggle('active', currentTab === 'worklist'); els.transmittalTab.classList.toggle('active', currentTab === 'transmittal'); els.trackingPanel.hidden = currentTab !== 'tracking'; - if (els.worklistPanel) els.worklistPanel.hidden = currentTab !== 'worklist'; els.transmittalPanel.hidden = currentTab !== 'transmittal'; render(); // The source-tree Show filters are per-axis, so the visible set changes // with the active tab — re-render the left tree. reRenderSource(); } - // "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(); } @@ -153,21 +141,40 @@ var placed = buildPlaced(files); renderTrackingGrid(els.trackingTree); renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal); - renderWorklist(placed.byTracking); renderStats(files); } + // Files in the grid whose NAME is complete (tracking + rev + title) — the + // candidates for an in-place rename, regardless of transmittal. + function renameableFiles() { + var c = C(), out = []; + c.trackingGridKeys().forEach(function (k) { + var f = fileByKey(k); if (!f) return; + if (!nameErrors(c.deriveTarget(f)).length) out.push(f); + }); + return out; + } + function renderStats(files) { var s = C().stats(files); if (els.stats) { els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · ' + s.none + ' unassigned · ' + s.excluded + ' excluded'; } + // Copy… lives on the transmittal tab — enabled once files are fully done + // (tracking leaf AND transmittal). var copyBtn = document.getElementById('copyOutputBtn'); if (copyBtn) { copyBtn.disabled = s.done === 0; copyBtn.textContent = s.done ? ('Copy ' + s.done + '…') : 'Copy…'; } + // Rename… lives on the By-tracking tab — enabled once any grid file has a + // complete name (transmittal not required). + if (els.renameBtn) { + var n = renameableFiles().length; + els.renameBtn.disabled = n === 0; + els.renameBtn.textContent = n ? ('Rename ' + n + '…') : 'Rename…'; + } } function el(tag, cls, text) { @@ -244,12 +251,16 @@ // seltable's own persistKey storage (merged, not clobbered). ─────────── var GRID_COL_META = [ { id: 'status', title: 'Status', fixed: true }, // fixed = never hidden by the chooser - { id: 'orig', title: 'Original name' }, + { id: 'orig', title: 'Original / expected name' }, { id: 'tn', title: 'Tracking number' }, { id: 'rev', title: 'Rev (status)' }, { id: 'title', title: 'Title' }, + { id: 'src', title: 'Source', defaultHidden: true }, // folded in from "From a list" + { id: 'latest', title: 'Latest rev', defaultHidden: true }, { id: 'x', title: '', fixed: true }, ]; + // A column is hidden if the prefs say so explicitly, else by its defaultHidden. + function colHidden(meta, hidden) { return (meta.id in hidden) ? !!hidden[meta.id] : !!meta.defaultHidden; } var GRID_PREFS_KEY = 'zddc.classifier.trackingCols'; function gridPrefs() { try { return JSON.parse(localStorage.getItem(GRID_PREFS_KEY)) || {}; } catch (_) { return {}; } } function saveGridPrefs(p) { try { localStorage.setItem(GRID_PREFS_KEY, JSON.stringify(p)); } catch (_) { /* private mode */ } } @@ -273,6 +284,25 @@ // different tab, so its "not placed in a transmittal" error doesn't count here. function nameErrors(d) { return (d.errors || []).filter(function (e) { return e.indexOf('transmittal') === -1; }); } + // ── unified rows: file rows + placeholder rows ───────────────────────── + // A row is a boring 0-or-1-file thing: + // { kind:'file', file, wl:null, id:'f:'+srcKey } + // { kind:'placeholder', file:null, wl, id:'p:'+rowId } (a list row with no + // file yet — drop/match a file on it and it becomes a file row) + function joinName(f) { return f.originalFilename + (f.extension ? '.' + f.extension : ''); } + function isFile(row) { return row.kind === 'file'; } + function gridRows() { + var c = C(), out = []; + c.trackingGridKeys().forEach(function (k) { + var f = fileByKey(k); if (f) out.push({ kind: 'file', file: f, wl: null, id: 'f:' + k }); + }); + c.getWorklist().forEach(function (r) { + if (!Object.keys(r.placed || {}).length) out.push({ kind: 'placeholder', file: null, wl: r, id: 'p:' + r.id }); + }); + return out; + } + function sourceStr(r) { var s = r.source || {}; return [s.mdl ? 'mdl' : '', s.archive ? 'arch' : '', s.pasted ? 'pasted' : ''].filter(Boolean).join(' '); } + // ── per-column cell renderers ────────────────────────────────────────── function gridStatusCell(td, f) { var c = C(), key = c.srcKeyForFile(f), d = c.deriveTarget(f), conflict = c.hasHashConflict(key); @@ -281,63 +311,88 @@ badge.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content. ' : '') + (ne.length ? ne.join('; ') : 'Complete'); td.appendChild(badge); } + function gridPlaceholderStatus(td) { + var dot = el('span', 'tfile__badge tg-wanted', '◇'); + dot.title = 'Awaiting a file — drag one onto this row, or use ⚡ Match names.'; + td.appendChild(dot); + } function gridOrigCell(td, f) { - var key = C().srcKeyForFile(f); - var orig = f.originalFilename + (f.extension ? '.' + f.extension : ''); + var key = C().srcKeyForFile(f), orig = joinName(f); var link = el('a', 'tg-orig__link', orig); link.href = '#'; link.title = 'Preview ' + orig; link.addEventListener('click', function (e) { e.preventDefault(); previewKey(key); }); td.appendChild(link); } + function gridExpectedCell(td, wl) { + var name = (wl.currentName || '').trim(); + var span = el('span', 'tg-expected', name || '(drag a file here)'); + span.title = name ? ('Expected file: ' + name + ' — drag it on, or ⚡ Match names.') : 'Drag a file onto this row to name it.'; + td.appendChild(span); + } + // Editable cell for a FILE row → writes the file's identity (placement). function gridEditCell(td, colId, f) { var c = C(), key = c.srcKeyForFile(f), ident = currentIdent(f); var value = colId === 'tn' ? ident.tracking : colId === 'rev' ? ident.rev : ident.title; var ph = colId === 'tn' ? 'ACME-…-0001' : colId === 'rev' ? 'A (IFR)' : 'title'; var warn = colId === 'tn' ? gridTnWarn(ident.tracking) : ''; - var inp = el('input', 'tg-input' + (warn ? ' is-warn' : '')); - inp.type = 'text'; inp.value = value || ''; inp.placeholder = ph; inp.spellcheck = false; - inp.setAttribute('data-no-select', ''); // a click in the input must not toggle row selection - if (warn) inp.title = warn; - inp.addEventListener('change', function () { + editCell(td, 'tg-input', value, ph, function (v) { var cur = currentIdent(f); // re-read so a prior edit isn't clobbered - if (colId === 'tn') cur.tracking = inp.value.trim(); - else if (colId === 'rev') cur.rev = inp.value.trim(); - else cur.title = inp.value; + if (colId === 'tn') cur.tracking = v; + else if (colId === 'rev') cur.rev = v; + else cur.title = v; c.setFileIdentity(key, cur); - }); - td.appendChild(inp); + }, warn); + } + // Editable cell for a PLACEHOLDER row → writes the worklist row. + function gridRowEditCell(td, colId, wl) { + var c = C(); + if (colId === 'tn') editCell(td, 'tg-input', wl.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(wl.id, v); }, tnWarn(wl)); + else if (colId === 'rev') editCell(td, 'tg-input', wl.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(wl.id, v); }); + else editCell(td, 'tg-input', wl.title, 'title', function (v) { c.setRowTitle(wl.id, v); }); } function gridRemoveCell(td, f) { var c = C(), key = c.srcKeyForFile(f); - var rm = el('button', 'tnode__act tg-x__btn', '✕'); - rm.title = 'Remove from the grid'; + var rm = el('button', 'tnode__act tg-x__btn', '✕'); rm.title = 'Remove from the grid'; rm.addEventListener('click', function () { c.removeFromTrackingGrid(key); }); td.appendChild(rm); } + function gridRowRemoveCell(td, wl) { + var c = C(); + var rm = el('button', 'tnode__act tg-x__btn', '✕'); rm.title = 'Remove this list row (any assignments are kept)'; + rm.addEventListener('click', function () { c.removeWorklistRow(wl.id); }); + td.appendChild(rm); + } // Build the seltable column array, dropping any the chooser has hidden. Each - // column's `get` feeds sort + filter; `render` paints the (editable) cell. + // column's `get` feeds sort + filter; `render` paints the cell. get/render + // receive the unified row wrapper and dispatch on kind. function trackingColumns() { - var c = C(), hidden = (gridPrefs().hidden || {}); + var hidden = (gridPrefs().hidden || {}); var defs = { status: { key: 'status', title: 'Status', cls: 'tg-status', filterable: false, - get: function (f) { var d = c.deriveTarget(f); return c.hasHashConflict(c.srcKeyForFile(f)) ? 'conflict' : (nameErrors(d).length ? 'incomplete' : 'ok'); }, - render: function (f, td) { gridStatusCell(td, f); } }, - orig: { key: 'orig', title: 'Original name', cls: 'tg-orig', - get: function (f) { return f.originalFilename + (f.extension ? '.' + f.extension : ''); }, - render: function (f, td) { gridOrigCell(td, f); } }, + get: function (r) { if (!isFile(r)) return 'awaiting'; var d = C().deriveTarget(r.file); return C().hasHashConflict(C().srcKeyForFile(r.file)) ? 'conflict' : (nameErrors(d).length ? 'incomplete' : 'ok'); }, + render: function (r, td) { isFile(r) ? gridStatusCell(td, r.file) : gridPlaceholderStatus(td); } }, + orig: { key: 'orig', title: 'Original / expected name', cls: 'tg-orig', + get: function (r) { return isFile(r) ? joinName(r.file) : (r.wl.currentName || ''); }, + render: function (r, td) { isFile(r) ? gridOrigCell(td, r.file) : gridExpectedCell(td, r.wl); } }, tn: { key: 'tn', title: 'Tracking number', cls: 'tg-tn', - get: function (f) { return currentIdent(f).tracking; }, - render: function (f, td) { gridEditCell(td, 'tn', f); } }, + get: function (r) { return isFile(r) ? currentIdent(r.file).tracking : (r.wl.trackingNumber || ''); }, + render: function (r, td) { isFile(r) ? gridEditCell(td, 'tn', r.file) : gridRowEditCell(td, 'tn', r.wl); } }, rev: { key: 'rev', title: 'Rev (status)', cls: 'tg-rev', - get: function (f) { return currentIdent(f).rev; }, - render: function (f, td) { gridEditCell(td, 'rev', f); } }, + get: function (r) { return isFile(r) ? currentIdent(r.file).rev : (r.wl.revisionCell || ''); }, + render: function (r, td) { isFile(r) ? gridEditCell(td, 'rev', r.file) : gridRowEditCell(td, 'rev', r.wl); } }, title: { key: 'title', title: 'Title', cls: 'tg-title', - get: function (f) { return currentIdent(f).title; }, - render: function (f, td) { gridEditCell(td, 'title', f); } }, + get: function (r) { return isFile(r) ? currentIdent(r.file).title : (r.wl.title || ''); }, + render: function (r, td) { isFile(r) ? gridEditCell(td, 'title', r.file) : gridRowEditCell(td, 'title', r.wl); } }, + src: { key: 'src', title: 'Source', cls: 'worklist-src', sortable: false, + get: function (r) { return isFile(r) ? '' : sourceStr(r.wl); }, + render: function (r, td) { if (!isFile(r)) renderSource(r.wl, td); } }, + latest: { key: 'latest', title: 'Latest rev', cls: 'tg-latest', + get: function (r) { return isFile(r) ? '' : latestRevOf(r.wl.archiveRevisions); }, + render: function (r, td) { td.textContent = isFile(r) ? '' : latestRevOf(r.wl.archiveRevisions); } }, x: { key: 'x', title: '', cls: 'tg-x', sortable: false, filterable: false, - render: function (f, td) { gridRemoveCell(td, f); } }, + render: function (r, td) { isFile(r) ? gridRemoveCell(td, r.file) : gridRowRemoveCell(td, r.wl); } }, }; - return GRID_COL_META.filter(function (m) { return !hidden[m.id]; }).map(function (m) { return defs[m.id]; }); + return GRID_COL_META.filter(function (m) { return !colHidden(m, hidden); }).map(function (m) { return defs[m.id]; }); } var trackingGrid = null, trackingColSig = ''; @@ -348,23 +403,27 @@ trackingColSig = colSig(); trackingGrid = window.app.modules.seltable.create({ container: container, - rows: function () { return c.trackingGridKeys().map(fileByKey).filter(Boolean); }, - rowId: function (f) { return c.srcKeyForFile(f); }, + rows: gridRows, + rowId: function (r) { return r.id; }, columns: trackingColumns(), persistKey: GRID_PREFS_KEY, + onRowDrop: function (id, keys) { + if (id.indexOf('p:') === 0) fillFromRow(id, keys); // placeholder → bind, fan out over consecutive placeholders + else onGridDrop(keys); // file row → just add the dragged files + }, }); trackingGrid.render(); return trackingGrid; } function renderTrackingGrid(container) { // Empty ↔ populated transition: tear the seltable down for the prompt, - // re-create it (create-once) when files arrive — same as the worklist. - if (!C().trackingGridKeys().length) { + // re-create it (create-once) when rows arrive. + if (!gridRows().length) { trackingGrid = null; container.textContent = ''; container.classList.remove('seltable'); container.appendChild(el('div', 'target-empty', - 'No files yet — drag files here from the left, then type each one’s tracking number, revision, and title. A file that’s already ZDDC-named fills in automatically.')); + 'No files yet — drag files in (or “⊕ Add filtered files”), or “⊞ Load…” / “⎘ Paste rows…” a list of tracking numbers and drop the matching files on. A file that’s already ZDDC-named fills in automatically.')); return; } // The Columns ▾ chooser changes which columns exist → rebuild on mismatch @@ -375,6 +434,19 @@ // (setFilter re-renders the body, so the rows are always fresh on render). trackingGrid.setFilter(rfTerms.join(' ')); } + // Drop N dragged files onto a starting placeholder row → bind file[i] to the + // i-th consecutive PLACEHOLDER row from there (Excel-style column fill). A + // single file just binds to the one row. + function fillFromRow(startId, keys) { + var c = C(); + var rows = trackingGrid ? trackingGrid.getFilteredRows() : gridRows(); + var placeholders = rows.filter(function (r) { return r.kind === 'placeholder'; }); + var start = placeholders.map(function (r) { return r.id; }).indexOf(startId); + if (start < 0) { var row0 = c.getWorklistRow(startId.slice(2)); if (row0) c.assignFromRow(keys, row0); return; } + for (var i = 0; i < keys.length && (start + i) < placeholders.length; i++) { + c.assignFromRow([keys[i]], placeholders[start + i].wl); + } + } // Drag files onto the grid → add as rows; auto-fill any already ZDDC-named. function setupGridDrop(container) { @@ -409,10 +481,10 @@ GRID_COL_META.forEach(function (col) { if (col.fixed) return; var lbl = el('label', 'col-chooser__item'); - var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !hidden[col.id]; + var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !colHidden(col, hidden); cb.addEventListener('change', function () { var p = gridPrefs(); p.hidden = p.hidden || {}; - if (cb.checked) delete p.hidden[col.id]; else p.hidden[col.id] = true; + p.hidden[col.id] = !cb.checked; // explicit (overrides defaultHidden) saveGridPrefs(p); trackingGrid = null; // column set changed → rebuild the seltable render(); @@ -518,54 +590,53 @@ return form; } - // ── "From a list" (scratch worklist via the shared seltable) ──────────── - function renderWorklist(placedByTracking) { - worklistPlaced = placedByTracking || {}; - if (!C().getWorklist().length) { - worklistGrid = null; - els.worklistTable.textContent = ''; - els.worklistTable.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).')); + function setGridStatus(text) { + var s = document.getElementById('scanStatus'); + if (s) { s.textContent = text; s.classList.toggle('scanning', !!text); } + } + + // "⊕ Add filtered files" — pull every file the LEFT tree filter currently + // shows into the grid (across collapsed folders too); already-ZDDC-named files + // fill in automatically (onGridDrop does the parse). + function addFilteredFiles() { + var tree = window.app.modules.tree; + var files = (tree && tree.filteredFiles) ? tree.filteredFiles() : []; + if (!files.length) { window.zddc.toast('No files to add — the tree filter shows none.', 'info'); return; } + onGridDrop(files.map(function (f) { return C().srcKeyForFile(f); })); + window.zddc.toast('Added ' + files.length + ' file' + (files.length === 1 ? '' : 's') + ' to the grid.', 'success'); + } + + // "Rename…" — rename the grid's NAME-COMPLETE files ON DISK, in place. + // DESTRUCTIVE: no backup. A renamed file is now correctly named, so it leaves + // the grid (forgetFile). Resumable: already-correct files are skipped. + async function renameInPlace() { + var c = C(); + var ready = renameableFiles(); + var items = ready.map(function (f) { + return { file: f, oldKey: c.srcKeyForFile(f), newName: c.deriveTarget(f).filename }; + }).filter(function (x) { return x.newName && x.newName !== joinName(x.file); }); + if (!items.length) { + window.zddc.toast(ready.length ? 'Those files are already correctly named.' : 'No name-complete files to rename — fill in tracking number, revision and title.', 'info'); return; } - ensureWorklistGrid(); - worklistGrid.renderBody(); - } - function rowPlaced(r) { var f = worklistPlaced[r.trackingNumber]; return f && f.length ? f : null; } - function ensureWorklistGrid() { - if (worklistGrid) return worklistGrid; - var c = C(); - var cols = [ - { key: 'tn', title: 'Tracking number', cls: 'worklist-tn', get: function (r) { return r.trackingNumber || ''; }, - render: function (r, td) { editCell(td, 'worklist-tn__input', r.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(r.id, v); }, tnWarn(r)); } }, - { key: 'title', title: 'Title', cls: 'worklist-title', get: function (r) { return r.title || ''; }, - render: function (r, td) { editCell(td, 'worklist-title__input', r.title, 'title', function (v) { c.setRowTitle(r.id, v); }); } }, - { key: 'cur', title: 'Current name', cls: 'worklist-cur', get: function (r) { return r.currentName || ''; } }, - { key: 'src', title: 'Source', cls: 'worklist-src', sortable: false, filterable: false, - 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: 'worklist-rev', get: function (r) { return r.revisionCell; }, - render: function (r, td) { editCell(td, 'worklist-rev__input', r.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(r.id, v); }); } }, - ]; - worklistGrid = window.app.modules.seltable.create({ - container: els.worklistTable, - extraTitle: 'Files', - rows: function () { - var list = c.getWorklist(); - return hideAssigned ? list.filter(function (r) { return !rowPlaced(r); }) : list; - }, - rowId: function (r) { return r.id; }, - columns: cols, - onRowDrop: function (rowId, keys) { var row = c.getWorklistRow(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)"):', ''); - if (v != null) c.setRevisionCells(ids, v.trim()); - }, - rowExtra: function (r, td) { renderWorklistFiles(r, td); }, - }); - worklistGrid.render(); - return worklistGrid; + var preview = items.slice(0, 4).map(function (x) { return ' ' + joinName(x.file) + ' → ' + x.newName; }).join('\n'); + var msg = '⚠ RENAME ' + items.length + ' FILE' + (items.length === 1 ? '' : 'S') + ' IN PLACE — this EDITS YOUR SOURCE FILES on disk.\n\n' + + 'There is NO backup and it cannot be undone. Renamed files are now correctly named, so they leave the grid.\n\n' + + preview + (items.length > 4 ? ('\n …and ' + (items.length - 4) + ' more') : '') + + '\n\nRename these files in place now?'; + if (!confirm(msg)) return; + setGridStatus('Renaming…'); + var done = 0, errors = 0; + for (var i = 0; i < items.length; i++) { + setGridStatus('Renaming… ' + (i + 1) + '/' + items.length + ' — ' + items[i].newName); + try { await window.app.modules.rename.renameTo(items[i].file, items[i].newName); c.forgetFile(items[i].oldKey); done++; } + catch (e) { errors++; window.zddc.toast('Rename failed for ' + items[i].newName + ' — ' + (e.message || e), 'error'); } + } + setGridStatus(''); + render(); + reRenderSource(); + window.zddc.toast('Renamed ' + done + ' file' + (done === 1 ? '' : 's') + ' in place' + + (errors ? (', ' + errors + ' failed (retry)') : '') + '. Source files updated.', errors ? 'warning' : 'success'); } // An editable seltable cell: an that commits on change. `warn` is an // optional tooltip that flags (without blocking) a questionable value. @@ -596,30 +667,6 @@ td.appendChild(el('span', 'src-badge src-badge--pasted', 'pasted')); } } - function renderWorklistFiles(row, td) { - var c = C(), files = rowPlaced(row) || []; - files.forEach(function (f) { - var d = c.deriveTarget(f); - var a = c.getAssignment(d.key) || {}; - var line = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : '')); - line.dataset.key = d.key; line.draggable = true; - line.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); }); - var nm = el('span', 'mdlfile__name', d.filename || '(set a revision)'); - nm.title = 'from ' + f.originalFilename + (f.extension ? '.' + f.extension : ''); - line.appendChild(nm); - 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 this file from the row'; - rm.addEventListener('click', function () { c.unassignRowFile(row, d.key); }); - line.appendChild(rm); - td.appendChild(line); - }); - } - // "From a list" loader: "Load…" opens a multi-select directory tree (scoped // to the served context); every ticked directory is walked recursively into // the union of existing files + MDL deliverables, deduped by tracking number @@ -719,7 +766,7 @@ function finishLoad(rows) { listScanned = true; C().appendWorklist(rows); // APPEND — the list accumulates across batches - showTab('worklist'); + showTab('tracking'); window.zddc.toast(rows.length ? ('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'); @@ -800,7 +847,7 @@ add.addEventListener('click', function () { var n = parsed.rows.length; c.appendWorklist(parsed.rows); - m.close(); showTab('worklist'); + m.close(); showTab('tracking'); var assigned = autoAssignByName(); var msg = 'Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.'; if (assigned) msg += ' Auto-assigned ' + assigned + ' file' + (assigned === 1 ? '' : 's') + ' by current name.'; @@ -852,7 +899,7 @@ var p = proposals[Number(cb.dataset.i)]; if (p) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; } }); - m.close(); showTab('worklist'); + m.close(); showTab('tracking'); window.zddc.toast('Assigned ' + n + ' file' + (n === 1 ? '' : 's') + ' by name match.', n ? 'success' : 'info'); }); fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); }); diff --git a/classifier/js/tree.js b/classifier/js/tree.js index d372f02..62fb6db 100644 --- a/classifier/js/tree.js +++ b/classifier/js/tree.js @@ -1005,6 +1005,7 @@ setShowFilters, setNameFilter, exportFilteredList, + filteredFiles: filteredFileObjects, _buildExportTsv: buildExportTsv }; })(); diff --git a/classifier/template.html b/classifier/template.html index efdd722..0de033f 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -30,10 +30,6 @@ -
- - -
@@ -96,81 +92,15 @@
- -
-
-
-

Files

-
- 0 files - 0 modified - -
-
-
- - - | - - - | - -
-
-
- - - - - - - - - - - - - - - - - -
#Original Filename - - Ext - - New Filename - - Tracking - - Rev - - Status - - Title - -
-
-
- - +
-

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.

+

Give each file a tracking number (revision + status + title) under By tracking number — that alone is a Rename in place. Route it into a transmittal folder under By transmittal to Copy it into the archive.

-
- - -
- -
- -
+ +
@@ -179,15 +109,19 @@ - -
+ + + + + - Drag files in, then type each one’s tracking number, revision (e.g. “A (IFR)”), and title. A file that’s already ZDDC-named fills in automatically. Columns are hideable + resizable. + Drag files in (ctrl-shift-click the left tree to multi-select; drop on a row to fill a contiguous block), or “Add filtered files”. Type the tracking number, revision (“A (IFR)”) and title — already-ZDDC-named files fill in. “Load…” / “Paste rows…” add rows to drop files onto. +
@@ -196,28 +130,14 @@ - -
@@ -247,26 +167,16 @@
- +
-

① Classify & copy recommended · non-destructive

-

Build a tidy copy of a project in a separate output folder. Your source files are only ever read, never renamed or moved.

+

Classify, then Rename or Copy

+

Give each file a ZDDC name in the By tracking number grid. A tracking number alone is a rename; add a transmittal folder and it can be copied into the archive.

  1. New workspace → pick a folder. It scans once and saves to this browser, so you can close the tab and pick up later.
  2. -
  3. Preview a file (single-click it in the left tree) to see what it actually is.
  4. -
  5. Drag it onto the right pane — onto a tracking-number folder (the folder path becomes the number, the leaf is the revision, e.g. A (IFR)), and onto a transmittal (party + date + TRN/SUB + sequence).
  6. -
  7. Copy when ready → choose an output directory; renamed copies are written as <party>/<transmittal>/<name>, with duplicates detected.
  8. -
-
-
-

② Rename in place edits your files

-

A quick spreadsheet for a folder you own: fill in tracking number, revision, status and title, and rename the files on disk.

-
    -
  1. Click Use Local Directory (top bar) to open a folder.
  2. -
  3. Switch the toggle to Rename in place.
  4. -
  5. Edit cells (or paste columns from Excel); names already in ZDDC format are parsed automatically and validated as you type.
  6. -
  7. Save All renames the files where they sit.
  8. +
  9. Add files to the grid — drag them from the left tree (ctrl-shift-click to multi-select and fill a block of rows), use ⊕ Add filtered files, or ⊞ Load… / ⎘ Paste rows… a list of expected tracking numbers and drop the matching files on. Already-ZDDC-named files fill in automatically.
  10. +
  11. Type each file's tracking number, revision (e.g. A (IFR)) and title.
  12. +
  13. Rename… (By tracking number) renames the named files in place on disk — destructive, no backup. Or place them into a transmittal under By transmittal and Copy… them into the archive (source untouched, resumable, verified).
diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 77db587..28f409c 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -143,17 +143,20 @@ test('exclude clears placements and reports excluded state', async ({ page }) => // ── Phase 2: mode toggle + target-tree rendering (UI) ────────────────────── -test('mode switch swaps the spreadsheet pane for the target pane', async ({ page }) => { - await page.click('#modeClassifyBtn'); +test('the classify surface is the single pane — no mode toggle, no spreadsheet', async ({ page }) => { + await page.evaluate(() => window.app.modules.app.setMode()); expect(await page.locator('#targetPane').isHidden()).toBe(false); - expect(await page.locator('#spreadsheetPane').isHidden()).toBe(true); - await page.click('#modeRenameBtn'); - expect(await page.locator('#targetPane').isHidden()).toBe(true); - expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false); + expect(await page.locator('#modeClassifyBtn').count()).toBe(0); // toggle removed + expect(await page.locator('#modeRenameBtn').count()).toBe(0); + expect(await page.locator('#spreadsheetPane').count()).toBe(0); // spreadsheet pane removed + expect(await page.locator('#worklistTab').count()).toBe(0); // From-a-list folded in + // Two tabs only: By tracking number + By transmittal. + await expect(page.locator('#trackingTab')).toBeVisible(); + await expect(page.locator('#transmittalTab')).toBeVisible(); }); test('target tree renders the By-tracking grid and tabs switch', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); @@ -176,7 +179,7 @@ test('target tree renders the By-tracking grid and tabs switch', async ({ page } // ── Phase 3: drag-and-drop assignment (drop handler) ─────────────────────── test('dropping files onto the By-tracking grid adds rows and auto-fills ZDDC names', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); const plain = { originalFilename: 'messy scan', extension: 'pdf', folderPath: 'R' }; @@ -200,7 +203,7 @@ test('dropping files onto the By-tracking grid adds rows and auto-fills ZDDC nam }); test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); await page.click('#transmittalTab'); const r = await page.evaluate(() => { const c = window.app.modules.classify; @@ -232,7 +235,7 @@ test('dropping onto a transmittal bin assigns; dropping on a party row does not' // Inject a synthetic scanned tree (no FS Access needed) and render it. async function withSourceTree(page) { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); await page.evaluate(() => { window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', @@ -250,7 +253,7 @@ test('source file rows render with a state dot in classify mode', async ({ page }); test('Folder Tree renders folders and files in natural, case-insensitive order', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const order = await page.evaluate(() => { window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', @@ -277,7 +280,7 @@ test('Folder Tree renders folders and files in natural, case-insensitive order', }); test('classify: single-click a source file triggers preview', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const previewed = await page.evaluate(() => { let got = null; window.app.modules.preview.previewFile = (f) => { got = f.originalFilename; }; // capture, skip popup @@ -293,7 +296,7 @@ test('classify: single-click a source file triggers preview', async ({ page }) = }); test('classify: a folder with files but no subfolders is expandable (drag source)', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); await page.evaluate(() => { window.app.folderTree = [{ name: 'Leaf', path: 'Leaf', expanded: false, scanState: 'done', @@ -352,7 +355,7 @@ test('cross-tree reveal: source→target switches to the placed axis', async ({ // ── Phase 5: copy-out engine + duplicate detection (mock FS handles) ─────── test('copy: writes the file, then resumes by skipping an existing target', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const res = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; const store = {}; @@ -392,7 +395,7 @@ test('copy: writes the file, then resumes by skipping an existing target', async }); test('copy: two sources mapping to the same output path are a conflict', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const conflicts = await page.evaluate(() => { const c = window.app.modules.classify, copy = window.app.modules.copy; const srcFile = (name, folder) => { @@ -507,7 +510,7 @@ test('persist: classify-only autosave preserves the stored snapshot', async ({ p }); test('copy: snapshot files (no handle) resolve from the workspace root', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const res = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; const srcStore = { 'Sub/foundation.pdf': 'AAA' }; @@ -583,7 +586,7 @@ test('expandFolderPattern: alternation, zero-padded ranges, cartesian product', }); test('Hide Assigned: hides files dealt-with on the active axis and folders left empty', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const before = await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); @@ -666,7 +669,7 @@ test('trackingNodeComplete: true only for a leaf with a valid status', async ({ }); test('editing grid cells re-files the file onto the new tracking path', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; c.reset(); @@ -734,7 +737,7 @@ test('dataset (filename-based): import reconstruction rebuilds tracking + shared }); test('source-tree filter hides non-matches in place; never changes expand state', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { window.app.folderTree = [{ name: 'Project', path: 'Project', expanded: true, scanState: 'done', files: [], children: [ @@ -765,7 +768,7 @@ test('source-tree filter hides non-matches in place; never changes expand state' }); test('the By-tracking grid filter narrows rows by name/tracking', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; c.reset(); @@ -783,7 +786,7 @@ test('the By-tracking grid filter narrows rows by name/tracking', async ({ page }); test('Show Empty off hides folders that contain no files', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { window.app.folderTree = [ { name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [], @@ -801,7 +804,7 @@ test('Show Empty off hides folders that contain no files', async ({ page }) => { }); test('toggling a Show filter preserves collapse state (no force-expand)', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { window.app.folderTree = [{ name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [ @@ -828,7 +831,7 @@ test('toggling a Show filter preserves collapse state (no force-expand)', async }); test('filter does not open collapsed branches; non-matching siblings hide', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { window.app.folderTree = [{ name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [ @@ -856,7 +859,7 @@ test('filter does not open collapsed branches; non-matching siblings hide', asyn }); test('folder count badge shows post-filter totals', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', @@ -916,7 +919,7 @@ test('snapshot: a scanned zip subtree round-trips with its virtual members', asy }); test('copy: a zip member is extracted from its archive and written out', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const res = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; const f = { @@ -985,7 +988,7 @@ test('workspace: import recreates a transferable record (snapshot + map, no hand }); test('zip mode: collapse turns an expanded archive back into one .zip file', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify, sc = window.app.modules.scanner; const member = { originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip', isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf' }; @@ -1011,7 +1014,7 @@ test('zip mode: collapse turns an expanded archive back into one .zip file', asy }); test('a fully-excluded folder is struck through like its files', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify, tree = window.app.modules.tree; const f1 = { originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' }; @@ -1030,7 +1033,7 @@ test('a fully-excluded folder is struck through like its files', async ({ page } }); test('grid: hiding a column drops its cells; a status badge reflects completeness', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; c.reset(); @@ -1054,7 +1057,7 @@ test('grid: hiding a column drops its cells; a status badge reflects completenes }); test('grid: the original-name cell is a preview link', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; c.reset(); @@ -1073,7 +1076,7 @@ test('grid: the original-name cell is a preview link', async ({ page }) => { }); test('Show Partial surfaces files assigned in the other tab only', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify, tree = window.app.modules.tree, tt = window.app.modules.targetTree; c.reset(); @@ -1094,7 +1097,7 @@ test('Show Partial surfaces files assigned in the other tab only', async ({ page }); test('copy: PUTs into a server-style handle, then resumes by skipping existing', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; c.reset(); @@ -1131,7 +1134,7 @@ test('copy: PUTs into a server-style handle, then resumes by skipping existing', }); test('copy audit: same name+rev — identical content dedups, different content conflicts', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; c.reset(); @@ -1168,7 +1171,7 @@ test('copy audit: same name+rev — identical content dedups, different content }); test('copy: verifies copied bytes; a bad write fails verification and is removed', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(async () => { const c = window.app.modules.classify, copy = window.app.modules.copy; c.reset(); @@ -1202,7 +1205,7 @@ test('copy: verifies copied bytes; a bad write fails verification and is removed }); test('transmittal: rename a bin (feeds the folder), remove and move a placed file', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(async () => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; c.reset(); @@ -1361,7 +1364,7 @@ test('seltable: dragging the header resizer widens a column and persists via per }); test('From a list: a drop materializes a real tracking placement; row revision + transmittal complete it', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); @@ -1398,7 +1401,7 @@ test('From a list: a drop materializes a real tracking placement; row revision + }); test('From a list: clearing the list keeps classifications; the row drives the seltable', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; c.reset(); @@ -1409,15 +1412,14 @@ test('From a list: clearing the list keeps classifications; the row drives the s { 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('worklist'); - const row = document.querySelector('#worklistTable .seltable__row[data-id="m1"]'); - const latestShown = !!row && row.textContent.includes('B (IFC)'); // latest archive rev shown + tt.render(); // placeholder rows live in the unified By-tracking grid + const row = document.querySelector('#trackingTree .seltable__row[data-id="p:m1"]'); window.app.modules.dnd.setDrag([key]); - row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop on m1 (rev C set) + if (row) row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop on m1 (rev C set) const named = c.deriveTarget(f).filename; c.clearWorklist(); // list emptied — assignment must survive return { - hasRow: !!row, latestShown, + hasRow: !!row, placedAfterDrop: !!(c.getAssignment(key) || {}).trackingNodeId, named, listLen: c.getWorklist().length, @@ -1426,7 +1428,6 @@ test('From a list: clearing the list keeps classifications; the row drives the s }; }); expect(r.hasRow).toBe(true); - 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 @@ -1435,7 +1436,7 @@ test('From a list: clearing the list keeps classifications; the row drives the s }); test('From a list: editing the tracking number (bump sequence) re-stamps placed files', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); @@ -1458,7 +1459,7 @@ test('From a list: editing the tracking number (bump sequence) re-stamps placed }); test('From a list: load() migrates a legacy mdlNodeId placement into a tracking placement', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); @@ -1483,7 +1484,7 @@ test('From a list: load() migrates a legacy mdlNodeId placement into a tracking }); test('parsePastedRows: fixed columns tracking · rev · title · current name', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify; const text = [ @@ -1501,7 +1502,7 @@ test('parsePastedRows: fixed columns tracking · rev · title · current name', }); test('proposeMatches: the current-name column drives exact (auto) + token matches', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify; const files = [ @@ -1524,7 +1525,7 @@ test('proposeMatches: the current-name column drives exact (auto) + token matche }); test('proposeMatches: ambiguous duplicate current-name is not auto-assigned', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify; const files = [ @@ -1540,7 +1541,7 @@ test('proposeMatches: ambiguous duplicate current-name is not auto-assigned', as }); test('proposeMatches finds a row whose tracking number is in the filename', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify; const files = [ @@ -1562,7 +1563,7 @@ test('proposeMatches finds a row whose tracking number is in the filename', asyn }); test('From a list: walkDirInto unions files + mdl deliverables, deduped to the latest revision', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(async () => { const tt = window.app.modules.targetTree; function fdir(name, children) { @@ -1647,7 +1648,7 @@ test('From a list: dir-picker resolves the topmost ticked directories only', asy // ── Export filtered list → Excel round-trip (path + file TSV) ────────────── test('export: filtered file list → TSV (path + file), includes collapsed folders', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify, tree = window.app.modules.tree; c.reset(); @@ -1680,7 +1681,7 @@ test('export: filtered file list → TSV (path + file), includes collapsed folde }); test('paste rows: a full-path Current name binds that exact file directly', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify; c.reset(); @@ -1709,7 +1710,7 @@ test('paste rows: a full-path Current name binds that exact file directly', asyn }); test('paste rows: a full path with no tracking yet is claimed, then placed when tracking lands', async ({ page }) => { - await page.click('#modeClassifyBtn'); + await page.evaluate(() => window.app.modules.app.setMode()); const r = await page.evaluate(() => { const c = window.app.modules.classify; c.reset();