diff --git a/classifier/build.sh b/classifier/build.sh index 0cbdfeb..04b7fac 100755 --- a/classifier/build.sh +++ b/classifier/build.sh @@ -58,6 +58,7 @@ concat_files \ "js/scanner.js" \ "js/tree.js" \ "js/target-tree.js" \ + "js/copy.js" \ "js/spreadsheet.js" \ "js/selection.js" \ "js/preview.js" \ diff --git a/classifier/js/app.js b/classifier/js/app.js index 23a2e64..b01b3f8 100644 --- a/classifier/js/app.js +++ b/classifier/js/app.js @@ -33,6 +33,23 @@ cacheDOMElements(); setupEventListeners(); + // Restore a saved Classify & Copy map (placements + target trees). It + // keys on source-relative paths, so it re-attaches once the SAME source + // directory is opened again — the source handle itself can't be opened + // without a user gesture, so we remind the user to re-pick it. + if (app.modules.persist && app.modules.persist.available) { + app.modules.persist.loadState().then(function (s) { + if (!s) return; + var has = Object.keys(s.assignments || {}).length + || (s.trackingTree || []).length || (s.transmittalTree || []).length; + if (!has) return; + app.modules.classify.load(s); + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Restored your Classify & Copy map from this browser. Open the SAME source directory and switch to “Classify & Copy” to continue.', 'info', { durationMs: 9000 }); + } + }); + } + // Browser-compatibility branch: // HTTP mode (served by zddc-server) — works everywhere; the // HTTP polyfill stands in for the FS Access API. Auto-load @@ -164,7 +181,8 @@ modeRenameBtn: document.getElementById('modeRenameBtn'), modeClassifyBtn: document.getElementById('modeClassifyBtn'), spreadsheetPane: document.getElementById('spreadsheetPane'), - targetPane: document.getElementById('targetPane') + targetPane: document.getElementById('targetPane'), + copyOutputBtn: document.getElementById('copyOutputBtn') }; } @@ -218,6 +236,7 @@ // 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(); }); // Keyboard shortcuts document.addEventListener('keydown', handleKeyDown); @@ -351,7 +370,10 @@ */ async function openDirectory(dirHandle) { app.rootHandle = dirHandle; - + // Remember the source handle so a later session can re-grant access in + // one click (the map re-attaches by relative path either way). + if (app.modules.persist) app.modules.persist.saveSourceHandle(dirHandle); + // Hide welcome screen and show main UI hideWelcomeScreen(); showMainUI(); diff --git a/classifier/js/copy.js b/classifier/js/copy.js new file mode 100644 index 0000000..9522325 --- /dev/null +++ b/classifier/js/copy.js @@ -0,0 +1,180 @@ +/** + * ZDDC Classifier — copy-out (Classify & Copy mode). + * + * Copies the fully-classified source files into a SEPARATE output directory + * under their canonical ZDDC names and folder layout + * /{received,issued}// + * The source is never modified — every operation is a read (getFile) on the + * source and a write into the chosen output handle. + * + * Duplicate detection: + * - two sources → the same output path = mapping conflict (skipped + reported) + * - target already exists, identical bytes (sha256) = skipped + * - target exists, different bytes = left untouched + reported (no clobber) + * + * Built on the generic FS-Access shape (getDirectoryHandle/getFileHandle/ + * createWritable), so it works against a real handle today and a server-backed + * output handle later without changing this logic. + */ +(function () { + 'use strict'; + + var outputHandle = null; // remembered for the session + + function C() { return window.app.modules.classify; } + + function collectFiles() { + var out = []; + (function walk(nodes) { + (nodes || []).forEach(function (n) { + (n.files || []).forEach(function (f) { out.push(f); }); + walk(n.children); + }); + })(window.app.folderTree || []); + return out; + } + + // Files that are ready to copy: complete target, not excluded. + function plan() { + var c = C(), items = []; + collectFiles().forEach(function (f) { + var d = c.deriveTarget(f); + if (d.excluded || !d.complete) return; + items.push({ file: f, d: d, outRel: d.outPath + '/' + d.filename }); + }); + return items; + } + + // Group by output path; >1 source for a path = a mapping conflict. + function conflictsIn(items) { + var by = {}, conflicts = []; + items.forEach(function (p) { (by[p.outRel] = by[p.outRel] || []).push(p); }); + Object.keys(by).forEach(function (k) { if (by[k].length > 1) conflicts.push(k); }); + return { by: by, conflicts: conflicts }; + } + + function toast(msg, level) { + if (window.zddc && window.zddc.toast) window.zddc.toast(msg, level); + } + function setStatus(text) { + var el = document.getElementById('scanStatus'); + if (!el) return; + el.textContent = text; + el.classList.toggle('scanning', !!text); + } + + async function chooseOutput() { + if (!window.showDirectoryPicker) { + toast('Copying to an output directory needs the File System Access API (use Chromium, or run via zddc-server).', 'error'); + return null; + } + try { + var h = await window.showDirectoryPicker({ mode: 'readwrite', id: 'zddc-classifier-output' }); + outputHandle = h; + C().setOutputName(h.name); + return h; + } catch (e) { + if (e.name !== 'AbortError') toast('Could not open the output directory — ' + (e.message || e), 'error'); + return null; + } + } + + async function ensureDir(root, relPath) { + var parts = relPath.split('/').filter(Boolean); + var cur = root; + for (var i = 0; i < parts.length; i++) { + cur = await cur.getDirectoryHandle(parts[i], { create: true }); + } + return cur; + } + + async function sameContent(existingHandle, srcFileObj) { + var ef = await existingHandle.getFile(); + var sf = await srcFileObj.handle.getFile(); + if (ef.size !== sf.size) return false; + var a = await window.zddc.crypto.sha256File(ef); + var b = await window.zddc.crypto.sha256File(sf); + return a === b; + } + + // Copy one file. Returns 'copied' | 'skipped' (identical) | 'differ' (left alone). + async function copyOne(out, p) { + var dir = await ensureDir(out, p.d.outPath); + var existing = null; + try { existing = await dir.getFileHandle(p.d.filename); } catch (e) { /* NotFound → fresh copy */ } + if (existing) { + return (await sameContent(existing, p.file)) ? 'skipped' : 'differ'; + } + var srcFile = await p.file.handle.getFile(); // READ source (never write it) + var fh = await dir.getFileHandle(p.d.filename, { create: true }); + var w = await fh.createWritable(); + await w.write(srcFile); + await w.close(); + return 'copied'; + } + + async function run() { + if (!C().isEnabled()) return; + var items = plan(); + if (!items.length) { + toast('Nothing to copy yet — no files are fully classified (need both a tracking leaf and a transmittal).', 'warning'); + return; + } + var cf = conflictsIn(items); + var blocked = {}; + cf.conflicts.forEach(function (path) { blocked[path] = true; }); + var todo = items.filter(function (p) { return !blocked[p.outRel]; }); + + if (cf.conflicts.length) { + toast(cf.conflicts.length + ' output-name collision(s) — two source files map to the same name. Skipped:\n' + + cf.conflicts.join('\n'), 'error'); + } + if (!todo.length) return; + + var out = outputHandle || await chooseOutput(); + if (!out) return; + if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\nThe source directory is not modified.')) return; + + var s = await copyTo(out, todo); + + var msg = 'Copy complete — ' + s.copied + ' copied, ' + s.skipped + ' identical skipped' + + (s.differ ? (', ' + s.differ + ' already exist with different content (left untouched)') : '') + + (s.errors ? (', ' + s.errors + ' errors') : '') + '.'; + toast(msg, (s.errors || s.differ) ? 'warning' : 'success'); + if (s.differing.length) toast('Existing-but-different (not overwritten):\n' + s.differing.join('\n'), 'warning'); + return s; + } + + // Run the copy loop over a ready list against an output handle. No picker, + // no confirm — that's run()'s job; this is the engine (and the test seam). + async function copyTo(out, todo) { + var s = { copied: 0, skipped: 0, differ: 0, errors: 0, differing: [] }; + for (var i = 0; i < todo.length; i++) { + setStatus('Copying… ' + (i + 1) + '/' + todo.length + ' — ' + todo[i].d.filename); + try { + var r = await copyOne(out, todo[i]); + s[r]++; + if (r === 'differ') s.differing.push(todo[i].outRel); + } catch (e) { + s.errors++; + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Failed to copy ' + todo[i].outRel + ' — ' + (e.message || e), 'error'); + } + } + } + setStatus(''); + return s; + } + + function readyCount() { return plan().length; } + + window.app.modules.copy = { + run: run, + readyCount: readyCount, + chooseOutput: chooseOutput, + // test/advanced seams + plan: plan, + conflictsIn: conflictsIn, + copyTo: copyTo, + }; +})(); diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index 3391d1d..cb7b39f 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -103,10 +103,16 @@ } function renderStats(files) { - if (!els.stats) return; var s = C().stats(files); - els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · ' - + s.none + ' unassigned · ' + s.excluded + ' excluded'; + if (els.stats) { + els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · ' + + s.none + ' unassigned · ' + s.excluded + ' excluded'; + } + var copyBtn = document.getElementById('copyOutputBtn'); + if (copyBtn) { + copyBtn.disabled = s.done === 0; + copyBtn.textContent = s.done ? ('Copy ' + s.done + '…') : 'Copy…'; + } } function el(tag, cls, text) { diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 53d7227..7cb86b1 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -286,6 +286,77 @@ test('cross-tree reveal: source→target switches to the placed axis', async ({ expect(ok).toBe(true); }); +// ── Phase 5: copy-out engine + duplicate detection (mock FS handles) ─────── + +test('copy: writes the file, skips an identical re-copy, flags a differing target', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const res = await page.evaluate(async () => { + const c = window.app.modules.classify, copy = window.app.modules.copy; + const store = {}; + const fileHandleFor = (full) => ({ + getFile: async () => new File([store[full] != null ? store[full] : ''], full.split('/').pop()), + createWritable: async () => ({ write: async (d) => { store[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }), + }); + const mockDir = (prefix) => ({ + name: prefix || 'out', + getDirectoryHandle: async (name) => mockDir((prefix ? prefix + '/' : '') + name), + getFileHandle: async (name, opts) => { + const full = (prefix ? prefix + '/' : '') + name; + if (!opts || !opts.create) { if (!(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } } + return fileHandleFor(full); + }, + }); + const srcFile = (name, content) => { + const p = name.split('.'); const ext = p.length > 1 ? p.pop() : ''; const stem = p.join('.'); + return { originalFilename: stem, extension: ext, folderPath: 'Root', handle: { getFile: async () => new File([content], name) } }; + }; + const f = srcFile('foundation.pdf', 'AAA'); + window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [f], children: [], runFiles: 1 }]; + const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); + const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); + const key = c.srcKeyForFile(f); + c.place([key], leaf, 'tracking'); c.place([key], bin, 'transmittal'); + + const out = mockDir(''); + const first = await copy.copyTo(out, copy.plan()); + const second = await copy.copyTo(out, copy.plan()); // identical → skipped + const tkey = Object.keys(store)[0]; + store[tkey] = 'DIFFERENT'; // tamper target + const third = await copy.copyTo(out, copy.plan()); // differs → left alone + return { firstCopied: first.copied, secondSkipped: second.skipped, thirdDiffer: third.differ, keys: Object.keys(store) }; + }); + expect(res.firstCopied).toBe(1); + expect(res.secondSkipped).toBe(1); + expect(res.thirdDiffer).toBe(1); + expect(res.keys.some((k) => k.endsWith('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal/ACME-MECH-0001_A (IFR) - foundation.pdf'))).toBe(true); +}); + +test('copy: two sources mapping to the same output path are a conflict', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const conflicts = await page.evaluate(() => { + const c = window.app.modules.classify, copy = window.app.modules.copy; + const srcFile = (name, folder) => { + const p = name.split('.'); const ext = p.length > 1 ? p.pop() : ''; const stem = p.join('.'); + return { originalFilename: stem, extension: ext, folderPath: folder, handle: { getFile: async () => new File(['x'], name) } }; + }; + const f1 = srcFile('plan.pdf', 'Root/a'); + const f2 = srcFile('plan.pdf', 'Root/b'); // same name, different folder → same derived output + window.app.folderTree = [{ + name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [], + children: [ + { name: 'a', path: 'Root/a', files: [f1], children: [] }, + { name: 'b', path: 'Root/b', files: [f2], children: [] }, + ], + }]; + const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)'); + const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); + c.place([c.srcKeyForFile(f1)], leaf, 'tracking'); c.place([c.srcKeyForFile(f1)], bin, 'transmittal'); + c.place([c.srcKeyForFile(f2)], leaf, 'tracking'); c.place([c.srcKeyForFile(f2)], bin, 'transmittal'); + return copy.conflictsIn(copy.plan()).conflicts.length; + }); + expect(conflicts).toBe(1); +}); + test('deleting a tracking node clears the files placed in it', async ({ page }) => { const after = await page.evaluate((file) => { const c = window.app.modules.classify;