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:
parent
ca1452dde1
commit
f82d6919b4
9 changed files with 388 additions and 350 deletions
|
|
@ -64,6 +64,7 @@ concat_files \
|
|||
"js/dir-picker.js" \
|
||||
"js/target-tree.js" \
|
||||
"js/copy.js" \
|
||||
"js/rename.js" \
|
||||
"js/spreadsheet.js" \
|
||||
"js/selection.js" \
|
||||
"js/preview.js" \
|
||||
|
|
|
|||
|
|
@ -752,6 +752,10 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
|||
.tg-orig__link { color: var(--text-muted); white-space: nowrap; text-decoration: none; cursor: pointer; }
|
||||
.tg-orig__link:hover { text-decoration: underline; }
|
||||
.tg-status, .tg-x { text-align: center; }
|
||||
/* Placeholder rows (a list item with no file yet) — a hollow "wanted" marker and
|
||||
a muted expected-name hint that doubles as the drop target. */
|
||||
.tg-wanted { color: var(--text-muted); }
|
||||
.tg-expected { color: var(--text-muted); font-style: italic; }
|
||||
.tg-x__btn { opacity: 0.5; }
|
||||
.seltable__row:hover .tg-x__btn { opacity: 1; }
|
||||
.tg-drop-hover { outline: 2px dashed var(--primary); outline-offset: -3px; background: var(--primary-light); }
|
||||
|
|
|
|||
|
|
@ -193,22 +193,19 @@
|
|||
* onto target trees, copy renamed copies out). The source tree (left) stays
|
||||
* in both modes; only the right pane swaps.
|
||||
*/
|
||||
function setMode(mode) {
|
||||
const classify = mode === 'classify';
|
||||
app.dom.modeRenameBtn.classList.toggle('active', !classify);
|
||||
app.dom.modeClassifyBtn.classList.toggle('active', classify);
|
||||
if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify;
|
||||
if (app.dom.targetPane) app.dom.targetPane.hidden = !classify;
|
||||
// Mode-specific source-tree filters: "Hide Compliant" is for the rename
|
||||
// grid; "Hide Assigned" is for the classify workflow.
|
||||
if (app.dom.hideCompliantLabel) app.dom.hideCompliantLabel.hidden = classify;
|
||||
if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = !classify;
|
||||
app.modules.classify.setEnabled(classify);
|
||||
if (classify && app.modules.targetTree) {
|
||||
// There is only one surface now (the classify grid + transmittal tree); the
|
||||
// old Rename-in-place spreadsheet was folded into the By-tracking grid's
|
||||
// "Rename…" action. setMode is kept as a no-arg enabler for back-compat with
|
||||
// the workspace/open flows that call it.
|
||||
function setMode() {
|
||||
if (app.dom.targetPane) app.dom.targetPane.hidden = false;
|
||||
if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = false;
|
||||
app.modules.classify.setEnabled(true);
|
||||
if (app.modules.targetTree) {
|
||||
app.modules.targetTree.init();
|
||||
app.modules.targetTree.render();
|
||||
}
|
||||
// Re-render the source tree so its per-file markers appear/disappear.
|
||||
// Re-render the source tree so its per-file markers appear.
|
||||
if (app.modules.tree) app.modules.tree.render();
|
||||
}
|
||||
|
||||
|
|
@ -339,18 +336,9 @@
|
|||
// Drag and drop on welcome screen
|
||||
setupWelcomeDragDrop();
|
||||
|
||||
// Bulk actions
|
||||
app.dom.saveAllBtn.addEventListener('click', handleSaveAll);
|
||||
app.dom.cancelAllBtn.addEventListener('click', handleCancelAll);
|
||||
|
||||
// Export hashes
|
||||
app.dom.exportHashesBtn.addEventListener('click', handleExportHashes);
|
||||
|
||||
// SHA256 toggle
|
||||
app.dom.sha256Checkbox.addEventListener('change', handleSha256Toggle);
|
||||
|
||||
// Hide compliant toggle
|
||||
app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
|
||||
// (The old Rename-in-place spreadsheet — Save All / Cancel All / SHA256 /
|
||||
// Export hashes — was removed; its rename is now the By-tracking "Rename…".)
|
||||
if (app.dom.hideCompliantCheckbox) app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
|
||||
|
||||
// Classify-mode source-tree filters: show/hide unassigned, assigned, excluded.
|
||||
function pushClassifyFilters() {
|
||||
|
|
@ -375,9 +363,6 @@
|
|||
// Collapse tree button
|
||||
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
|
||||
|
||||
// Workflow mode switch
|
||||
if (app.dom.modeRenameBtn) app.dom.modeRenameBtn.addEventListener('click', function () { setMode('rename'); });
|
||||
if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); });
|
||||
if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); });
|
||||
if (app.dom.checkDuplicatesBtn) app.dom.checkDuplicatesBtn.addEventListener('click', function () { app.modules.copy.audit(); });
|
||||
|
||||
|
|
@ -543,12 +528,7 @@
|
|||
showMainUI();
|
||||
if (!shellInited) {
|
||||
shellInited = true;
|
||||
app.modules.spreadsheet.init(); // Subscribe to store
|
||||
app.modules.selection.init();
|
||||
app.modules.preview.init(); // After selection so it can listen for rowfocused
|
||||
app.modules.resize.init();
|
||||
app.modules.filter.init();
|
||||
app.modules.sort.init();
|
||||
app.modules.preview.init(); // file preview (click a row / original-name link)
|
||||
app.modules.tree.setupKeyboardShortcuts();
|
||||
if (app.modules.targetTree) app.modules.targetTree.init();
|
||||
}
|
||||
|
|
@ -558,9 +538,8 @@
|
|||
async function openDirectory(dirHandle) {
|
||||
app.rootHandle = dirHandle;
|
||||
enterAppShell();
|
||||
// Default to Classify & Copy (the primary workflow). The user can switch
|
||||
// to "Rename in place" via the toggle for the spreadsheet.
|
||||
setMode('classify');
|
||||
setMode(); // the single classify surface
|
||||
|
||||
// Now scan directory (this will trigger store updates and renders)
|
||||
await app.modules.scanner.scanDirectory(dirHandle);
|
||||
}
|
||||
|
|
@ -669,18 +648,8 @@
|
|||
* Handle keyboard shortcuts
|
||||
*/
|
||||
function handleKeyDown(e) {
|
||||
// Ctrl+S - Save All
|
||||
if (e.ctrlKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (!app.dom.saveAllBtn.disabled) {
|
||||
handleSaveAll();
|
||||
}
|
||||
}
|
||||
|
||||
// Escape - Cancel editing
|
||||
if (e.key === 'Escape') {
|
||||
app.modules.spreadsheet.cancelEditing();
|
||||
}
|
||||
// (Spreadsheet Ctrl+S / Escape handlers removed with the Rename-in-place
|
||||
// pane. The By-tracking grid commits edits on change.)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -712,6 +681,7 @@
|
|||
* Update stats display
|
||||
*/
|
||||
function updateStats() {
|
||||
if (!app.dom.totalFiles) return; // spreadsheet pane removed — nothing to update
|
||||
const files = app.modules.store.getDisplayFiles();
|
||||
const totalFiles = files.length;
|
||||
const modifiedFiles = files.filter(f => f.isDirty).length;
|
||||
|
|
|
|||
|
|
@ -493,6 +493,19 @@
|
|||
Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].trackingNodeId) set[k] = true; });
|
||||
return Object.keys(set);
|
||||
}
|
||||
// A file was renamed on disk to its canonical name — it's done, so drop it from
|
||||
// the grid model entirely (under its OLD key): clear both placements, the
|
||||
// workset, the assignment, and any worklist-row binding. The compliant file
|
||||
// object itself stays in the scanned tree. No notify — the caller batches.
|
||||
function forgetFile(key) {
|
||||
var a = state.assignments[key], oldTrack = a ? a.trackingNodeId : null;
|
||||
delete state.trackingWorkset[key];
|
||||
place([key], null, 'tracking');
|
||||
place([key], null, 'transmittal');
|
||||
delete state.assignments[key];
|
||||
state.worklist.forEach(function (r) { if (r.placed) delete r.placed[key]; if (r.bound) delete r.bound[key]; });
|
||||
if (oldTrack) pruneEmptyTrackingChain(oldTrack);
|
||||
}
|
||||
// Re-materialize a file's tracking placement from a full identity. The caller
|
||||
// passes ALL three fields (current values for the ones it didn't edit), read
|
||||
// from deriveTarget — so this module needs no file objects. A blank revision
|
||||
|
|
@ -578,6 +591,13 @@
|
|||
notify();
|
||||
}
|
||||
function clearWorklist() { state.worklist = []; notify(); } // rows only — assignments survive
|
||||
// Remove ONE worklist row (a placeholder's ✕). Any files it placed keep their
|
||||
// assignments — only the scratch row goes away.
|
||||
function removeWorklistRow(id) {
|
||||
var before = state.worklist.length;
|
||||
state.worklist = state.worklist.filter(function (r) { return r.id !== id; });
|
||||
if (state.worklist.length !== before) notify();
|
||||
}
|
||||
function getWorklist() { return state.worklist; }
|
||||
function getWorklistRow(id) { return state.worklist.filter(function (r) { return r.id === id; })[0] || null; }
|
||||
|
||||
|
|
@ -962,8 +982,9 @@
|
|||
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
|
||||
// By-tracking grid
|
||||
addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid,
|
||||
trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity,
|
||||
trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity, forgetFile: forgetFile,
|
||||
setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist,
|
||||
removeWorklistRow: removeWorklistRow,
|
||||
getWorklist: getWorklist, getWorklistRow: getWorklistRow,
|
||||
assignFromRow: assignFromRow, unassignRowFile: unassignRowFile,
|
||||
setRowTracking: setRowTracking, setRowTitle: setRowTitle,
|
||||
|
|
|
|||
83
classifier/js/rename.js
Normal file
83
classifier/js/rename.js
Normal 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 };
|
||||
})();
|
||||
|
|
@ -19,10 +19,7 @@
|
|||
var collapsed = {}; // nodeId -> true when collapsed (default expanded)
|
||||
var openForm = null; // { partyId, slot } when a bin form is open
|
||||
var initialized = false;
|
||||
var currentTab = 'tracking'; // 'tracking' | 'worklist' | 'transmittal' — active tab
|
||||
var worklistGrid = null; // the seltable controller for the "From a list" tab
|
||||
var worklistPlaced = {}; // trackingNumber -> placed files (read by the Files cell)
|
||||
var hideAssigned = false; // "Hide assigned" toggle in the From-a-list toolbar
|
||||
var currentTab = 'tracking'; // 'tracking' | 'transmittal' — active tab
|
||||
var listScanned = false; // a Load has run this session (drives the "new" badge)
|
||||
|
||||
function init() {
|
||||
|
|
@ -30,49 +27,43 @@
|
|||
initialized = true;
|
||||
els = {
|
||||
trackingTab: document.getElementById('trackingTab'),
|
||||
worklistTab: document.getElementById('worklistTab'),
|
||||
transmittalTab: document.getElementById('transmittalTab'),
|
||||
trackingPanel: document.getElementById('trackingPanel'),
|
||||
transmittalPanel: document.getElementById('transmittalPanel'),
|
||||
worklistPanel: document.getElementById('worklistPanel'),
|
||||
trackingTree: document.getElementById('trackingTree'),
|
||||
transmittalTree: document.getElementById('transmittalTree'),
|
||||
worklistTable: document.getElementById('worklistTable'),
|
||||
loadWorklistBtn: document.getElementById('loadWorklistBtn'),
|
||||
pasteRowsBtn: document.getElementById('pasteRowsBtn'),
|
||||
matchNamesBtn: document.getElementById('matchNamesBtn'),
|
||||
clearListBtn: document.getElementById('clearListBtn'),
|
||||
hideAssignedToggle: document.getElementById('hideAssignedToggle'),
|
||||
addFilteredBtn: document.getElementById('addFilteredBtn'),
|
||||
renameBtn: document.getElementById('renameBtn'),
|
||||
trackingColsBtn: document.getElementById('trackingColsBtn'),
|
||||
addPartyBtn: document.getElementById('addPartyBtn'),
|
||||
stats: document.getElementById('classifyStats'),
|
||||
};
|
||||
|
||||
els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
|
||||
if (els.worklistTab) els.worklistTab.addEventListener('click', function () { showTab('worklist'); });
|
||||
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
|
||||
if (els.loadWorklistBtn) els.loadWorklistBtn.addEventListener('click', loadWorklist);
|
||||
if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); });
|
||||
if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog);
|
||||
if (els.addFilteredBtn) els.addFilteredBtn.addEventListener('click', addFilteredFiles);
|
||||
if (els.renameBtn) els.renameBtn.addEventListener('click', renameInPlace);
|
||||
if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () {
|
||||
var list = C().getWorklist();
|
||||
if (!list.length) return;
|
||||
if (!list.length) { window.zddc.toast('No list rows to clear.', 'info'); return; }
|
||||
// Warn before stranding files that still need a revision: they stay
|
||||
// assigned (on a "pending" leaf under By tracking number), but the
|
||||
// row you'd use to finish them here is about to disappear.
|
||||
// assigned (on a "pending" leaf), but the placeholder row goes away.
|
||||
var pending = 0;
|
||||
list.forEach(function (r) { if (!(r.revisionCell || '').trim()) pending += Object.keys(r.placed || {}).length; });
|
||||
if (pending && !confirm(pending + ' file' + (pending === 1 ? '' : 's') + ' still need a revision. They stay assigned (a “pending” folder under By tracking number), but the list row to finish them here goes away. Clear anyway?')) return;
|
||||
if (pending && !confirm(pending + ' file' + (pending === 1 ? '' : 's') + ' still need a revision. They stay assigned (a “pending” folder), but the list rows to finish them here go away. Clear anyway?')) return;
|
||||
C().clearWorklist();
|
||||
window.zddc.toast('List cleared — every assignment is kept (see By tracking number).', 'info');
|
||||
window.zddc.toast('List rows cleared — every assignment is kept.', 'info');
|
||||
});
|
||||
if (els.hideAssignedToggle) els.hideAssignedToggle.addEventListener('change', function () {
|
||||
hideAssigned = !!els.hideAssignedToggle.checked;
|
||||
if (worklistGrid) worklistGrid.renderBody();
|
||||
});
|
||||
// Ctrl-V anywhere on the From-a-list panel opens the paste dialog prefilled.
|
||||
if (els.worklistPanel) els.worklistPanel.addEventListener('paste', function (e) {
|
||||
if (currentTab !== 'worklist') return;
|
||||
// Ctrl-V on the By-tracking panel opens the paste dialog prefilled.
|
||||
if (els.trackingPanel) els.trackingPanel.addEventListener('paste', function (e) {
|
||||
if (currentTab !== 'tracking') return;
|
||||
if (e.target && e.target.closest('input, textarea')) return; // let real inputs paste
|
||||
var t = (e.clipboardData || window.clipboardData);
|
||||
var text = t ? t.getData('text') : '';
|
||||
|
|
@ -129,19 +120,16 @@
|
|||
}
|
||||
|
||||
function showTab(which) {
|
||||
currentTab = (which === 'transmittal' || which === 'worklist') ? which : 'tracking';
|
||||
currentTab = (which === 'transmittal') ? 'transmittal' : 'tracking';
|
||||
els.trackingTab.classList.toggle('active', currentTab === 'tracking');
|
||||
if (els.worklistTab) els.worklistTab.classList.toggle('active', currentTab === 'worklist');
|
||||
els.transmittalTab.classList.toggle('active', currentTab === 'transmittal');
|
||||
els.trackingPanel.hidden = currentTab !== 'tracking';
|
||||
if (els.worklistPanel) els.worklistPanel.hidden = currentTab !== 'worklist';
|
||||
els.transmittalPanel.hidden = currentTab !== 'transmittal';
|
||||
render();
|
||||
// The source-tree Show filters are per-axis, so the visible set changes
|
||||
// with the active tab — re-render the left tree.
|
||||
reRenderSource();
|
||||
}
|
||||
// "From a list" drops materialize tracking placements, so its axis is 'tracking'.
|
||||
function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; }
|
||||
function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); }
|
||||
|
||||
|
|
@ -153,21 +141,40 @@
|
|||
var placed = buildPlaced(files);
|
||||
renderTrackingGrid(els.trackingTree);
|
||||
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
|
||||
renderWorklist(placed.byTracking);
|
||||
renderStats(files);
|
||||
}
|
||||
|
||||
// Files in the grid whose NAME is complete (tracking + rev + title) — the
|
||||
// candidates for an in-place rename, regardless of transmittal.
|
||||
function renameableFiles() {
|
||||
var c = C(), out = [];
|
||||
c.trackingGridKeys().forEach(function (k) {
|
||||
var f = fileByKey(k); if (!f) return;
|
||||
if (!nameErrors(c.deriveTarget(f)).length) out.push(f);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderStats(files) {
|
||||
var s = C().stats(files);
|
||||
if (els.stats) {
|
||||
els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · '
|
||||
+ s.none + ' unassigned · ' + s.excluded + ' excluded';
|
||||
}
|
||||
// Copy… lives on the transmittal tab — enabled once files are fully done
|
||||
// (tracking leaf AND transmittal).
|
||||
var copyBtn = document.getElementById('copyOutputBtn');
|
||||
if (copyBtn) {
|
||||
copyBtn.disabled = s.done === 0;
|
||||
copyBtn.textContent = s.done ? ('Copy ' + s.done + '…') : 'Copy…';
|
||||
}
|
||||
// Rename… lives on the By-tracking tab — enabled once any grid file has a
|
||||
// complete name (transmittal not required).
|
||||
if (els.renameBtn) {
|
||||
var n = renameableFiles().length;
|
||||
els.renameBtn.disabled = n === 0;
|
||||
els.renameBtn.textContent = n ? ('Rename ' + n + '…') : 'Rename…';
|
||||
}
|
||||
}
|
||||
|
||||
function el(tag, cls, text) {
|
||||
|
|
@ -244,12 +251,16 @@
|
|||
// seltable's own persistKey storage (merged, not clobbered). ───────────
|
||||
var GRID_COL_META = [
|
||||
{ id: 'status', title: 'Status', fixed: true }, // fixed = never hidden by the chooser
|
||||
{ id: 'orig', title: 'Original name' },
|
||||
{ id: 'orig', title: 'Original / expected name' },
|
||||
{ id: 'tn', title: 'Tracking number' },
|
||||
{ id: 'rev', title: 'Rev (status)' },
|
||||
{ id: 'title', title: 'Title' },
|
||||
{ id: 'src', title: 'Source', defaultHidden: true }, // folded in from "From a list"
|
||||
{ id: 'latest', title: 'Latest rev', defaultHidden: true },
|
||||
{ id: 'x', title: '', fixed: true },
|
||||
];
|
||||
// A column is hidden if the prefs say so explicitly, else by its defaultHidden.
|
||||
function colHidden(meta, hidden) { return (meta.id in hidden) ? !!hidden[meta.id] : !!meta.defaultHidden; }
|
||||
var GRID_PREFS_KEY = 'zddc.classifier.trackingCols';
|
||||
function gridPrefs() { try { return JSON.parse(localStorage.getItem(GRID_PREFS_KEY)) || {}; } catch (_) { return {}; } }
|
||||
function saveGridPrefs(p) { try { localStorage.setItem(GRID_PREFS_KEY, JSON.stringify(p)); } catch (_) { /* private mode */ } }
|
||||
|
|
@ -273,6 +284,25 @@
|
|||
// different tab, so its "not placed in a transmittal" error doesn't count here.
|
||||
function nameErrors(d) { return (d.errors || []).filter(function (e) { return e.indexOf('transmittal') === -1; }); }
|
||||
|
||||
// ── unified rows: file rows + placeholder rows ─────────────────────────
|
||||
// A row is a boring 0-or-1-file thing:
|
||||
// { kind:'file', file, wl:null, id:'f:'+srcKey }
|
||||
// { kind:'placeholder', file:null, wl, id:'p:'+rowId } (a list row with no
|
||||
// file yet — drop/match a file on it and it becomes a file row)
|
||||
function joinName(f) { return f.originalFilename + (f.extension ? '.' + f.extension : ''); }
|
||||
function isFile(row) { return row.kind === 'file'; }
|
||||
function gridRows() {
|
||||
var c = C(), out = [];
|
||||
c.trackingGridKeys().forEach(function (k) {
|
||||
var f = fileByKey(k); if (f) out.push({ kind: 'file', file: f, wl: null, id: 'f:' + k });
|
||||
});
|
||||
c.getWorklist().forEach(function (r) {
|
||||
if (!Object.keys(r.placed || {}).length) out.push({ kind: 'placeholder', file: null, wl: r, id: 'p:' + r.id });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
function sourceStr(r) { var s = r.source || {}; return [s.mdl ? 'mdl' : '', s.archive ? 'arch' : '', s.pasted ? 'pasted' : ''].filter(Boolean).join(' '); }
|
||||
|
||||
// ── per-column cell renderers ──────────────────────────────────────────
|
||||
function gridStatusCell(td, f) {
|
||||
var c = C(), key = c.srcKeyForFile(f), d = c.deriveTarget(f), conflict = c.hasHashConflict(key);
|
||||
|
|
@ -281,63 +311,88 @@
|
|||
badge.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content. ' : '') + (ne.length ? ne.join('; ') : 'Complete');
|
||||
td.appendChild(badge);
|
||||
}
|
||||
function gridPlaceholderStatus(td) {
|
||||
var dot = el('span', 'tfile__badge tg-wanted', '◇');
|
||||
dot.title = 'Awaiting a file — drag one onto this row, or use ⚡ Match names.';
|
||||
td.appendChild(dot);
|
||||
}
|
||||
function gridOrigCell(td, f) {
|
||||
var key = C().srcKeyForFile(f);
|
||||
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
|
||||
var key = C().srcKeyForFile(f), orig = joinName(f);
|
||||
var link = el('a', 'tg-orig__link', orig);
|
||||
link.href = '#'; link.title = 'Preview ' + orig;
|
||||
link.addEventListener('click', function (e) { e.preventDefault(); previewKey(key); });
|
||||
td.appendChild(link);
|
||||
}
|
||||
function gridExpectedCell(td, wl) {
|
||||
var name = (wl.currentName || '').trim();
|
||||
var span = el('span', 'tg-expected', name || '(drag a file here)');
|
||||
span.title = name ? ('Expected file: ' + name + ' — drag it on, or ⚡ Match names.') : 'Drag a file onto this row to name it.';
|
||||
td.appendChild(span);
|
||||
}
|
||||
// Editable cell for a FILE row → writes the file's identity (placement).
|
||||
function gridEditCell(td, colId, f) {
|
||||
var c = C(), key = c.srcKeyForFile(f), ident = currentIdent(f);
|
||||
var value = colId === 'tn' ? ident.tracking : colId === 'rev' ? ident.rev : ident.title;
|
||||
var ph = colId === 'tn' ? 'ACME-…-0001' : colId === 'rev' ? 'A (IFR)' : 'title';
|
||||
var warn = colId === 'tn' ? gridTnWarn(ident.tracking) : '';
|
||||
var inp = el('input', 'tg-input' + (warn ? ' is-warn' : ''));
|
||||
inp.type = 'text'; inp.value = value || ''; inp.placeholder = ph; inp.spellcheck = false;
|
||||
inp.setAttribute('data-no-select', ''); // a click in the input must not toggle row selection
|
||||
if (warn) inp.title = warn;
|
||||
inp.addEventListener('change', function () {
|
||||
editCell(td, 'tg-input', value, ph, function (v) {
|
||||
var cur = currentIdent(f); // re-read so a prior edit isn't clobbered
|
||||
if (colId === 'tn') cur.tracking = inp.value.trim();
|
||||
else if (colId === 'rev') cur.rev = inp.value.trim();
|
||||
else cur.title = inp.value;
|
||||
if (colId === 'tn') cur.tracking = v;
|
||||
else if (colId === 'rev') cur.rev = v;
|
||||
else cur.title = v;
|
||||
c.setFileIdentity(key, cur);
|
||||
});
|
||||
td.appendChild(inp);
|
||||
}, warn);
|
||||
}
|
||||
// Editable cell for a PLACEHOLDER row → writes the worklist row.
|
||||
function gridRowEditCell(td, colId, wl) {
|
||||
var c = C();
|
||||
if (colId === 'tn') editCell(td, 'tg-input', wl.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(wl.id, v); }, tnWarn(wl));
|
||||
else if (colId === 'rev') editCell(td, 'tg-input', wl.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(wl.id, v); });
|
||||
else editCell(td, 'tg-input', wl.title, 'title', function (v) { c.setRowTitle(wl.id, v); });
|
||||
}
|
||||
function gridRemoveCell(td, f) {
|
||||
var c = C(), key = c.srcKeyForFile(f);
|
||||
var rm = el('button', 'tnode__act tg-x__btn', '✕');
|
||||
rm.title = 'Remove from the grid';
|
||||
var rm = el('button', 'tnode__act tg-x__btn', '✕'); rm.title = 'Remove from the grid';
|
||||
rm.addEventListener('click', function () { c.removeFromTrackingGrid(key); });
|
||||
td.appendChild(rm);
|
||||
}
|
||||
function gridRowRemoveCell(td, wl) {
|
||||
var c = C();
|
||||
var rm = el('button', 'tnode__act tg-x__btn', '✕'); rm.title = 'Remove this list row (any assignments are kept)';
|
||||
rm.addEventListener('click', function () { c.removeWorklistRow(wl.id); });
|
||||
td.appendChild(rm);
|
||||
}
|
||||
// Build the seltable column array, dropping any the chooser has hidden. Each
|
||||
// column's `get` feeds sort + filter; `render` paints the (editable) cell.
|
||||
// column's `get` feeds sort + filter; `render` paints the cell. get/render
|
||||
// receive the unified row wrapper and dispatch on kind.
|
||||
function trackingColumns() {
|
||||
var c = C(), hidden = (gridPrefs().hidden || {});
|
||||
var hidden = (gridPrefs().hidden || {});
|
||||
var defs = {
|
||||
status: { key: 'status', title: 'Status', cls: 'tg-status', filterable: false,
|
||||
get: function (f) { var d = c.deriveTarget(f); return c.hasHashConflict(c.srcKeyForFile(f)) ? 'conflict' : (nameErrors(d).length ? 'incomplete' : 'ok'); },
|
||||
render: function (f, td) { gridStatusCell(td, f); } },
|
||||
orig: { key: 'orig', title: 'Original name', cls: 'tg-orig',
|
||||
get: function (f) { return f.originalFilename + (f.extension ? '.' + f.extension : ''); },
|
||||
render: function (f, td) { gridOrigCell(td, f); } },
|
||||
get: function (r) { if (!isFile(r)) return 'awaiting'; var d = C().deriveTarget(r.file); return C().hasHashConflict(C().srcKeyForFile(r.file)) ? 'conflict' : (nameErrors(d).length ? 'incomplete' : 'ok'); },
|
||||
render: function (r, td) { isFile(r) ? gridStatusCell(td, r.file) : gridPlaceholderStatus(td); } },
|
||||
orig: { key: 'orig', title: 'Original / expected name', cls: 'tg-orig',
|
||||
get: function (r) { return isFile(r) ? joinName(r.file) : (r.wl.currentName || ''); },
|
||||
render: function (r, td) { isFile(r) ? gridOrigCell(td, r.file) : gridExpectedCell(td, r.wl); } },
|
||||
tn: { key: 'tn', title: 'Tracking number', cls: 'tg-tn',
|
||||
get: function (f) { return currentIdent(f).tracking; },
|
||||
render: function (f, td) { gridEditCell(td, 'tn', f); } },
|
||||
get: function (r) { return isFile(r) ? currentIdent(r.file).tracking : (r.wl.trackingNumber || ''); },
|
||||
render: function (r, td) { isFile(r) ? gridEditCell(td, 'tn', r.file) : gridRowEditCell(td, 'tn', r.wl); } },
|
||||
rev: { key: 'rev', title: 'Rev (status)', cls: 'tg-rev',
|
||||
get: function (f) { return currentIdent(f).rev; },
|
||||
render: function (f, td) { gridEditCell(td, 'rev', f); } },
|
||||
get: function (r) { return isFile(r) ? currentIdent(r.file).rev : (r.wl.revisionCell || ''); },
|
||||
render: function (r, td) { isFile(r) ? gridEditCell(td, 'rev', r.file) : gridRowEditCell(td, 'rev', r.wl); } },
|
||||
title: { key: 'title', title: 'Title', cls: 'tg-title',
|
||||
get: function (f) { return currentIdent(f).title; },
|
||||
render: function (f, td) { gridEditCell(td, 'title', f); } },
|
||||
get: function (r) { return isFile(r) ? currentIdent(r.file).title : (r.wl.title || ''); },
|
||||
render: function (r, td) { isFile(r) ? gridEditCell(td, 'title', r.file) : gridRowEditCell(td, 'title', r.wl); } },
|
||||
src: { key: 'src', title: 'Source', cls: 'worklist-src', sortable: false,
|
||||
get: function (r) { return isFile(r) ? '' : sourceStr(r.wl); },
|
||||
render: function (r, td) { if (!isFile(r)) renderSource(r.wl, td); } },
|
||||
latest: { key: 'latest', title: 'Latest rev', cls: 'tg-latest',
|
||||
get: function (r) { return isFile(r) ? '' : latestRevOf(r.wl.archiveRevisions); },
|
||||
render: function (r, td) { td.textContent = isFile(r) ? '' : latestRevOf(r.wl.archiveRevisions); } },
|
||||
x: { key: 'x', title: '', cls: 'tg-x', sortable: false, filterable: false,
|
||||
render: function (f, td) { gridRemoveCell(td, f); } },
|
||||
render: function (r, td) { isFile(r) ? gridRemoveCell(td, r.file) : gridRowRemoveCell(td, r.wl); } },
|
||||
};
|
||||
return GRID_COL_META.filter(function (m) { return !hidden[m.id]; }).map(function (m) { return defs[m.id]; });
|
||||
return GRID_COL_META.filter(function (m) { return !colHidden(m, hidden); }).map(function (m) { return defs[m.id]; });
|
||||
}
|
||||
|
||||
var trackingGrid = null, trackingColSig = '';
|
||||
|
|
@ -348,23 +403,27 @@
|
|||
trackingColSig = colSig();
|
||||
trackingGrid = window.app.modules.seltable.create({
|
||||
container: container,
|
||||
rows: function () { return c.trackingGridKeys().map(fileByKey).filter(Boolean); },
|
||||
rowId: function (f) { return c.srcKeyForFile(f); },
|
||||
rows: gridRows,
|
||||
rowId: function (r) { return r.id; },
|
||||
columns: trackingColumns(),
|
||||
persistKey: GRID_PREFS_KEY,
|
||||
onRowDrop: function (id, keys) {
|
||||
if (id.indexOf('p:') === 0) fillFromRow(id, keys); // placeholder → bind, fan out over consecutive placeholders
|
||||
else onGridDrop(keys); // file row → just add the dragged files
|
||||
},
|
||||
});
|
||||
trackingGrid.render();
|
||||
return trackingGrid;
|
||||
}
|
||||
function renderTrackingGrid(container) {
|
||||
// Empty ↔ populated transition: tear the seltable down for the prompt,
|
||||
// re-create it (create-once) when files arrive — same as the worklist.
|
||||
if (!C().trackingGridKeys().length) {
|
||||
// re-create it (create-once) when rows arrive.
|
||||
if (!gridRows().length) {
|
||||
trackingGrid = null;
|
||||
container.textContent = '';
|
||||
container.classList.remove('seltable');
|
||||
container.appendChild(el('div', 'target-empty',
|
||||
'No files yet — drag files here from the left, then type each one’s tracking number, revision, and title. A file that’s already ZDDC-named fills in automatically.'));
|
||||
'No files yet — drag files in (or “⊕ Add filtered files”), or “⊞ Load…” / “⎘ Paste rows…” a list of tracking numbers and drop the matching files on. A file that’s already ZDDC-named fills in automatically.'));
|
||||
return;
|
||||
}
|
||||
// The Columns ▾ chooser changes which columns exist → rebuild on mismatch
|
||||
|
|
@ -375,6 +434,19 @@
|
|||
// (setFilter re-renders the body, so the rows are always fresh on render).
|
||||
trackingGrid.setFilter(rfTerms.join(' '));
|
||||
}
|
||||
// Drop N dragged files onto a starting placeholder row → bind file[i] to the
|
||||
// i-th consecutive PLACEHOLDER row from there (Excel-style column fill). A
|
||||
// single file just binds to the one row.
|
||||
function fillFromRow(startId, keys) {
|
||||
var c = C();
|
||||
var rows = trackingGrid ? trackingGrid.getFilteredRows() : gridRows();
|
||||
var placeholders = rows.filter(function (r) { return r.kind === 'placeholder'; });
|
||||
var start = placeholders.map(function (r) { return r.id; }).indexOf(startId);
|
||||
if (start < 0) { var row0 = c.getWorklistRow(startId.slice(2)); if (row0) c.assignFromRow(keys, row0); return; }
|
||||
for (var i = 0; i < keys.length && (start + i) < placeholders.length; i++) {
|
||||
c.assignFromRow([keys[i]], placeholders[start + i].wl);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag files onto the grid → add as rows; auto-fill any already ZDDC-named.
|
||||
function setupGridDrop(container) {
|
||||
|
|
@ -409,10 +481,10 @@
|
|||
GRID_COL_META.forEach(function (col) {
|
||||
if (col.fixed) return;
|
||||
var lbl = el('label', 'col-chooser__item');
|
||||
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !hidden[col.id];
|
||||
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !colHidden(col, hidden);
|
||||
cb.addEventListener('change', function () {
|
||||
var p = gridPrefs(); p.hidden = p.hidden || {};
|
||||
if (cb.checked) delete p.hidden[col.id]; else p.hidden[col.id] = true;
|
||||
p.hidden[col.id] = !cb.checked; // explicit (overrides defaultHidden)
|
||||
saveGridPrefs(p);
|
||||
trackingGrid = null; // column set changed → rebuild the seltable
|
||||
render();
|
||||
|
|
@ -518,54 +590,53 @@
|
|||
return form;
|
||||
}
|
||||
|
||||
// ── "From a list" (scratch worklist via the shared seltable) ────────────
|
||||
function renderWorklist(placedByTracking) {
|
||||
worklistPlaced = placedByTracking || {};
|
||||
if (!C().getWorklist().length) {
|
||||
worklistGrid = null;
|
||||
els.worklistTable.textContent = '';
|
||||
els.worklistTable.appendChild(el('div', 'target-empty', 'Empty — “Load…” numbers from the archive/MDL, “Paste rows…” from Excel, or “⚡ Match names”. Then drag files onto a row to name them. The list is a scratch pad — clearing it keeps every assignment (see By tracking number).'));
|
||||
function setGridStatus(text) {
|
||||
var s = document.getElementById('scanStatus');
|
||||
if (s) { s.textContent = text; s.classList.toggle('scanning', !!text); }
|
||||
}
|
||||
|
||||
// "⊕ Add filtered files" — pull every file the LEFT tree filter currently
|
||||
// shows into the grid (across collapsed folders too); already-ZDDC-named files
|
||||
// fill in automatically (onGridDrop does the parse).
|
||||
function addFilteredFiles() {
|
||||
var tree = window.app.modules.tree;
|
||||
var files = (tree && tree.filteredFiles) ? tree.filteredFiles() : [];
|
||||
if (!files.length) { window.zddc.toast('No files to add — the tree filter shows none.', 'info'); return; }
|
||||
onGridDrop(files.map(function (f) { return C().srcKeyForFile(f); }));
|
||||
window.zddc.toast('Added ' + files.length + ' file' + (files.length === 1 ? '' : 's') + ' to the grid.', 'success');
|
||||
}
|
||||
|
||||
// "Rename…" — rename the grid's NAME-COMPLETE files ON DISK, in place.
|
||||
// DESTRUCTIVE: no backup. A renamed file is now correctly named, so it leaves
|
||||
// the grid (forgetFile). Resumable: already-correct files are skipped.
|
||||
async function renameInPlace() {
|
||||
var c = C();
|
||||
var ready = renameableFiles();
|
||||
var items = ready.map(function (f) {
|
||||
return { file: f, oldKey: c.srcKeyForFile(f), newName: c.deriveTarget(f).filename };
|
||||
}).filter(function (x) { return x.newName && x.newName !== joinName(x.file); });
|
||||
if (!items.length) {
|
||||
window.zddc.toast(ready.length ? 'Those files are already correctly named.' : 'No name-complete files to rename — fill in tracking number, revision and title.', 'info');
|
||||
return;
|
||||
}
|
||||
ensureWorklistGrid();
|
||||
worklistGrid.renderBody();
|
||||
}
|
||||
function rowPlaced(r) { var f = worklistPlaced[r.trackingNumber]; return f && f.length ? f : null; }
|
||||
function ensureWorklistGrid() {
|
||||
if (worklistGrid) return worklistGrid;
|
||||
var c = C();
|
||||
var cols = [
|
||||
{ key: 'tn', title: 'Tracking number', cls: 'worklist-tn', get: function (r) { return r.trackingNumber || ''; },
|
||||
render: function (r, td) { editCell(td, 'worklist-tn__input', r.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(r.id, v); }, tnWarn(r)); } },
|
||||
{ key: 'title', title: 'Title', cls: 'worklist-title', get: function (r) { return r.title || ''; },
|
||||
render: function (r, td) { editCell(td, 'worklist-title__input', r.title, 'title', function (v) { c.setRowTitle(r.id, v); }); } },
|
||||
{ key: 'cur', title: 'Current name', cls: 'worklist-cur', get: function (r) { return r.currentName || ''; } },
|
||||
{ key: 'src', title: 'Source', cls: 'worklist-src', sortable: false, filterable: false,
|
||||
get: function (r) { var s = r.source || {}; return [s.mdl ? 'mdl' : '', s.archive ? 'arch' : '', s.pasted ? 'pasted' : ''].filter(Boolean).join(' '); },
|
||||
render: function (r, td) { renderSource(r, td); } },
|
||||
{ key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } },
|
||||
{ key: 'rev', title: 'Revision', cls: 'worklist-rev', get: function (r) { return r.revisionCell; },
|
||||
render: function (r, td) { editCell(td, 'worklist-rev__input', r.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(r.id, v); }); } },
|
||||
];
|
||||
worklistGrid = window.app.modules.seltable.create({
|
||||
container: els.worklistTable,
|
||||
extraTitle: 'Files',
|
||||
rows: function () {
|
||||
var list = c.getWorklist();
|
||||
return hideAssigned ? list.filter(function (r) { return !rowPlaced(r); }) : list;
|
||||
},
|
||||
rowId: function (r) { return r.id; },
|
||||
columns: cols,
|
||||
onRowDrop: function (rowId, keys) { var row = c.getWorklistRow(rowId); if (row) c.assignFromRow(keys, row); },
|
||||
onActivate: function (ids) {
|
||||
if (!ids.length) return;
|
||||
var v = prompt('Set the revision on ' + ids.length + ' selected row(s) (e.g. "A (IFR)"):', '');
|
||||
if (v != null) c.setRevisionCells(ids, v.trim());
|
||||
},
|
||||
rowExtra: function (r, td) { renderWorklistFiles(r, td); },
|
||||
});
|
||||
worklistGrid.render();
|
||||
return worklistGrid;
|
||||
var preview = items.slice(0, 4).map(function (x) { return ' ' + joinName(x.file) + ' → ' + x.newName; }).join('\n');
|
||||
var msg = '⚠ RENAME ' + items.length + ' FILE' + (items.length === 1 ? '' : 'S') + ' IN PLACE — this EDITS YOUR SOURCE FILES on disk.\n\n'
|
||||
+ 'There is NO backup and it cannot be undone. Renamed files are now correctly named, so they leave the grid.\n\n'
|
||||
+ preview + (items.length > 4 ? ('\n …and ' + (items.length - 4) + ' more') : '')
|
||||
+ '\n\nRename these files in place now?';
|
||||
if (!confirm(msg)) return;
|
||||
setGridStatus('Renaming…');
|
||||
var done = 0, errors = 0;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
setGridStatus('Renaming… ' + (i + 1) + '/' + items.length + ' — ' + items[i].newName);
|
||||
try { await window.app.modules.rename.renameTo(items[i].file, items[i].newName); c.forgetFile(items[i].oldKey); done++; }
|
||||
catch (e) { errors++; window.zddc.toast('Rename failed for ' + items[i].newName + ' — ' + (e.message || e), 'error'); }
|
||||
}
|
||||
setGridStatus('');
|
||||
render();
|
||||
reRenderSource();
|
||||
window.zddc.toast('Renamed ' + done + ' file' + (done === 1 ? '' : 's') + ' in place'
|
||||
+ (errors ? (', ' + errors + ' failed (retry)') : '') + '. Source files updated.', errors ? 'warning' : 'success');
|
||||
}
|
||||
// An editable seltable cell: an <input> that commits on change. `warn` is an
|
||||
// optional tooltip that flags (without blocking) a questionable value.
|
||||
|
|
@ -596,30 +667,6 @@
|
|||
td.appendChild(el('span', 'src-badge src-badge--pasted', 'pasted'));
|
||||
}
|
||||
}
|
||||
function renderWorklistFiles(row, td) {
|
||||
var c = C(), files = rowPlaced(row) || [];
|
||||
files.forEach(function (f) {
|
||||
var d = c.deriveTarget(f);
|
||||
var a = c.getAssignment(d.key) || {};
|
||||
var line = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
|
||||
line.dataset.key = d.key; line.draggable = true;
|
||||
line.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); });
|
||||
var nm = el('span', 'mdlfile__name', d.filename || '(set a revision)');
|
||||
nm.title = 'from ' + f.originalFilename + (f.extension ? '.' + f.extension : '');
|
||||
line.appendChild(nm);
|
||||
var usingRow = a.titleOverride != null && row.title && a.titleOverride === row.title.trim();
|
||||
var tgl = el('button', 'tnode__act', usingRow ? 'Title: row' : 'Title: file');
|
||||
tgl.title = 'Use the row’s title or the file’s own';
|
||||
tgl.addEventListener('click', function () { c.setTitleOverride(d.key, usingRow ? '' : row.title); });
|
||||
line.appendChild(tgl);
|
||||
var rm = el('button', 'tnode__act tfile__remove', '✕');
|
||||
rm.title = 'Remove this file from the row';
|
||||
rm.addEventListener('click', function () { c.unassignRowFile(row, d.key); });
|
||||
line.appendChild(rm);
|
||||
td.appendChild(line);
|
||||
});
|
||||
}
|
||||
|
||||
// "From a list" loader: "Load…" opens a multi-select directory tree (scoped
|
||||
// to the served context); every ticked directory is walked recursively into
|
||||
// the union of existing files + MDL deliverables, deduped by tracking number
|
||||
|
|
@ -719,7 +766,7 @@
|
|||
function finishLoad(rows) {
|
||||
listScanned = true;
|
||||
C().appendWorklist(rows); // APPEND — the list accumulates across batches
|
||||
showTab('worklist');
|
||||
showTab('tracking');
|
||||
window.zddc.toast(rows.length
|
||||
? ('Added ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.')
|
||||
: 'No files or deliverables in the selected directories.', rows.length ? 'success' : 'warning');
|
||||
|
|
@ -800,7 +847,7 @@
|
|||
add.addEventListener('click', function () {
|
||||
var n = parsed.rows.length;
|
||||
c.appendWorklist(parsed.rows);
|
||||
m.close(); showTab('worklist');
|
||||
m.close(); showTab('tracking');
|
||||
var assigned = autoAssignByName();
|
||||
var msg = 'Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.';
|
||||
if (assigned) msg += ' Auto-assigned ' + assigned + ' file' + (assigned === 1 ? '' : 's') + ' by current name.';
|
||||
|
|
@ -852,7 +899,7 @@
|
|||
var p = proposals[Number(cb.dataset.i)];
|
||||
if (p) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; }
|
||||
});
|
||||
m.close(); showTab('worklist');
|
||||
m.close(); showTab('tracking');
|
||||
window.zddc.toast('Assigned ' + n + ' file' + (n === 1 ? '' : 's') + ' by name match.', n ? 'success' : 'info');
|
||||
});
|
||||
fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); });
|
||||
|
|
|
|||
|
|
@ -1005,6 +1005,7 @@
|
|||
setShowFilters,
|
||||
setNameFilter,
|
||||
exportFilteredList,
|
||||
filteredFiles: filteredFileObjects,
|
||||
_buildExportTsv: buildExportTsv
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -30,10 +30,6 @@
|
|||
</div>
|
||||
<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>
|
||||
<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 & 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="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>
|
||||
|
|
@ -96,81 +92,15 @@
|
|||
<div class="resize-handle" id="treeResizeHandle"></div>
|
||||
</aside>
|
||||
|
||||
<!-- Spreadsheet Table (Rename in place) -->
|
||||
<main class="spreadsheet-pane" id="spreadsheetPane" hidden>
|
||||
<div class="pane-header">
|
||||
<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 -->
|
||||
<!-- The classify surface: one editable By-tracking grid + a
|
||||
transmittal tree. Tracking number alone ⇒ a Rename (in place);
|
||||
add a transmittal folder ⇒ a Copy into the archive. -->
|
||||
<main class="target-pane" id="targetPane">
|
||||
<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__group">
|
||||
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</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>
|
||||
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
|
||||
<button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
|
||||
</div>
|
||||
<div class="pane-header-right">
|
||||
<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>
|
||||
<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="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 class="target-body">
|
||||
<section id="trackingPanel" class="target-panel">
|
||||
<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>
|
||||
<span class="target-hint">Drag files in, then type each one’s tracking number, revision (e.g. “A (IFR)”), and title. A file that’s already ZDDC-named fills in automatically. Columns are hideable + resizable.</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>
|
||||
<input type="search" id="trackingFilterInput" class="tree-filter target-filter" spellcheck="false"
|
||||
placeholder="Filter the grid…" aria-label="Filter the tracking grid">
|
||||
|
|
@ -196,28 +130,14 @@
|
|||
<section id="transmittalPanel" class="target-panel" hidden>
|
||||
<div class="target-panel__toolbar">
|
||||
<button id="addPartyBtn" class="btn btn-sm btn-secondary">+ Party</button>
|
||||
<span class="target-hint"><party>/{received,issued}/<transmittal>. Drag files (or a whole folder) into a transmittal.</span>
|
||||
<span class="target-hint"><party>/{received,issued}/<transmittal>. 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>
|
||||
<input type="search" id="transmittalFilterInput" class="tree-filter target-filter" spellcheck="false"
|
||||
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
|
||||
<div id="transmittalTree" class="target-tree"></div>
|
||||
</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>
|
||||
</main>
|
||||
|
||||
|
|
@ -247,26 +167,16 @@
|
|||
<div id="workspaceList" class="ws-list"><!-- rendered --></div>
|
||||
</section>
|
||||
|
||||
<!-- Two-method tutorial -->
|
||||
<!-- One flow, two endings: rename in place or copy to the archive -->
|
||||
<div class="welcome__methods">
|
||||
<section class="method method--primary">
|
||||
<h3 class="method__title">① Classify & copy <span class="method__tag">recommended · non-destructive</span></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>
|
||||
<h3 class="method__title">Classify, then Rename or Copy</h3>
|
||||
<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">
|
||||
<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>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><strong>Copy</strong> when ready → choose an output directory; renamed copies are written as <code><party>/<transmittal>/<name></code>, with duplicates detected.</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>
|
||||
<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>Type each file's <strong>tracking number</strong>, <strong>revision</strong> (e.g. <code>A (IFR)</code>) and <strong>title</strong>.</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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -143,17 +143,20 @@ test('exclude clears placements and reports excluded state', async ({ page }) =>
|
|||
|
||||
// ── Phase 2: mode toggle + target-tree rendering (UI) ──────────────────────
|
||||
|
||||
test('mode switch swaps the spreadsheet pane for the target pane', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
test('the classify surface is the single pane — no mode toggle, no spreadsheet', async ({ page }) => {
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
expect(await page.locator('#targetPane').isHidden()).toBe(false);
|
||||
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(true);
|
||||
await page.click('#modeRenameBtn');
|
||||
expect(await page.locator('#targetPane').isHidden()).toBe(true);
|
||||
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false);
|
||||
expect(await page.locator('#modeClassifyBtn').count()).toBe(0); // toggle removed
|
||||
expect(await page.locator('#modeRenameBtn').count()).toBe(0);
|
||||
expect(await page.locator('#spreadsheetPane').count()).toBe(0); // spreadsheet pane removed
|
||||
expect(await page.locator('#worklistTab').count()).toBe(0); // From-a-list folded in
|
||||
// Two tabs only: By tracking number + By transmittal.
|
||||
await expect(page.locator('#trackingTab')).toBeVisible();
|
||||
await expect(page.locator('#transmittalTab')).toBeVisible();
|
||||
});
|
||||
|
||||
test('target tree renders the By-tracking grid and tabs switch', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
c.reset();
|
||||
|
|
@ -176,7 +179,7 @@ test('target tree renders the By-tracking grid and tabs switch', async ({ page }
|
|||
// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
|
||||
|
||||
test('dropping files onto the By-tracking grid adds rows and auto-fills ZDDC names', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify; c.reset();
|
||||
const plain = { originalFilename: 'messy scan', extension: 'pdf', folderPath: 'R' };
|
||||
|
|
@ -200,7 +203,7 @@ test('dropping files onto the By-tracking grid adds rows and auto-fills ZDDC nam
|
|||
});
|
||||
|
||||
test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
await page.click('#transmittalTab');
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
|
|
@ -232,7 +235,7 @@ test('dropping onto a transmittal bin assigns; dropping on a party row does not'
|
|||
|
||||
// Inject a synthetic scanned tree (no FS Access needed) and render it.
|
||||
async function withSourceTree(page) {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
await page.evaluate(() => {
|
||||
window.app.folderTree = [{
|
||||
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
|
||||
|
|
@ -250,7 +253,7 @@ test('source file rows render with a state dot in classify mode', async ({ page
|
|||
});
|
||||
|
||||
test('Folder Tree renders folders and files in natural, case-insensitive order', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const order = await page.evaluate(() => {
|
||||
window.app.folderTree = [{
|
||||
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
|
||||
|
|
@ -277,7 +280,7 @@ test('Folder Tree renders folders and files in natural, case-insensitive order',
|
|||
});
|
||||
|
||||
test('classify: single-click a source file triggers preview', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const previewed = await page.evaluate(() => {
|
||||
let got = null;
|
||||
window.app.modules.preview.previewFile = (f) => { got = f.originalFilename; }; // capture, skip popup
|
||||
|
|
@ -293,7 +296,7 @@ test('classify: single-click a source file triggers preview', async ({ page }) =
|
|||
});
|
||||
|
||||
test('classify: a folder with files but no subfolders is expandable (drag source)', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
await page.evaluate(() => {
|
||||
window.app.folderTree = [{
|
||||
name: 'Leaf', path: 'Leaf', expanded: false, scanState: 'done',
|
||||
|
|
@ -352,7 +355,7 @@ test('cross-tree reveal: source→target switches to the placed axis', async ({
|
|||
// ── Phase 5: copy-out engine + duplicate detection (mock FS handles) ───────
|
||||
|
||||
test('copy: writes the file, then resumes by skipping an existing target', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const res = await page.evaluate(async () => {
|
||||
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||
const store = {};
|
||||
|
|
@ -392,7 +395,7 @@ test('copy: writes the file, then resumes by skipping an existing target', async
|
|||
});
|
||||
|
||||
test('copy: two sources mapping to the same output path are a conflict', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const conflicts = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||
const srcFile = (name, folder) => {
|
||||
|
|
@ -507,7 +510,7 @@ test('persist: classify-only autosave preserves the stored snapshot', async ({ p
|
|||
});
|
||||
|
||||
test('copy: snapshot files (no handle) resolve from the workspace root', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const res = await page.evaluate(async () => {
|
||||
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||
const srcStore = { 'Sub/foundation.pdf': 'AAA' };
|
||||
|
|
@ -583,7 +586,7 @@ test('expandFolderPattern: alternation, zero-padded ranges, cartesian product',
|
|||
});
|
||||
|
||||
test('Hide Assigned: hides files dealt-with on the active axis and folders left empty', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const before = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
c.reset();
|
||||
|
|
@ -666,7 +669,7 @@ test('trackingNodeComplete: true only for a leaf with a valid status', async ({
|
|||
});
|
||||
|
||||
test('editing grid cells re-files the file onto the new tracking path', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||
c.reset();
|
||||
|
|
@ -734,7 +737,7 @@ test('dataset (filename-based): import reconstruction rebuilds tracking + shared
|
|||
});
|
||||
|
||||
test('source-tree filter hides non-matches in place; never changes expand state', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
window.app.folderTree = [{
|
||||
name: 'Project', path: 'Project', expanded: true, scanState: 'done', files: [], children: [
|
||||
|
|
@ -765,7 +768,7 @@ test('source-tree filter hides non-matches in place; never changes expand state'
|
|||
});
|
||||
|
||||
test('the By-tracking grid filter narrows rows by name/tracking', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||
c.reset();
|
||||
|
|
@ -783,7 +786,7 @@ test('the By-tracking grid filter narrows rows by name/tracking', async ({ page
|
|||
});
|
||||
|
||||
test('Show Empty off hides folders that contain no files', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
window.app.folderTree = [
|
||||
{ name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [],
|
||||
|
|
@ -801,7 +804,7 @@ test('Show Empty off hides folders that contain no files', async ({ page }) => {
|
|||
});
|
||||
|
||||
test('toggling a Show filter preserves collapse state (no force-expand)', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
window.app.folderTree = [{
|
||||
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
|
||||
|
|
@ -828,7 +831,7 @@ test('toggling a Show filter preserves collapse state (no force-expand)', async
|
|||
});
|
||||
|
||||
test('filter does not open collapsed branches; non-matching siblings hide', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
window.app.folderTree = [{
|
||||
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
|
||||
|
|
@ -856,7 +859,7 @@ test('filter does not open collapsed branches; non-matching siblings hide', asyn
|
|||
});
|
||||
|
||||
test('folder count badge shows post-filter totals', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
window.app.folderTree = [{
|
||||
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
|
||||
|
|
@ -916,7 +919,7 @@ test('snapshot: a scanned zip subtree round-trips with its virtual members', asy
|
|||
});
|
||||
|
||||
test('copy: a zip member is extracted from its archive and written out', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const res = await page.evaluate(async () => {
|
||||
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||
const f = {
|
||||
|
|
@ -985,7 +988,7 @@ test('workspace: import recreates a transferable record (snapshot + map, no hand
|
|||
});
|
||||
|
||||
test('zip mode: collapse turns an expanded archive back into one .zip file', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, sc = window.app.modules.scanner;
|
||||
const member = { originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip', isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf' };
|
||||
|
|
@ -1011,7 +1014,7 @@ test('zip mode: collapse turns an expanded archive back into one .zip file', asy
|
|||
});
|
||||
|
||||
test('a fully-excluded folder is struck through like its files', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tree = window.app.modules.tree;
|
||||
const f1 = { originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' };
|
||||
|
|
@ -1030,7 +1033,7 @@ test('a fully-excluded folder is struck through like its files', async ({ page }
|
|||
});
|
||||
|
||||
test('grid: hiding a column drops its cells; a status badge reflects completeness', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||
c.reset();
|
||||
|
|
@ -1054,7 +1057,7 @@ test('grid: hiding a column drops its cells; a status badge reflects completenes
|
|||
});
|
||||
|
||||
test('grid: the original-name cell is a preview link', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||
c.reset();
|
||||
|
|
@ -1073,7 +1076,7 @@ test('grid: the original-name cell is a preview link', async ({ page }) => {
|
|||
});
|
||||
|
||||
test('Show Partial surfaces files assigned in the other tab only', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tree = window.app.modules.tree, tt = window.app.modules.targetTree;
|
||||
c.reset();
|
||||
|
|
@ -1094,7 +1097,7 @@ test('Show Partial surfaces files assigned in the other tab only', async ({ page
|
|||
});
|
||||
|
||||
test('copy: PUTs into a server-style handle, then resumes by skipping existing', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(async () => {
|
||||
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||
c.reset();
|
||||
|
|
@ -1131,7 +1134,7 @@ test('copy: PUTs into a server-style handle, then resumes by skipping existing',
|
|||
});
|
||||
|
||||
test('copy audit: same name+rev — identical content dedups, different content conflicts', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(async () => {
|
||||
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||
c.reset();
|
||||
|
|
@ -1168,7 +1171,7 @@ test('copy audit: same name+rev — identical content dedups, different content
|
|||
});
|
||||
|
||||
test('copy: verifies copied bytes; a bad write fails verification and is removed', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(async () => {
|
||||
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||
c.reset();
|
||||
|
|
@ -1202,7 +1205,7 @@ test('copy: verifies copied bytes; a bad write fails verification and is removed
|
|||
});
|
||||
|
||||
test('transmittal: rename a bin (feeds the folder), remove and move a placed file', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(async () => {
|
||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||
c.reset();
|
||||
|
|
@ -1361,7 +1364,7 @@ test('seltable: dragging the header resizer widens a column and persists via per
|
|||
});
|
||||
|
||||
test('From a list: a drop materializes a real tracking placement; row revision + transmittal complete it', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
c.reset();
|
||||
|
|
@ -1398,7 +1401,7 @@ test('From a list: a drop materializes a real tracking placement; row revision +
|
|||
});
|
||||
|
||||
test('From a list: clearing the list keeps classifications; the row drives the seltable', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||
c.reset();
|
||||
|
|
@ -1409,15 +1412,14 @@ test('From a list: clearing the list keeps classifications; the row drives the s
|
|||
{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'], revisionCell: 'C (IFC)' },
|
||||
{ id: 'm2', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', archiveRevisions: ['0 (IFC)'] },
|
||||
]);
|
||||
tt.showTab('worklist');
|
||||
const row = document.querySelector('#worklistTable .seltable__row[data-id="m1"]');
|
||||
const latestShown = !!row && row.textContent.includes('B (IFC)'); // latest archive rev shown
|
||||
tt.render(); // placeholder rows live in the unified By-tracking grid
|
||||
const row = document.querySelector('#trackingTree .seltable__row[data-id="p:m1"]');
|
||||
window.app.modules.dnd.setDrag([key]);
|
||||
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop on m1 (rev C set)
|
||||
if (row) row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop on m1 (rev C set)
|
||||
const named = c.deriveTarget(f).filename;
|
||||
c.clearWorklist(); // list emptied — assignment must survive
|
||||
return {
|
||||
hasRow: !!row, latestShown,
|
||||
hasRow: !!row,
|
||||
placedAfterDrop: !!(c.getAssignment(key) || {}).trackingNodeId,
|
||||
named,
|
||||
listLen: c.getWorklist().length,
|
||||
|
|
@ -1426,7 +1428,6 @@ test('From a list: clearing the list keeps classifications; the row drives the s
|
|||
};
|
||||
});
|
||||
expect(r.hasRow).toBe(true);
|
||||
expect(r.latestShown).toBe(true);
|
||||
expect(r.placedAfterDrop).toBe(true);
|
||||
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_C (IFC) - Spec.pdf'); // tracking + row revision + title
|
||||
expect(r.listLen).toBe(0); // list cleared
|
||||
|
|
@ -1435,7 +1436,7 @@ test('From a list: clearing the list keeps classifications; the row drives the s
|
|||
});
|
||||
|
||||
test('From a list: editing the tracking number (bump sequence) re-stamps placed files', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
c.reset();
|
||||
|
|
@ -1458,7 +1459,7 @@ test('From a list: editing the tracking number (bump sequence) re-stamps placed
|
|||
});
|
||||
|
||||
test('From a list: load() migrates a legacy mdlNodeId placement into a tracking placement', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
c.reset();
|
||||
|
|
@ -1483,7 +1484,7 @@ test('From a list: load() migrates a legacy mdlNodeId placement into a tracking
|
|||
});
|
||||
|
||||
test('parsePastedRows: fixed columns tracking · rev · title · current name', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
const text = [
|
||||
|
|
@ -1501,7 +1502,7 @@ test('parsePastedRows: fixed columns tracking · rev · title · current name',
|
|||
});
|
||||
|
||||
test('proposeMatches: the current-name column drives exact (auto) + token matches', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
const files = [
|
||||
|
|
@ -1524,7 +1525,7 @@ test('proposeMatches: the current-name column drives exact (auto) + token matche
|
|||
});
|
||||
|
||||
test('proposeMatches: ambiguous duplicate current-name is not auto-assigned', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
const files = [
|
||||
|
|
@ -1540,7 +1541,7 @@ test('proposeMatches: ambiguous duplicate current-name is not auto-assigned', as
|
|||
});
|
||||
|
||||
test('proposeMatches finds a row whose tracking number is in the filename', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
const files = [
|
||||
|
|
@ -1562,7 +1563,7 @@ test('proposeMatches finds a row whose tracking number is in the filename', asyn
|
|||
});
|
||||
|
||||
test('From a list: walkDirInto unions files + mdl deliverables, deduped to the latest revision', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(async () => {
|
||||
const tt = window.app.modules.targetTree;
|
||||
function fdir(name, children) {
|
||||
|
|
@ -1647,7 +1648,7 @@ test('From a list: dir-picker resolves the topmost ticked directories only', asy
|
|||
// ── Export filtered list → Excel round-trip (path + file TSV) ──────────────
|
||||
|
||||
test('export: filtered file list → TSV (path + file), includes collapsed folders', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify, tree = window.app.modules.tree;
|
||||
c.reset();
|
||||
|
|
@ -1680,7 +1681,7 @@ test('export: filtered file list → TSV (path + file), includes collapsed folde
|
|||
});
|
||||
|
||||
test('paste rows: a full-path Current name binds that exact file directly', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
c.reset();
|
||||
|
|
@ -1709,7 +1710,7 @@ test('paste rows: a full-path Current name binds that exact file directly', asyn
|
|||
});
|
||||
|
||||
test('paste rows: a full path with no tracking yet is claimed, then placed when tracking lands', async ({ page }) => {
|
||||
await page.click('#modeClassifyBtn');
|
||||
await page.evaluate(() => window.app.modules.app.setMode());
|
||||
const r = await page.evaluate(() => {
|
||||
const c = window.app.modules.classify;
|
||||
c.reset();
|
||||
|
|
|
|||
Loading…
Reference in a new issue