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 <party>/<received|issued>/<transmittal>/),
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/<transmittal>/
ACME-MECH-0001_A (IFR) - foundation.pdf (52 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-11 08:31:35 -05:00
parent 645b308ebc
commit 325dce9f3e
3 changed files with 123 additions and 3 deletions

View file

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

View file

@ -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 isnt 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 <party>/<received|issued>/<transmittal>/ — 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
// <party>/<slot>/<transmittal>/<name> 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: <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.';
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,
};
})();

View file

@ -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 <party>/<slot>/<transmittal>/<name>', 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);
});