feat(classifier): Copy → server archive (HTTP) or local folder, resumable

Replaces the zip option. Copy now offers two real destinations, both filing
under <party>/<received|issued>/<transmittal>/<name>:
- Server archive — PUTs each file into a zddc-server over HTTP via the
  zddc-source HttpDirectoryHandle (mkdir as needed). Offered only over http(s);
  prompts for the archive URL (guessed from the current path's archive/ segment).
- Local folder — the File System Access picker (choose archive/ to file directly).

Both reuse the one copy engine and are efficiently resumable: copyOne probes the
target with a cheap stat/HEAD and SKIPS anything already present — no source
read, no hashing — so a re-run after an interruption only does what's left.
Canonical ZDDC names + the WORM archive mean an existing name is the same
document, so we never overwrite (the old content-diff path is dropped).

Tests: re-run skips an existing local target; PUT into a server-style handle then
resume-skips it (52 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-11 08:43:53 -05:00
parent 325dce9f3e
commit 144baeec61
2 changed files with 103 additions and 84 deletions

View file

@ -88,13 +88,17 @@
return cur; return cur;
} }
async function sameContent(existingHandle, srcFileObj) { // Resolve a target subdirectory WITHOUT creating it (null if any segment is
var ef = await existingHandle.getFile(); // missing). Lets us check a file's existence cheaply on resume before paying
var sf = await readSource(srcFileObj); // to create the folder chain.
if (ef.size !== sf.size) return false; async function resolveDir(root, relPath, create) {
var a = await window.zddc.crypto.sha256File(ef); var parts = relPath.split('/').filter(Boolean);
var b = await window.zddc.crypto.sha256File(sf); var cur = root;
return a === b; for (var i = 0; i < parts.length; i++) {
try { cur = await cur.getDirectoryHandle(parts[i], create ? { create: true } : undefined); }
catch (e) { if (!create) return null; throw e; }
}
return cur;
} }
// Resolve a source file's live handle. Fresh-scan files already carry one; // Resolve a source file's live handle. Fresh-scan files already carry one;
@ -115,14 +119,21 @@
return (await srcHandle(fileObj)).getFile(); return (await srcHandle(fileObj)).getFile();
} }
// Copy one file. Returns 'copied' | 'skipped' (identical) | 'differ' (left alone). // Copy one file. Returns 'copied' | 'skipped' (already present → resumable).
// The existence check is a cheap stat/HEAD; a present target is left as-is so
// re-running after an interruption skips the work already done — no source
// read, no hashing. (Canonical ZDDC names ⇒ same name = same document, and
// the server archive is WORM, so we never overwrite.)
async function copyOne(out, p) { async function copyOne(out, p) {
var dir = await ensureDir(out, p.d.outPath); // Cheap existence probe: resolve the dir WITHOUT creating it (the HTTP
var existing = null; // handle doesn't verify here, but getFileHandle below does a HEAD).
try { existing = await dir.getFileHandle(p.d.filename); } catch (e) { /* NotFound → fresh copy */ } var probe = await resolveDir(out, p.d.outPath, false);
if (existing) { if (probe) {
return (await sameContent(existing, p.file)) ? 'skipped' : 'differ'; try { await probe.getFileHandle(p.d.filename); return 'skipped'; }
catch (e) { /* NotFound → write it below */ }
} }
// Write path: create the folder chain (idempotent) then read + write.
var dir = await ensureDir(out, p.d.outPath);
var srcFile = await readSource(p.file); // READ source (never write it) var srcFile = await readSource(p.file); // READ source (never write it)
var fh = await dir.getFileHandle(p.d.filename, { create: true }); var fh = await dir.getFileHandle(p.d.filename, { create: true });
var w = await fh.createWritable(); var w = await fh.createWritable();
@ -149,8 +160,9 @@
} }
if (!todo.length) return; if (!todo.length) return;
// Ask where to put the canonical copies: a directory (drop straight into // Where to file the canonical copies: the server archive (HTTP) or a
// archive/) or a downloadable zip. Both read the source, never write it. // local folder (File System Access). Both read the source, never write it,
// and both are resumable — already-present targets are skipped.
var dest = await chooseDestination(todo.length); var dest = await chooseDestination(todo.length);
if (!dest) return; if (!dest) return;
@ -165,81 +177,82 @@
if (!srcOk) { toast('Permission to read the source directory was denied.', 'error'); return; } if (!srcOk) { toast('Permission to read the source directory was denied.', 'error'); return; }
} }
return dest === 'zip' ? downloadZip(todo) : copyToDirectory(todo); return dest === 'server' ? copyToServer(todo) : copyToLocal(todo);
} }
async function copyToDirectory(todo) { function summary(s, where) {
var msg = 'Copy to ' + where + ' — ' + s.copied + ' copied, ' + s.skipped + ' already there'
+ (s.errors ? (', ' + s.errors + ' failed (retry to resume)') : '') + '.';
toast(msg, s.errors ? 'warning' : 'success');
}
async function copyToLocal(todo) {
var out = outputHandle || await chooseOutput(); var out = outputHandle || await chooseOutput();
if (!out) return; if (!out) return;
if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\n' if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\n'
+ 'Written under <party>/<received|issued>/<transmittal>/ — pick your archive/ folder to file them directly. The source is not modified.')) return; + 'Written under <party>/<received|issued>/<transmittal>/ — pick your archive/ folder to file them directly. '
+ 'Re-running resumes (already-copied files are skipped). The source is not modified.')) return;
var s = await copyTo(out, todo); var s = await copyTo(out, todo);
var msg = 'Copy complete — ' + s.copied + ' copied, ' + s.skipped + ' identical skipped' summary(s, '"' + out.name + '"');
+ (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; return s;
} }
// Bundle the canonical copies into a single .zip and download it — same // Copy straight into the server's archive over HTTP (PUT per file, mkdir as
// <party>/<slot>/<transmittal>/<name> layout as the directory copy, so the // needed). Uses the zddc-source HTTP handle, so the SAME copy engine writes
// archive unzips straight into place. // <party>/<received|issued>/<transmittal>/<name> under the chosen archive URL.
async function downloadZip(todo) { async function copyToServer(todo) {
if (typeof JSZip === 'undefined') { toast('Zip export needs JSZip — rebuild the classifier.', 'error'); return; } var src = window.zddc && window.zddc.source;
var zip = new JSZip(); if (!src || location.protocol === 'file:') {
var s = { added: 0, errors: 0 }; toast('Server copy needs the classifier to be served by a zddc-server (open it over http).', 'error');
for (var i = 0; i < todo.length; i++) { return;
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 url = serverArchiveUrl || guessArchiveUrl();
var blob; url = prompt('Server archive URL to file into (canonical copies go under <party>/<received|issued>/<transmittal>/):', url);
try { blob = await zip.generateAsync({ type: 'blob' }); } if (!url) return;
catch (e) { setStatus(''); toast('Could not build the zip — ' + (e.message || e), 'error'); return s; } if (url.charAt(url.length - 1) !== '/') url += '/';
setStatus(''); serverArchiveUrl = url;
var name = zipBaseName() + '.zip'; var out;
var a = document.createElement('a'); try {
a.href = URL.createObjectURL(blob); var u = new URL(url, location.origin);
a.download = name; out = new src.HttpDirectoryHandle(u.href, 'archive');
document.body.appendChild(a); a.click(); a.remove(); } catch (e) { toast('Bad archive URL — ' + (e.message || e), 'error'); return; }
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000); var s = await copyTo(out, todo);
toast('Downloaded ' + name + ' — ' + s.added + ' file' + (s.added === 1 ? '' : 's') summary(s, 'server archive');
+ (s.errors ? (', ' + s.errors + ' failed') : '') + '.', s.errors ? 'warning' : 'success');
return s; return s;
} }
function zipBaseName() { // Best-guess archive root from the current page: the path up to and including
try { // the first "archive/" segment, else the served directory.
var ws = window.app.modules.workspace; function guessArchiveUrl() {
var n = ws && ws.activeName && ws.activeName(); var p = location.pathname;
if (n) return String(n).replace(/[^\w.-]+/g, '_'); var m = /^(.*?\/archive\/)/.exec(p);
} catch (_) { /* ok */ } return location.origin + (m ? m[1] : p.replace(/[^/]*$/, ''));
return 'transmittals';
} }
var serverArchiveUrl = null;
// Tiny modal: choose directory vs zip. Resolves 'dir' | 'zip' | null. // Tiny modal: choose server archive vs local folder. Resolves 'server' |
// 'local' | null. The server option is offered only over http(s).
function chooseDestination(n) { function chooseDestination(n) {
return new Promise(function (resolve) { return new Promise(function (resolve) {
var done = false; var done = false;
function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); } 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); } function onKey(e) { if (e.key === 'Escape') finish(null); }
var onServer = location.protocol === 'http:' || location.protocol === 'https:';
var back = document.createElement('div'); back.className = 'copy-choice__backdrop'; var back = document.createElement('div'); back.className = 'copy-choice__backdrop';
var box = document.createElement('div'); box.className = 'copy-choice'; var box = document.createElement('div'); box.className = 'copy-choice';
var h = document.createElement('h3'); var h = document.createElement('h3');
h.textContent = 'Copy ' + n + ' classified file' + (n === 1 ? '' : 's'); h.textContent = 'Copy ' + n + ' classified file' + (n === 1 ? '' : 's');
var p = document.createElement('p'); var p = document.createElement('p');
p.innerHTML = 'Layout: <code>&lt;party&gt;/&lt;received|issued&gt;/&lt;transmittal&gt;/&lt;name&gt;</code>. ' p.innerHTML = 'Filed under <code>&lt;party&gt;/&lt;received|issued&gt;/&lt;transmittal&gt;/&lt;name&gt;</code>. '
+ 'Copy into a folder — choose your <code>archive/</code> to file them directly — or download a zip.'; + 'Re-running resumes — files already present at the destination are skipped.';
var row = document.createElement('div'); row.className = 'copy-choice__btns'; var row = document.createElement('div'); row.className = 'copy-choice__btns';
function btn(label, cls, val) { function btn(label, cls, val, disabled) {
var b = document.createElement('button'); b.className = 'btn ' + cls; b.textContent = label; var b = document.createElement('button'); b.className = 'btn ' + cls; b.textContent = label;
b.addEventListener('click', function () { finish(val); }); if (disabled) { b.disabled = true; b.title = 'Open the classifier over a zddc-server to enable this'; }
else b.addEventListener('click', function () { finish(val); });
return b; return b;
} }
row.appendChild(btn('📁 Copy to a folder…', 'btn-primary', 'dir')); row.appendChild(btn('☁ Copy to server archive', 'btn-primary', 'server', !onServer));
row.appendChild(btn('🗜 Download .zip', 'btn-secondary', 'zip')); row.appendChild(btn('📁 Copy to a local folder…', onServer ? 'btn-secondary' : 'btn-primary', 'local'));
row.appendChild(btn('Cancel', 'btn-secondary', null)); row.appendChild(btn('Cancel', 'btn-secondary', null));
box.appendChild(h); box.appendChild(p); box.appendChild(row); box.appendChild(h); box.appendChild(p); box.appendChild(row);
back.appendChild(box); back.appendChild(box);
@ -251,14 +264,14 @@
// Run the copy loop over a ready list against an output handle. No picker, // 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). // no confirm — that's run()'s job; this is the engine (and the test seam).
// Resumable: copyOne skips targets that already exist, so a re-run after an
// interruption only does the remaining work.
async function copyTo(out, todo) { async function copyTo(out, todo) {
var s = { copied: 0, skipped: 0, differ: 0, errors: 0, differing: [] }; var s = { copied: 0, skipped: 0, errors: 0 };
for (var i = 0; i < todo.length; i++) { for (var i = 0; i < todo.length; i++) {
setStatus('Copying… ' + (i + 1) + '/' + todo.length + ' — ' + todo[i].d.filename); setStatus('Copying… ' + (i + 1) + '/' + todo.length + ' — ' + todo[i].d.filename);
try { try {
var r = await copyOne(out, todo[i]); s[await copyOne(out, todo[i])]++;
s[r]++;
if (r === 'differ') s.differing.push(todo[i].outRel);
} catch (e) { } catch (e) {
s.errors++; s.errors++;
if (window.zddc && window.zddc.toast) { if (window.zddc && window.zddc.toast) {
@ -280,6 +293,5 @@
plan: plan, plan: plan,
conflictsIn: conflictsIn, conflictsIn: conflictsIn,
copyTo: copyTo, copyTo: copyTo,
downloadZip: downloadZip,
}; };
})(); })();

View file

@ -322,7 +322,7 @@ test('cross-tree reveal: source→target switches to the placed axis', async ({
// ── Phase 5: copy-out engine + duplicate detection (mock FS handles) ─────── // ── Phase 5: copy-out engine + duplicate detection (mock FS handles) ───────
test('copy: writes the file, skips an identical re-copy, flags a differing target', async ({ page }) => { test('copy: writes the file, then resumes by skipping an existing target', async ({ page }) => {
await page.click('#modeClassifyBtn'); await page.click('#modeClassifyBtn');
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
const c = window.app.modules.classify, copy = window.app.modules.copy; const c = window.app.modules.classify, copy = window.app.modules.copy;
@ -353,15 +353,12 @@ test('copy: writes the file, skips an identical re-copy, flags a differing targe
const out = mockDir(''); const out = mockDir('');
const first = await copy.copyTo(out, copy.plan()); const first = await copy.copyTo(out, copy.plan());
const second = await copy.copyTo(out, copy.plan()); // identical → skipped const second = await copy.copyTo(out, copy.plan()); // already present → skipped (resume)
const tkey = Object.keys(store)[0]; return { firstCopied: first.copied, secondSkipped: second.skipped, secondCopied: second.copied, keys: Object.keys(store) };
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.firstCopied).toBe(1);
expect(res.secondSkipped).toBe(1); expect(res.secondSkipped).toBe(1); // re-run resumes: the existing target is skipped
expect(res.thirdDiffer).toBe(1); expect(res.secondCopied).toBe(0); // …and not re-written
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); 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);
}); });
@ -1029,7 +1026,7 @@ test('Show Partial surfaces files assigned in the other tab only', async ({ page
expect(r.withoutPartial).toBe(false); // hidden once Partial is off expect(r.withoutPartial).toBe(false); // hidden once Partial is off
}); });
test('Copy → zip bundles files under <party>/<slot>/<transmittal>/<name>', async ({ page }) => { test('copy: PUTs into a server-style handle, then resumes by skipping existing', async ({ page }) => {
await page.click('#modeClassifyBtn'); await page.click('#modeClassifyBtn');
const r = await page.evaluate(async () => { const r = await page.evaluate(async () => {
const c = window.app.modules.classify, copy = window.app.modules.copy; const c = window.app.modules.classify, copy = window.app.modules.copy;
@ -1043,15 +1040,25 @@ test('Copy → zip bundles files under <party>/<slot>/<transmittal>/<name>', asy
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' }); 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)], leaf, 'tracking');
c.place([c.srcKeyForFile(f)], bin, 'transmittal'); c.place([c.srcKeyForFile(f)], bin, 'transmittal');
const entries = {};
window.JSZip = function () { // Server-style handle: getDirectoryHandle never verifies (like the HTTP
this.file = (name, data) => { entries[name] = data; }; // polyfill); getFileHandle does a HEAD-style existence check.
this.generateAsync = async () => new Blob(['zip']); const store = {}, mkdirs = [];
}; const srvDir = (base) => ({
await copy.downloadZip(copy.plan()); getDirectoryHandle: async (n, opts) => { if (opts && opts.create) mkdirs.push(base + n); return srvDir(base + n + '/'); },
return { paths: Object.keys(entries) }; getFileHandle: async (n, opts) => {
const full = base + n;
if ((!opts || !opts.create) && !(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; }
return { createWritable: async () => ({ write: async (d) => { store[full] = d; }, close: async () => {} }) };
},
});
const out = srvDir('');
const first = await copy.copyTo(out, copy.plan());
const second = await copy.copyTo(out, copy.plan()); // existing → skipped (resume)
return { firstCopied: first.copied, secondSkipped: second.skipped, paths: Object.keys(store) };
}); });
expect(r.paths.length).toBe(1); expect(r.firstCopied).toBe(1);
expect(r.secondSkipped).toBe(1);
expect(r.paths[0].startsWith('ClientCorp/received/')).toBe(true); expect(r.paths[0].startsWith('ClientCorp/received/')).toBe(true);
expect(r.paths[0].endsWith('ACME-MECH-0001_A (IFR) - foundation.pdf')).toBe(true); expect(r.paths[0].endsWith('ACME-MECH-0001_A (IFR) - foundation.pdf')).toBe(true);
}); });