feat(classifier): one classify surface — By-tracking grid with Rename, transmittal with Copy

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-16 09:53:04 -05:00
parent ca1452dde1
commit f82d6919b4
9 changed files with 388 additions and 350 deletions

View file

@ -64,6 +64,7 @@ concat_files \
"js/dir-picker.js" \ "js/dir-picker.js" \
"js/target-tree.js" \ "js/target-tree.js" \
"js/copy.js" \ "js/copy.js" \
"js/rename.js" \
"js/spreadsheet.js" \ "js/spreadsheet.js" \
"js/selection.js" \ "js/selection.js" \
"js/preview.js" \ "js/preview.js" \

View file

@ -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 { color: var(--text-muted); white-space: nowrap; text-decoration: none; cursor: pointer; }
.tg-orig__link:hover { text-decoration: underline; } .tg-orig__link:hover { text-decoration: underline; }
.tg-status, .tg-x { text-align: center; } .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; } .tg-x__btn { opacity: 0.5; }
.seltable__row:hover .tg-x__btn { opacity: 1; } .seltable__row:hover .tg-x__btn { opacity: 1; }
.tg-drop-hover { outline: 2px dashed var(--primary); outline-offset: -3px; background: var(--primary-light); } .tg-drop-hover { outline: 2px dashed var(--primary); outline-offset: -3px; background: var(--primary-light); }

View file

@ -193,22 +193,19 @@
* onto target trees, copy renamed copies out). The source tree (left) stays * onto target trees, copy renamed copies out). The source tree (left) stays
* in both modes; only the right pane swaps. * in both modes; only the right pane swaps.
*/ */
function setMode(mode) { // There is only one surface now (the classify grid + transmittal tree); the
const classify = mode === 'classify'; // old Rename-in-place spreadsheet was folded into the By-tracking grid's
app.dom.modeRenameBtn.classList.toggle('active', !classify); // "Rename…" action. setMode is kept as a no-arg enabler for back-compat with
app.dom.modeClassifyBtn.classList.toggle('active', classify); // the workspace/open flows that call it.
if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify; function setMode() {
if (app.dom.targetPane) app.dom.targetPane.hidden = !classify; if (app.dom.targetPane) app.dom.targetPane.hidden = false;
// Mode-specific source-tree filters: "Hide Compliant" is for the rename if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = false;
// grid; "Hide Assigned" is for the classify workflow. app.modules.classify.setEnabled(true);
if (app.dom.hideCompliantLabel) app.dom.hideCompliantLabel.hidden = classify; if (app.modules.targetTree) {
if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = !classify;
app.modules.classify.setEnabled(classify);
if (classify && app.modules.targetTree) {
app.modules.targetTree.init(); app.modules.targetTree.init();
app.modules.targetTree.render(); 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(); if (app.modules.tree) app.modules.tree.render();
} }
@ -339,18 +336,9 @@
// Drag and drop on welcome screen // Drag and drop on welcome screen
setupWelcomeDragDrop(); setupWelcomeDragDrop();
// Bulk actions // (The old Rename-in-place spreadsheet — Save All / Cancel All / SHA256 /
app.dom.saveAllBtn.addEventListener('click', handleSaveAll); // Export hashes — was removed; its rename is now the By-tracking "Rename…".)
app.dom.cancelAllBtn.addEventListener('click', handleCancelAll); if (app.dom.hideCompliantCheckbox) app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
// 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);
// Classify-mode source-tree filters: show/hide unassigned, assigned, excluded. // Classify-mode source-tree filters: show/hide unassigned, assigned, excluded.
function pushClassifyFilters() { function pushClassifyFilters() {
@ -375,9 +363,6 @@
// Collapse tree button // Collapse tree button
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree); 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.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(); }); if (app.dom.checkDuplicatesBtn) app.dom.checkDuplicatesBtn.addEventListener('click', function () { app.modules.copy.audit(); });
@ -543,12 +528,7 @@
showMainUI(); showMainUI();
if (!shellInited) { if (!shellInited) {
shellInited = true; shellInited = true;
app.modules.spreadsheet.init(); // Subscribe to store app.modules.preview.init(); // file preview (click a row / original-name link)
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.tree.setupKeyboardShortcuts(); app.modules.tree.setupKeyboardShortcuts();
if (app.modules.targetTree) app.modules.targetTree.init(); if (app.modules.targetTree) app.modules.targetTree.init();
} }
@ -558,9 +538,8 @@
async function openDirectory(dirHandle) { async function openDirectory(dirHandle) {
app.rootHandle = dirHandle; app.rootHandle = dirHandle;
enterAppShell(); enterAppShell();
// Default to Classify & Copy (the primary workflow). The user can switch setMode(); // the single classify surface
// to "Rename in place" via the toggle for the spreadsheet.
setMode('classify');
// Now scan directory (this will trigger store updates and renders) // Now scan directory (this will trigger store updates and renders)
await app.modules.scanner.scanDirectory(dirHandle); await app.modules.scanner.scanDirectory(dirHandle);
} }
@ -669,18 +648,8 @@
* Handle keyboard shortcuts * Handle keyboard shortcuts
*/ */
function handleKeyDown(e) { function handleKeyDown(e) {
// Ctrl+S - Save All // (Spreadsheet Ctrl+S / Escape handlers removed with the Rename-in-place
if (e.ctrlKey && e.key === 's') { // pane. The By-tracking grid commits edits on change.)
e.preventDefault();
if (!app.dom.saveAllBtn.disabled) {
handleSaveAll();
}
}
// Escape - Cancel editing
if (e.key === 'Escape') {
app.modules.spreadsheet.cancelEditing();
}
} }
/** /**
@ -712,6 +681,7 @@
* Update stats display * Update stats display
*/ */
function updateStats() { function updateStats() {
if (!app.dom.totalFiles) return; // spreadsheet pane removed — nothing to update
const files = app.modules.store.getDisplayFiles(); const files = app.modules.store.getDisplayFiles();
const totalFiles = files.length; const totalFiles = files.length;
const modifiedFiles = files.filter(f => f.isDirty).length; const modifiedFiles = files.filter(f => f.isDirty).length;

View file

@ -493,6 +493,19 @@
Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].trackingNodeId) set[k] = true; }); Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].trackingNodeId) set[k] = true; });
return Object.keys(set); 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 // 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 // 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 // from deriveTarget — so this module needs no file objects. A blank revision
@ -578,6 +591,13 @@
notify(); notify();
} }
function clearWorklist() { state.worklist = []; notify(); } // rows only — assignments survive 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 getWorklist() { return state.worklist; }
function getWorklistRow(id) { return state.worklist.filter(function (r) { return r.id === id; })[0] || null; } 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, getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
// By-tracking grid // By-tracking grid
addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid, addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid,
trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity, trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity, forgetFile: forgetFile,
setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist, setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist,
removeWorklistRow: removeWorklistRow,
getWorklist: getWorklist, getWorklistRow: getWorklistRow, getWorklist: getWorklist, getWorklistRow: getWorklistRow,
assignFromRow: assignFromRow, unassignRowFile: unassignRowFile, assignFromRow: assignFromRow, unassignRowFile: unassignRowFile,
setRowTracking: setRowTracking, setRowTitle: setRowTitle, setRowTracking: setRowTracking, setRowTitle: setRowTitle,

83
classifier/js/rename.js Normal file
View file

@ -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 };
})();

View file

@ -19,10 +19,7 @@
var collapsed = {}; // nodeId -> true when collapsed (default expanded) var collapsed = {}; // nodeId -> true when collapsed (default expanded)
var openForm = null; // { partyId, slot } when a bin form is open var openForm = null; // { partyId, slot } when a bin form is open
var initialized = false; var initialized = false;
var currentTab = 'tracking'; // 'tracking' | 'worklist' | 'transmittal' — active tab var currentTab = 'tracking'; // 'tracking' | '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 listScanned = false; // a Load has run this session (drives the "new" badge) var listScanned = false; // a Load has run this session (drives the "new" badge)
function init() { function init() {
@ -30,49 +27,43 @@
initialized = true; initialized = true;
els = { els = {
trackingTab: document.getElementById('trackingTab'), trackingTab: document.getElementById('trackingTab'),
worklistTab: document.getElementById('worklistTab'),
transmittalTab: document.getElementById('transmittalTab'), transmittalTab: document.getElementById('transmittalTab'),
trackingPanel: document.getElementById('trackingPanel'), trackingPanel: document.getElementById('trackingPanel'),
transmittalPanel: document.getElementById('transmittalPanel'), transmittalPanel: document.getElementById('transmittalPanel'),
worklistPanel: document.getElementById('worklistPanel'),
trackingTree: document.getElementById('trackingTree'), trackingTree: document.getElementById('trackingTree'),
transmittalTree: document.getElementById('transmittalTree'), transmittalTree: document.getElementById('transmittalTree'),
worklistTable: document.getElementById('worklistTable'),
loadWorklistBtn: document.getElementById('loadWorklistBtn'), loadWorklistBtn: document.getElementById('loadWorklistBtn'),
pasteRowsBtn: document.getElementById('pasteRowsBtn'), pasteRowsBtn: document.getElementById('pasteRowsBtn'),
matchNamesBtn: document.getElementById('matchNamesBtn'), matchNamesBtn: document.getElementById('matchNamesBtn'),
clearListBtn: document.getElementById('clearListBtn'), clearListBtn: document.getElementById('clearListBtn'),
hideAssignedToggle: document.getElementById('hideAssignedToggle'), addFilteredBtn: document.getElementById('addFilteredBtn'),
renameBtn: document.getElementById('renameBtn'),
trackingColsBtn: document.getElementById('trackingColsBtn'), trackingColsBtn: document.getElementById('trackingColsBtn'),
addPartyBtn: document.getElementById('addPartyBtn'), addPartyBtn: document.getElementById('addPartyBtn'),
stats: document.getElementById('classifyStats'), stats: document.getElementById('classifyStats'),
}; };
els.trackingTab.addEventListener('click', function () { showTab('tracking'); }); els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
if (els.worklistTab) els.worklistTab.addEventListener('click', function () { showTab('worklist'); });
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); }); els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
if (els.loadWorklistBtn) els.loadWorklistBtn.addEventListener('click', loadWorklist); if (els.loadWorklistBtn) els.loadWorklistBtn.addEventListener('click', loadWorklist);
if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); }); if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); });
if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog); 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 () { if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () {
var list = C().getWorklist(); 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 // Warn before stranding files that still need a revision: they stay
// assigned (on a "pending" leaf under By tracking number), but the // assigned (on a "pending" leaf), but the placeholder row goes away.
// row you'd use to finish them here is about to disappear.
var pending = 0; var pending = 0;
list.forEach(function (r) { if (!(r.revisionCell || '').trim()) pending += Object.keys(r.placed || {}).length; }); 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(); 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 () { // Ctrl-V on the By-tracking panel opens the paste dialog prefilled.
hideAssigned = !!els.hideAssignedToggle.checked; if (els.trackingPanel) els.trackingPanel.addEventListener('paste', function (e) {
if (worklistGrid) worklistGrid.renderBody(); if (currentTab !== 'tracking') return;
});
// 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;
if (e.target && e.target.closest('input, textarea')) return; // let real inputs paste if (e.target && e.target.closest('input, textarea')) return; // let real inputs paste
var t = (e.clipboardData || window.clipboardData); var t = (e.clipboardData || window.clipboardData);
var text = t ? t.getData('text') : ''; var text = t ? t.getData('text') : '';
@ -129,19 +120,16 @@
} }
function showTab(which) { function showTab(which) {
currentTab = (which === 'transmittal' || which === 'worklist') ? which : 'tracking'; currentTab = (which === 'transmittal') ? 'transmittal' : 'tracking';
els.trackingTab.classList.toggle('active', currentTab === '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.transmittalTab.classList.toggle('active', currentTab === 'transmittal');
els.trackingPanel.hidden = currentTab !== 'tracking'; els.trackingPanel.hidden = currentTab !== 'tracking';
if (els.worklistPanel) els.worklistPanel.hidden = currentTab !== 'worklist';
els.transmittalPanel.hidden = currentTab !== 'transmittal'; els.transmittalPanel.hidden = currentTab !== 'transmittal';
render(); render();
// The source-tree Show filters are per-axis, so the visible set changes // The source-tree Show filters are per-axis, so the visible set changes
// with the active tab — re-render the left tree. // with the active tab — re-render the left tree.
reRenderSource(); reRenderSource();
} }
// "From a list" drops materialize tracking placements, so its axis is 'tracking'.
function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : '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(); } 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); var placed = buildPlaced(files);
renderTrackingGrid(els.trackingTree); renderTrackingGrid(els.trackingTree);
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal); renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
renderWorklist(placed.byTracking);
renderStats(files); 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) { function renderStats(files) {
var s = C().stats(files); var s = C().stats(files);
if (els.stats) { if (els.stats) {
els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · ' els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · '
+ s.none + ' unassigned · ' + s.excluded + ' excluded'; + 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'); var copyBtn = document.getElementById('copyOutputBtn');
if (copyBtn) { if (copyBtn) {
copyBtn.disabled = s.done === 0; copyBtn.disabled = s.done === 0;
copyBtn.textContent = s.done ? ('Copy ' + s.done + '…') : 'Copy…'; 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) { function el(tag, cls, text) {
@ -244,12 +251,16 @@
// seltable's own persistKey storage (merged, not clobbered). ─────────── // seltable's own persistKey storage (merged, not clobbered). ───────────
var GRID_COL_META = [ var GRID_COL_META = [
{ id: 'status', title: 'Status', fixed: true }, // fixed = never hidden by the chooser { 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: 'tn', title: 'Tracking number' },
{ id: 'rev', title: 'Rev (status)' }, { id: 'rev', title: 'Rev (status)' },
{ id: 'title', title: 'Title' }, { 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 }, { 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'; var GRID_PREFS_KEY = 'zddc.classifier.trackingCols';
function gridPrefs() { try { return JSON.parse(localStorage.getItem(GRID_PREFS_KEY)) || {}; } catch (_) { return {}; } } 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 */ } } 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. // 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; }); } 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 ────────────────────────────────────────── // ── per-column cell renderers ──────────────────────────────────────────
function gridStatusCell(td, f) { function gridStatusCell(td, f) {
var c = C(), key = c.srcKeyForFile(f), d = c.deriveTarget(f), conflict = c.hasHashConflict(key); 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'); badge.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content. ' : '') + (ne.length ? ne.join('; ') : 'Complete');
td.appendChild(badge); 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) { function gridOrigCell(td, f) {
var key = C().srcKeyForFile(f); var key = C().srcKeyForFile(f), orig = joinName(f);
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
var link = el('a', 'tg-orig__link', orig); var link = el('a', 'tg-orig__link', orig);
link.href = '#'; link.title = 'Preview ' + orig; link.href = '#'; link.title = 'Preview ' + orig;
link.addEventListener('click', function (e) { e.preventDefault(); previewKey(key); }); link.addEventListener('click', function (e) { e.preventDefault(); previewKey(key); });
td.appendChild(link); 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) { function gridEditCell(td, colId, f) {
var c = C(), key = c.srcKeyForFile(f), ident = currentIdent(f); var c = C(), key = c.srcKeyForFile(f), ident = currentIdent(f);
var value = colId === 'tn' ? ident.tracking : colId === 'rev' ? ident.rev : ident.title; var value = colId === 'tn' ? ident.tracking : colId === 'rev' ? ident.rev : ident.title;
var ph = colId === 'tn' ? 'ACME-…-0001' : colId === 'rev' ? 'A (IFR)' : 'title'; var ph = colId === 'tn' ? 'ACME-…-0001' : colId === 'rev' ? 'A (IFR)' : 'title';
var warn = colId === 'tn' ? gridTnWarn(ident.tracking) : ''; var warn = colId === 'tn' ? gridTnWarn(ident.tracking) : '';
var inp = el('input', 'tg-input' + (warn ? ' is-warn' : '')); editCell(td, 'tg-input', value, ph, function (v) {
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 () {
var cur = currentIdent(f); // re-read so a prior edit isn't clobbered var cur = currentIdent(f); // re-read so a prior edit isn't clobbered
if (colId === 'tn') cur.tracking = inp.value.trim(); if (colId === 'tn') cur.tracking = v;
else if (colId === 'rev') cur.rev = inp.value.trim(); else if (colId === 'rev') cur.rev = v;
else cur.title = inp.value; else cur.title = v;
c.setFileIdentity(key, cur); c.setFileIdentity(key, cur);
}); }, warn);
td.appendChild(inp); }
// 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) { function gridRemoveCell(td, f) {
var c = C(), key = c.srcKeyForFile(f); var c = C(), key = c.srcKeyForFile(f);
var rm = el('button', 'tnode__act tg-x__btn', '✕'); var rm = el('button', 'tnode__act tg-x__btn', '✕'); rm.title = 'Remove from the grid';
rm.title = 'Remove from the grid';
rm.addEventListener('click', function () { c.removeFromTrackingGrid(key); }); rm.addEventListener('click', function () { c.removeFromTrackingGrid(key); });
td.appendChild(rm); 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 // 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() { function trackingColumns() {
var c = C(), hidden = (gridPrefs().hidden || {}); var hidden = (gridPrefs().hidden || {});
var defs = { var defs = {
status: { key: 'status', title: 'Status', cls: 'tg-status', filterable: false, 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'); }, 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 (f, td) { gridStatusCell(td, f); } }, render: function (r, td) { isFile(r) ? gridStatusCell(td, r.file) : gridPlaceholderStatus(td); } },
orig: { key: 'orig', title: 'Original name', cls: 'tg-orig', orig: { key: 'orig', title: 'Original / expected name', cls: 'tg-orig',
get: function (f) { return f.originalFilename + (f.extension ? '.' + f.extension : ''); }, get: function (r) { return isFile(r) ? joinName(r.file) : (r.wl.currentName || ''); },
render: function (f, td) { gridOrigCell(td, f); } }, render: function (r, td) { isFile(r) ? gridOrigCell(td, r.file) : gridExpectedCell(td, r.wl); } },
tn: { key: 'tn', title: 'Tracking number', cls: 'tg-tn', tn: { key: 'tn', title: 'Tracking number', cls: 'tg-tn',
get: function (f) { return currentIdent(f).tracking; }, get: function (r) { return isFile(r) ? currentIdent(r.file).tracking : (r.wl.trackingNumber || ''); },
render: function (f, td) { gridEditCell(td, 'tn', f); } }, 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', rev: { key: 'rev', title: 'Rev (status)', cls: 'tg-rev',
get: function (f) { return currentIdent(f).rev; }, get: function (r) { return isFile(r) ? currentIdent(r.file).rev : (r.wl.revisionCell || ''); },
render: function (f, td) { gridEditCell(td, 'rev', f); } }, render: function (r, td) { isFile(r) ? gridEditCell(td, 'rev', r.file) : gridRowEditCell(td, 'rev', r.wl); } },
title: { key: 'title', title: 'Title', cls: 'tg-title', title: { key: 'title', title: 'Title', cls: 'tg-title',
get: function (f) { return currentIdent(f).title; }, get: function (r) { return isFile(r) ? currentIdent(r.file).title : (r.wl.title || ''); },
render: function (f, td) { gridEditCell(td, 'title', f); } }, 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, 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 = ''; var trackingGrid = null, trackingColSig = '';
@ -348,23 +403,27 @@
trackingColSig = colSig(); trackingColSig = colSig();
trackingGrid = window.app.modules.seltable.create({ trackingGrid = window.app.modules.seltable.create({
container: container, container: container,
rows: function () { return c.trackingGridKeys().map(fileByKey).filter(Boolean); }, rows: gridRows,
rowId: function (f) { return c.srcKeyForFile(f); }, rowId: function (r) { return r.id; },
columns: trackingColumns(), columns: trackingColumns(),
persistKey: GRID_PREFS_KEY, 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(); trackingGrid.render();
return trackingGrid; return trackingGrid;
} }
function renderTrackingGrid(container) { function renderTrackingGrid(container) {
// Empty ↔ populated transition: tear the seltable down for the prompt, // Empty ↔ populated transition: tear the seltable down for the prompt,
// re-create it (create-once) when files arrive — same as the worklist. // re-create it (create-once) when rows arrive.
if (!C().trackingGridKeys().length) { if (!gridRows().length) {
trackingGrid = null; trackingGrid = null;
container.textContent = ''; container.textContent = '';
container.classList.remove('seltable'); container.classList.remove('seltable');
container.appendChild(el('div', 'target-empty', container.appendChild(el('div', 'target-empty',
'No files yet — drag files here from the left, then type each ones tracking number, revision, and title. A file thats 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 thats already ZDDC-named fills in automatically.'));
return; return;
} }
// The Columns ▾ chooser changes which columns exist → rebuild on mismatch // 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). // (setFilter re-renders the body, so the rows are always fresh on render).
trackingGrid.setFilter(rfTerms.join(' ')); 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. // Drag files onto the grid → add as rows; auto-fill any already ZDDC-named.
function setupGridDrop(container) { function setupGridDrop(container) {
@ -409,10 +481,10 @@
GRID_COL_META.forEach(function (col) { GRID_COL_META.forEach(function (col) {
if (col.fixed) return; if (col.fixed) return;
var lbl = el('label', 'col-chooser__item'); 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 () { cb.addEventListener('change', function () {
var p = gridPrefs(); p.hidden = p.hidden || {}; 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); saveGridPrefs(p);
trackingGrid = null; // column set changed → rebuild the seltable trackingGrid = null; // column set changed → rebuild the seltable
render(); render();
@ -518,54 +590,53 @@
return form; return form;
} }
// ── "From a list" (scratch worklist via the shared seltable) ──────────── function setGridStatus(text) {
function renderWorklist(placedByTracking) { var s = document.getElementById('scanStatus');
worklistPlaced = placedByTracking || {}; if (s) { s.textContent = text; s.classList.toggle('scanning', !!text); }
if (!C().getWorklist().length) { }
worklistGrid = null;
els.worklistTable.textContent = ''; // "⊕ Add filtered files" — pull every file the LEFT tree filter currently
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).')); // 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; return;
} }
ensureWorklistGrid(); var preview = items.slice(0, 4).map(function (x) { return ' ' + joinName(x.file) + ' → ' + x.newName; }).join('\n');
worklistGrid.renderBody(); 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'
function rowPlaced(r) { var f = worklistPlaced[r.trackingNumber]; return f && f.length ? f : null; } + preview + (items.length > 4 ? ('\n …and ' + (items.length - 4) + ' more') : '')
function ensureWorklistGrid() { + '\n\nRename these files in place now?';
if (worklistGrid) return worklistGrid; if (!confirm(msg)) return;
var c = C(); setGridStatus('Renaming…');
var cols = [ var done = 0, errors = 0;
{ key: 'tn', title: 'Tracking number', cls: 'worklist-tn', get: function (r) { return r.trackingNumber || ''; }, for (var i = 0; i < items.length; i++) {
render: function (r, td) { editCell(td, 'worklist-tn__input', r.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(r.id, v); }, tnWarn(r)); } }, setGridStatus('Renaming… ' + (i + 1) + '/' + items.length + ' — ' + items[i].newName);
{ key: 'title', title: 'Title', cls: 'worklist-title', get: function (r) { return r.title || ''; }, try { await window.app.modules.rename.renameTo(items[i].file, items[i].newName); c.forgetFile(items[i].oldKey); done++; }
render: function (r, td) { editCell(td, 'worklist-title__input', r.title, 'title', function (v) { c.setRowTitle(r.id, v); }); } }, catch (e) { errors++; window.zddc.toast('Rename failed for ' + items[i].newName + ' — ' + (e.message || e), 'error'); }
{ 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, setGridStatus('');
get: function (r) { var s = r.source || {}; return [s.mdl ? 'mdl' : '', s.archive ? 'arch' : '', s.pasted ? 'pasted' : ''].filter(Boolean).join(' '); }, render();
render: function (r, td) { renderSource(r, td); } }, reRenderSource();
{ key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } }, window.zddc.toast('Renamed ' + done + ' file' + (done === 1 ? '' : 's') + ' in place'
{ key: 'rev', title: 'Revision', cls: 'worklist-rev', get: function (r) { return r.revisionCell; }, + (errors ? (', ' + errors + ' failed (retry)') : '') + '. Source files updated.', errors ? 'warning' : 'success');
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;
} }
// An editable seltable cell: an <input> that commits on change. `warn` is an // An editable seltable cell: an <input> that commits on change. `warn` is an
// optional tooltip that flags (without blocking) a questionable value. // optional tooltip that flags (without blocking) a questionable value.
@ -596,30 +667,6 @@
td.appendChild(el('span', 'src-badge src-badge--pasted', 'pasted')); 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 rows title or the files 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 // "From a list" loader: "Load…" opens a multi-select directory tree (scoped
// to the served context); every ticked directory is walked recursively into // to the served context); every ticked directory is walked recursively into
// the union of existing files + MDL deliverables, deduped by tracking number // the union of existing files + MDL deliverables, deduped by tracking number
@ -719,7 +766,7 @@
function finishLoad(rows) { function finishLoad(rows) {
listScanned = true; listScanned = true;
C().appendWorklist(rows); // APPEND — the list accumulates across batches C().appendWorklist(rows); // APPEND — the list accumulates across batches
showTab('worklist'); showTab('tracking');
window.zddc.toast(rows.length window.zddc.toast(rows.length
? ('Added ' + 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'); : 'No files or deliverables in the selected directories.', rows.length ? 'success' : 'warning');
@ -800,7 +847,7 @@
add.addEventListener('click', function () { add.addEventListener('click', function () {
var n = parsed.rows.length; var n = parsed.rows.length;
c.appendWorklist(parsed.rows); c.appendWorklist(parsed.rows);
m.close(); showTab('worklist'); m.close(); showTab('tracking');
var assigned = autoAssignByName(); var assigned = autoAssignByName();
var msg = 'Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.'; var msg = 'Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.';
if (assigned) msg += ' Auto-assigned ' + assigned + ' file' + (assigned === 1 ? '' : 's') + ' by current name.'; if (assigned) msg += ' Auto-assigned ' + assigned + ' file' + (assigned === 1 ? '' : 's') + ' by current name.';
@ -852,7 +899,7 @@
var p = proposals[Number(cb.dataset.i)]; var p = proposals[Number(cb.dataset.i)];
if (p) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; } 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'); window.zddc.toast('Assigned ' + n + ' file' + (n === 1 ? '' : 's') + ' by name match.', n ? 'success' : 'info');
}); });
fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); }); fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); });

View file

@ -1005,6 +1005,7 @@
setShowFilters, setShowFilters,
setNameFilter, setNameFilter,
exportFilteredList, exportFilteredList,
filteredFiles: filteredFileObjects,
_buildExportTsv: buildExportTsv _buildExportTsv: buildExportTsv
}; };
})(); })();

View file

@ -30,10 +30,6 @@
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
<div class="mode-switch" id="modeSwitch" role="group" aria-label="Workflow mode">
<button id="modeClassifyBtn" class="mode-btn active" title="Map files onto tracking numbers and transmittals, then copy renamed copies to an output directory — the source is never modified">Classify &amp; copy</button>
<button id="modeRenameBtn" class="mode-btn" title="Edit a spreadsheet and rename the files in place (edits the source)">Rename in place</button>
</div>
<button id="workspacesBtn" class="btn btn-secondary btn-sm" title="Workspaces — open or create a classification project">≡ Workspaces</button> <button id="workspacesBtn" class="btn btn-secondary btn-sm" title="Workspaces — open or create a classification project">≡ Workspaces</button>
<button id="connectDirBtn" class="btn btn-primary btn-sm" title="Connect this workspace's source directory to preview, copy, or finish scanning" hidden>⮷ Connect directory</button> <button id="connectDirBtn" class="btn btn-primary btn-sm" title="Connect this workspace's source directory to preview, copy, or finish scanning" hidden>⮷ Connect directory</button>
</div> </div>
@ -96,81 +92,15 @@
<div class="resize-handle" id="treeResizeHandle"></div> <div class="resize-handle" id="treeResizeHandle"></div>
</aside> </aside>
<!-- Spreadsheet Table (Rename in place) --> <!-- The classify surface: one editable By-tracking grid + a
<main class="spreadsheet-pane" id="spreadsheetPane" hidden> transmittal tree. Tracking number alone ⇒ a Rename (in place);
<div class="pane-header"> add a transmittal folder ⇒ a Copy into the archive. -->
<div class="pane-header-left">
<h3>Files</h3>
<div class="file-stats">
<span id="totalFiles">0 files</span>
<span id="modifiedFiles">0 modified</span>
<span id="errorFiles" class="hidden">0 errors</span>
</div>
</div>
<div class="pane-header-right">
<button id="saveAllBtn" class="btn btn-success btn-sm" disabled>Save All</button>
<button id="cancelAllBtn" class="btn btn-secondary btn-sm" disabled>Cancel All</button>
<span class="header-divider">|</span>
<label class="checkbox-label">
<input type="checkbox" id="sha256Checkbox">
SHA256
</label>
<button id="exportHashesBtn" class="btn btn-secondary btn-sm" disabled title="Export SHA256 hashes in sha256sum format">💾 Export Hashes</button>
<span class="header-divider">|</span>
<button id="togglePreviewBtn" class="btn btn-secondary btn-sm" title="Toggle file preview panel">👁 Preview</button>
</div>
</div>
<div class="spreadsheet-container">
<table id="spreadsheet" class="spreadsheet">
<thead>
<tr>
<th class="col-row-num">#</th>
<th class="col-original">Original Filename
<input type="text" class="column-filter" data-filter-field="original" placeholder="filter…" spellcheck="false" aria-label="Filter by original filename">
</th>
<th class="col-extension">Ext
<input type="text" class="column-filter" data-filter-field="extension" placeholder="filter…" spellcheck="false" aria-label="Filter by extension">
</th>
<th class="col-new">New Filename
<input type="text" class="column-filter" data-filter-field="newFilename" placeholder="filter…" spellcheck="false" aria-label="Filter by new filename">
</th>
<th class="col-trackingNumber">Tracking
<input type="text" class="column-filter" data-filter-field="trackingNumber" placeholder="filter…" spellcheck="false" aria-label="Filter by tracking number">
</th>
<th class="col-revision">Rev
<input type="text" class="column-filter" data-filter-field="revision" placeholder="filter…" spellcheck="false" aria-label="Filter by revision">
</th>
<th class="col-status">Status
<input type="text" class="column-filter" data-filter-field="status" placeholder="filter…" spellcheck="false" aria-label="Filter by status">
</th>
<th class="col-title">Title
<input type="text" class="column-filter" data-filter-field="title" placeholder="filter…" spellcheck="false" aria-label="Filter by title">
</th>
<th class="col-sha256 hidden" id="sha256Column">SHA256
<input type="text" class="column-filter" data-filter-field="sha256" placeholder="filter…" spellcheck="false" aria-label="Filter by SHA256">
</th>
</tr>
</thead>
<tbody id="spreadsheetBody">
<!-- Dynamically populated -->
</tbody>
</table>
</div>
</main>
<!-- Target Trees (Classify & Copy mode) — default view -->
<main class="target-pane" id="targetPane"> <main class="target-pane" id="targetPane">
<div class="pane-header pane-header--target"> <div class="pane-header pane-header--target">
<p class="target-goal">Each file needs a <strong>tracking number</strong> (revision + status + title) and a <strong>transmittal folder</strong>. Name it — build one under <em>By tracking number</em>, or drag onto a row under <em>From a list</em> (loaded from the archive/MDL or pasted from Excel) — then route it under <em>By transmittal</em>.</p> <p class="target-goal">Give each file a <strong>tracking number</strong> (revision + status + title) under <em>By tracking number</em> — that alone is a <strong>Rename</strong> in place. Route it into a <strong>transmittal folder</strong> under <em>By transmittal</em> to <strong>Copy</strong> it into the archive.</p>
<div class="target-tabs" role="tablist"> <div class="target-tabs" role="tablist">
<div class="target-tabs__group"> <button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button> <button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
<button class="target-tab" id="worklistTab" role="tab" title="Drag files onto a list of tracking numbers — loaded from the archive/MDL, pasted from Excel, or auto-matched by name.">From a list</button>
</div>
<span class="target-tabs__divider" aria-hidden="true"></span>
<div class="target-tabs__group">
<button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
</div>
</div> </div>
<div class="pane-header-right"> <div class="pane-header-right">
<span id="classifyStats" class="file-stats"></span> <span id="classifyStats" class="file-stats"></span>
@ -179,15 +109,19 @@
<button id="importDatasetBtn" class="btn btn-secondary btn-sm" title="Load an edited classification JSON back in — replaces the current classifications. (To move a whole scanned workspace between browsers, use “Import workspace” on the welcome screen.)">Import edits</button> <button id="importDatasetBtn" class="btn btn-secondary btn-sm" title="Load an edited classification JSON back in — replaces the current classifications. (To move a whole scanned workspace between browsers, use “Import workspace” on the welcome screen.)">Import edits</button>
<input type="file" id="importDatasetInput" accept="application/json,.json" hidden> <input type="file" id="importDatasetInput" accept="application/json,.json" hidden>
<button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button> <button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button>
<button id="checkDuplicatesBtn" class="btn btn-secondary btn-sm" title="Check for files with the same tracking number + revision but different content (flagged ≠ in red)">Check</button>
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to the server archive or a local folder (source untouched, resumable, verified)">Copy…</button>
</div> </div>
</div> </div>
<div class="target-body"> <div class="target-body">
<section id="trackingPanel" class="target-panel"> <section id="trackingPanel" class="target-panel">
<div class="target-panel__toolbar"> <div class="target-panel__toolbar">
<button id="loadWorklistBtn" class="btn btn-sm btn-secondary" title="Add tracking numbers from the project archive/MDL (pick directories to scan) as rows to fill.">⊞ Load…</button>
<button id="pasteRowsBtn" class="btn btn-sm btn-secondary" title="Paste rows from Excel: Tracking · Rev (Status) · Title · Current name.">⎘ Paste rows…</button>
<button id="matchNamesBtn" class="btn btn-sm btn-secondary" title="Auto-match unassigned files to list rows by name.">⚡ Match names</button>
<button id="addFilteredBtn" class="btn btn-sm btn-secondary" title="Add every file the left tree currently shows (filters applied) to the grid.">⊕ Add filtered files</button>
<button id="clearListBtn" class="btn btn-sm btn-secondary" title="Remove the list (placeholder) rows. Every assignment is kept.">Clear list</button>
<button id="trackingColsBtn" class="btn btn-sm btn-secondary" title="Show or hide columns">Columns ▾</button> <button id="trackingColsBtn" class="btn btn-sm btn-secondary" title="Show or hide columns">Columns ▾</button>
<span class="target-hint">Drag files in, then type each ones tracking number, revision (e.g. “A (IFR)”), and title. A file thats already ZDDC-named fills in automatically. Columns are hideable + resizable.</span> <span class="target-hint">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.</span>
<button id="renameBtn" class="btn btn-sm btn-danger" disabled title="Rename the name-complete files IN PLACE on disk. Destructive — no backup.">Rename…</button>
</div> </div>
<input type="search" id="trackingFilterInput" class="tree-filter target-filter" spellcheck="false" <input type="search" id="trackingFilterInput" class="tree-filter target-filter" spellcheck="false"
placeholder="Filter the grid…" aria-label="Filter the tracking grid"> placeholder="Filter the grid…" aria-label="Filter the tracking grid">
@ -196,28 +130,14 @@
<section id="transmittalPanel" class="target-panel" hidden> <section id="transmittalPanel" class="target-panel" hidden>
<div class="target-panel__toolbar"> <div class="target-panel__toolbar">
<button id="addPartyBtn" class="btn btn-sm btn-secondary">+ Party</button> <button id="addPartyBtn" class="btn btn-sm btn-secondary">+ Party</button>
<span class="target-hint">&lt;party&gt;/{received,issued}/&lt;transmittal&gt;. Drag files (or a whole folder) into a transmittal.</span> <span class="target-hint">&lt;party&gt;/{received,issued}/&lt;transmittal&gt;. Drag files (or a whole folder) into a transmittal, then Copy into the archive.</span>
<button id="checkDuplicatesBtn" class="btn btn-secondary btn-sm" title="Check for files with the same tracking number + revision but different content (flagged ≠ in red)">Check</button>
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy fully-classified files (+ their transmittal folders) into the archive — server or a local folder. Source untouched, resumable, verified.">Copy…</button>
</div> </div>
<input type="search" id="transmittalFilterInput" class="tree-filter target-filter" spellcheck="false" <input type="search" id="transmittalFilterInput" class="tree-filter target-filter" spellcheck="false"
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree"> placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
<div id="transmittalTree" class="target-tree"></div> <div id="transmittalTree" class="target-tree"></div>
</section> </section>
<!-- "From a list": a scratch worklist of tracking numbers — Load them
from the archive/MDL, Paste rows from Excel, or ⚡ Match by name.
Dragging a file onto a row MATERIALIZES a real "By tracking number"
placement, so Clear keeps every assignment. The left filetree is
the drag source. -->
<section id="worklistPanel" class="target-panel" hidden>
<div class="target-panel__toolbar">
<button id="loadWorklistBtn" class="btn btn-sm btn-secondary" title="Add tracking numbers from the project archive/MDL (pick directories to scan).">⊞ Load…</button>
<button id="pasteRowsBtn" class="btn btn-sm btn-secondary" title="Paste rows from Excel: Tracking · Rev (Status) · Title.">⎘ Paste rows…</button>
<button id="matchNamesBtn" class="btn btn-sm btn-secondary" title="Auto-suggest assignments by matching unassigned filenames against the list.">⚡ Match names</button>
<button id="clearListBtn" class="btn btn-sm btn-secondary" title="Empty the list. Every assignment is kept — see By tracking number.">Clear list</button>
<label class="target-toggle" title="Hide rows that already have files assigned."><input type="checkbox" id="hideAssignedToggle"> Hide assigned</label>
<span class="target-hint">Drag files onto a row to name them; edit the Tracking number / Revision inline (ctrl-shift + ctrl-Enter sets many). Clearing the list keeps every assignment.</span>
</div>
<div id="worklistTable" class="target-tree"></div>
</section>
</div> </div>
</main> </main>
@ -247,26 +167,16 @@
<div id="workspaceList" class="ws-list"><!-- rendered --></div> <div id="workspaceList" class="ws-list"><!-- rendered --></div>
</section> </section>
<!-- Two-method tutorial --> <!-- One flow, two endings: rename in place or copy to the archive -->
<div class="welcome__methods"> <div class="welcome__methods">
<section class="method method--primary"> <section class="method method--primary">
<h3 class="method__title">① Classify &amp; copy <span class="method__tag">recommended · non-destructive</span></h3> <h3 class="method__title">Classify, then Rename or Copy</h3>
<p class="method__what">Build a tidy copy of a project in a separate output folder. Your source files are only ever <em>read</em>, never renamed or moved.</p> <p class="method__what">Give each file a ZDDC name in the <strong>By tracking number</strong> grid. A tracking number alone <em>is</em> a rename; add a <strong>transmittal folder</strong> and it can be copied into the archive.</p>
<ol class="method__steps"> <ol class="method__steps">
<li><strong>New workspace</strong> → pick a folder. It scans <em>once</em> and saves to this browser, so you can close the tab and pick up later.</li> <li><strong>New workspace</strong> → pick a folder. It scans <em>once</em> and saves to this browser, so you can close the tab and pick up later.</li>
<li><strong>Preview</strong> a file (single-click it in the left tree) to see what it actually is.</li> <li><strong>Add files to the grid</strong> — drag them from the left tree (ctrl-shift-click to multi-select and fill a block of rows), use <strong>⊕ Add filtered files</strong>, or <strong>⊞ Load…</strong> / <strong>⎘ Paste rows…</strong> a list of expected tracking numbers and drop the matching files on. Already-ZDDC-named files fill in automatically.</li>
<li><strong>Drag</strong> it onto the right pane — onto a <em>tracking-number</em> folder (the folder path becomes the number, the leaf is the revision, e.g. <code>A (IFR)</code>), and onto a <em>transmittal</em> (party + date + TRN/SUB + sequence).</li> <li>Type each file's <strong>tracking number</strong>, <strong>revision</strong> (e.g. <code>A (IFR)</code>) and <strong>title</strong>.</li>
<li><strong>Copy</strong> when ready → choose an output directory; renamed copies are written as <code>&lt;party&gt;/&lt;transmittal&gt;/&lt;name&gt;</code>, with duplicates detected.</li> <li><strong>Rename…</strong> (By tracking number) renames the named files <em>in place</em> on disk — <span class="method__tag method__tag--warn">destructive, no backup</span>. Or place them into a transmittal under <strong>By transmittal</strong> and <strong>Copy…</strong> them into the archive (source untouched, resumable, verified).</li>
</ol>
</section>
<section class="method">
<h3 class="method__title">② Rename in place <span class="method__tag method__tag--warn">edits your files</span></h3>
<p class="method__what">A quick spreadsheet for a folder you own: fill in tracking number, revision, status and title, and rename the files on disk.</p>
<ol class="method__steps">
<li>Click <strong>Use Local Directory</strong> (top bar) to open a folder.</li>
<li>Switch the toggle to <strong>Rename in place</strong>.</li>
<li>Edit cells (or paste columns from Excel); names already in ZDDC format are parsed automatically and validated as you type.</li>
<li><strong>Save All</strong> renames the files where they sit.</li>
</ol> </ol>
</section> </section>
</div> </div>

View file

@ -143,17 +143,20 @@ test('exclude clears placements and reports excluded state', async ({ page }) =>
// ── Phase 2: mode toggle + target-tree rendering (UI) ────────────────────── // ── Phase 2: mode toggle + target-tree rendering (UI) ──────────────────────
test('mode switch swaps the spreadsheet pane for the target pane', async ({ page }) => { test('the classify surface is the single pane — no mode toggle, no spreadsheet', async ({ page }) => {
await page.click('#modeClassifyBtn'); await page.evaluate(() => window.app.modules.app.setMode());
expect(await page.locator('#targetPane').isHidden()).toBe(false); expect(await page.locator('#targetPane').isHidden()).toBe(false);
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(true); expect(await page.locator('#modeClassifyBtn').count()).toBe(0); // toggle removed
await page.click('#modeRenameBtn'); expect(await page.locator('#modeRenameBtn').count()).toBe(0);
expect(await page.locator('#targetPane').isHidden()).toBe(true); expect(await page.locator('#spreadsheetPane').count()).toBe(0); // spreadsheet pane removed
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false); 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 }) => { 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(() => { await page.evaluate(() => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
c.reset(); 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) ─────────────────────── // ── 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify; c.reset(); const c = window.app.modules.classify; c.reset();
const plain = { originalFilename: 'messy scan', extension: 'pdf', folderPath: 'R' }; 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 }) => { 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'); await page.click('#transmittalTab');
const r = await page.evaluate(() => { const r = await page.evaluate(() => {
const c = window.app.modules.classify; 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. // Inject a synthetic scanned tree (no FS Access needed) and render it.
async function withSourceTree(page) { async function withSourceTree(page) {
await page.click('#modeClassifyBtn'); await page.evaluate(() => window.app.modules.app.setMode());
await page.evaluate(() => { await page.evaluate(() => {
window.app.folderTree = [{ window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done', 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 }) => { 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(() => { const order = await page.evaluate(() => {
window.app.folderTree = [{ window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done', 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 }) => { 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(() => { const previewed = await page.evaluate(() => {
let got = null; let got = null;
window.app.modules.preview.previewFile = (f) => { got = f.originalFilename; }; // capture, skip popup 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 }) => { 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(() => { await page.evaluate(() => {
window.app.folderTree = [{ window.app.folderTree = [{
name: 'Leaf', path: 'Leaf', expanded: false, scanState: 'done', 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) ─────── // ── Phase 5: copy-out engine + duplicate detection (mock FS handles) ───────
test('copy: writes the file, then resumes by skipping an existing target', async ({ page }) => { 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 res = await page.evaluate(async () => {
const c = window.app.modules.classify, copy = window.app.modules.copy; const c = window.app.modules.classify, copy = window.app.modules.copy;
const store = {}; 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 }) => { 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 conflicts = await page.evaluate(() => {
const c = window.app.modules.classify, copy = window.app.modules.copy; const c = window.app.modules.classify, copy = window.app.modules.copy;
const srcFile = (name, folder) => { 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 }) => { 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 res = await page.evaluate(async () => {
const c = window.app.modules.classify, copy = window.app.modules.copy; const c = window.app.modules.classify, copy = window.app.modules.copy;
const srcStore = { 'Sub/foundation.pdf': 'AAA' }; 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 }) => { 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 before = await page.evaluate(() => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
c.reset(); 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree; const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset(); 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 }) => { 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(() => { const r = await page.evaluate(() => {
window.app.folderTree = [{ window.app.folderTree = [{
name: 'Project', path: 'Project', expanded: true, scanState: 'done', files: [], children: [ 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree; const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset(); 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 }) => { 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(() => { const r = await page.evaluate(() => {
window.app.folderTree = [ window.app.folderTree = [
{ name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [], { 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 }) => { 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(() => { const r = await page.evaluate(() => {
window.app.folderTree = [{ window.app.folderTree = [{
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [ 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 }) => { 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(() => { const r = await page.evaluate(() => {
window.app.folderTree = [{ window.app.folderTree = [{
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [ 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 }) => { 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(() => { const r = await page.evaluate(() => {
window.app.folderTree = [{ window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done', 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 }) => { 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 res = await page.evaluate(async () => {
const c = window.app.modules.classify, copy = window.app.modules.copy; const c = window.app.modules.classify, copy = window.app.modules.copy;
const f = { 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify, sc = window.app.modules.scanner; 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' }; 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify, tree = window.app.modules.tree; const c = window.app.modules.classify, tree = window.app.modules.tree;
const f1 = { originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' }; 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree; const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset(); 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree; const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset(); 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify, tree = window.app.modules.tree, tt = window.app.modules.targetTree; const c = window.app.modules.classify, tree = window.app.modules.tree, tt = window.app.modules.targetTree;
c.reset(); 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 }) => { 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 r = await page.evaluate(async () => {
const c = window.app.modules.classify, copy = window.app.modules.copy; const c = window.app.modules.classify, copy = window.app.modules.copy;
c.reset(); 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 }) => { 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 r = await page.evaluate(async () => {
const c = window.app.modules.classify, copy = window.app.modules.copy; const c = window.app.modules.classify, copy = window.app.modules.copy;
c.reset(); 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 }) => { 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 r = await page.evaluate(async () => {
const c = window.app.modules.classify, copy = window.app.modules.copy; const c = window.app.modules.classify, copy = window.app.modules.copy;
c.reset(); 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 }) => { 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 r = await page.evaluate(async () => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree; const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset(); 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
c.reset(); 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree; const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset(); 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: '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)'] }, { id: 'm2', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', archiveRevisions: ['0 (IFC)'] },
]); ]);
tt.showTab('worklist'); tt.render(); // placeholder rows live in the unified By-tracking grid
const row = document.querySelector('#worklistTable .seltable__row[data-id="m1"]'); const row = document.querySelector('#trackingTree .seltable__row[data-id="p:m1"]');
const latestShown = !!row && row.textContent.includes('B (IFC)'); // latest archive rev shown
window.app.modules.dnd.setDrag([key]); 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; const named = c.deriveTarget(f).filename;
c.clearWorklist(); // list emptied — assignment must survive c.clearWorklist(); // list emptied — assignment must survive
return { return {
hasRow: !!row, latestShown, hasRow: !!row,
placedAfterDrop: !!(c.getAssignment(key) || {}).trackingNodeId, placedAfterDrop: !!(c.getAssignment(key) || {}).trackingNodeId,
named, named,
listLen: c.getWorklist().length, 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.hasRow).toBe(true);
expect(r.latestShown).toBe(true);
expect(r.placedAfterDrop).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.named).toBe('ACM-PRJ-EL-SPC-0001_C (IFC) - Spec.pdf'); // tracking + row revision + title
expect(r.listLen).toBe(0); // list cleared 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
c.reset(); 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
c.reset(); 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
const text = [ 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
const files = [ 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
const files = [ 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
const files = [ 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 }) => { 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 r = await page.evaluate(async () => {
const tt = window.app.modules.targetTree; const tt = window.app.modules.targetTree;
function fdir(name, children) { 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) ────────────── // ── Export filtered list → Excel round-trip (path + file TSV) ──────────────
test('export: filtered file list → TSV (path + file), includes collapsed folders', async ({ page }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify, tree = window.app.modules.tree; const c = window.app.modules.classify, tree = window.app.modules.tree;
c.reset(); 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
c.reset(); 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 }) => { 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 r = await page.evaluate(() => {
const c = window.app.modules.classify; const c = window.app.modules.classify;
c.reset(); c.reset();