From 325dce9f3ec136b9610a2a3aa68600e627b821a5 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 11 Jun 2026 08:31:35 -0500 Subject: [PATCH] feat(classifier): Copy prompts download .zip or copy to a directory Copy now opens a small dialog: copy the canonical files into a folder (pick your archive/ to file them straight into ///), or download a single .zip with the same layout (unzips into place). The zip path reuses readSource + the derived outRel, so the bytes and folder structure match the directory copy exactly; conflicts are still skipped beforehand. Test: the zip is built with one entry at ClientCorp/received// ACME-MECH-0001_A (IFR) - foundation.pdf (52 green). Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/css/layout.css | 17 ++++++++ classifier/js/copy.js | 82 +++++++++++++++++++++++++++++++++++++-- tests/classify.spec.js | 27 +++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/classifier/css/layout.css b/classifier/css/layout.css index f9547a8..aa48b51 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -567,6 +567,23 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o cursor: wait; } +/* ── Copy destination dialog ────────────────────────────────────────────── */ +.copy-choice__backdrop { + position: fixed; inset: 0; z-index: 1000; + background: rgba(0, 0, 0, 0.45); + display: flex; align-items: center; justify-content: center; padding: 1rem; +} +.copy-choice { + background: var(--bg); color: var(--text); + border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + max-width: 460px; width: 100%; padding: 1.25rem 1.5rem; +} +.copy-choice h3 { margin: 0 0 0.5rem; font-size: 1.15rem; } +.copy-choice p { margin: 0 0 1.1rem; color: var(--text-muted); font-size: 0.85rem; line-height: 1.5; } +.copy-choice code { font-size: 0.82em; } +.copy-choice__btns { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; } + /* ── By-tracking merged-cell table ──────────────────────────────────────── */ #trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */ .ttable { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; } diff --git a/classifier/js/copy.js b/classifier/js/copy.js index ee72892..2025727 100644 --- a/classifier/js/copy.js +++ b/classifier/js/copy.js @@ -149,8 +149,13 @@ } if (!todo.length) return; + // Ask where to put the canonical copies: a directory (drop straight into + // archive/) or a downloadable zip. Both read the source, never write it. + var dest = await chooseDestination(todo.length); + if (!dest) return; + // Snapshot-loaded files have no live handle — re-grant read on the - // workspace source directory (one click) before copying. + // workspace source directory (one click) before reading. if (todo.some(function (p) { return !p.file.handle; })) { if (!window.app.rootHandle) { toast('The source directory isn’t connected. Re-open the workspace to reconnect it.', 'error'); @@ -160,12 +165,16 @@ if (!srcOk) { toast('Permission to read the source directory was denied.', 'error'); return; } } + return dest === 'zip' ? downloadZip(todo) : copyToDirectory(todo); + } + + async function copyToDirectory(todo) { 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; + if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\n' + + 'Written under /// — pick your archive/ folder to file them directly. The source 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') : '') + '.'; @@ -174,6 +183,72 @@ return s; } + // Bundle the canonical copies into a single .zip and download it — same + // /// layout as the directory copy, so the + // archive unzips straight into place. + async function downloadZip(todo) { + if (typeof JSZip === 'undefined') { toast('Zip export needs JSZip — rebuild the classifier.', 'error'); return; } + var zip = new JSZip(); + var s = { added: 0, errors: 0 }; + for (var i = 0; i < todo.length; i++) { + setStatus('Zipping… ' + (i + 1) + '/' + todo.length + ' — ' + todo[i].d.filename); + try { zip.file(todo[i].outRel, await readSource(todo[i].file)); s.added++; } + catch (e) { s.errors++; toast('Failed to add ' + todo[i].outRel + ' — ' + (e.message || e), 'error'); } + } + setStatus('Packaging…'); + var blob; + try { blob = await zip.generateAsync({ type: 'blob' }); } + catch (e) { setStatus(''); toast('Could not build the zip — ' + (e.message || e), 'error'); return s; } + setStatus(''); + var name = zipBaseName() + '.zip'; + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = name; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000); + toast('Downloaded ' + name + ' — ' + s.added + ' file' + (s.added === 1 ? '' : 's') + + (s.errors ? (', ' + s.errors + ' failed') : '') + '.', s.errors ? 'warning' : 'success'); + return s; + } + function zipBaseName() { + try { + var ws = window.app.modules.workspace; + var n = ws && ws.activeName && ws.activeName(); + if (n) return String(n).replace(/[^\w.-]+/g, '_'); + } catch (_) { /* ok */ } + return 'transmittals'; + } + + // Tiny modal: choose directory vs zip. Resolves 'dir' | 'zip' | null. + function chooseDestination(n) { + return new Promise(function (resolve) { + var done = false; + function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); } + function onKey(e) { if (e.key === 'Escape') finish(null); } + var back = document.createElement('div'); back.className = 'copy-choice__backdrop'; + var box = document.createElement('div'); box.className = 'copy-choice'; + var h = document.createElement('h3'); + h.textContent = 'Copy ' + n + ' classified file' + (n === 1 ? '' : 's'); + var p = document.createElement('p'); + p.innerHTML = 'Layout: <party>/<received|issued>/<transmittal>/<name>. ' + + 'Copy into a folder — choose your archive/ to file them directly — or download a zip.'; + var row = document.createElement('div'); row.className = 'copy-choice__btns'; + function btn(label, cls, val) { + var b = document.createElement('button'); b.className = 'btn ' + cls; b.textContent = label; + b.addEventListener('click', function () { finish(val); }); + return b; + } + row.appendChild(btn('📁 Copy to a folder…', 'btn-primary', 'dir')); + row.appendChild(btn('🗜 Download .zip', 'btn-secondary', 'zip')); + row.appendChild(btn('Cancel', 'btn-secondary', null)); + box.appendChild(h); box.appendChild(p); box.appendChild(row); + back.appendChild(box); + back.addEventListener('click', function (e) { if (e.target === back) finish(null); }); + document.addEventListener('keydown', onKey); + document.body.appendChild(back); + }); + } + // 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) { @@ -205,5 +280,6 @@ plan: plan, conflictsIn: conflictsIn, copyTo: copyTo, + downloadZip: downloadZip, }; })(); diff --git a/tests/classify.spec.js b/tests/classify.spec.js index d55117f..cfe1fd2 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -1028,3 +1028,30 @@ test('Show Partial surfaces files assigned in the other tab only', async ({ page expect(r.withPartial).toBe(true); // shown while Partial is on (to-do for this tab) expect(r.withoutPartial).toBe(false); // hidden once Partial is off }); + +test('Copy → zip bundles files under ///', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const r = await page.evaluate(async () => { + const c = window.app.modules.classify, copy = window.app.modules.copy; + c.reset(); + const f = { + originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root', + handle: { getFile: async () => new File(['AAA'], 'foundation.pdf') }, + }; + window.app.folderTree = [{ name: 'Root', path: 'Root', files: [f], 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(f)], leaf, 'tracking'); + c.place([c.srcKeyForFile(f)], bin, 'transmittal'); + const entries = {}; + window.JSZip = function () { + this.file = (name, data) => { entries[name] = data; }; + this.generateAsync = async () => new Blob(['zip']); + }; + await copy.downloadZip(copy.plan()); + return { paths: Object.keys(entries) }; + }); + expect(r.paths.length).toBe(1); + expect(r.paths[0].startsWith('ClientCorp/received/')).toBe(true); + expect(r.paths[0].endsWith('ACME-MECH-0001_A (IFR) - foundation.pdf')).toBe(true); +});