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:
parent
645b308ebc
commit
325dce9f3e
3 changed files with 123 additions and 3 deletions
|
|
@ -567,6 +567,23 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
||||||
cursor: wait;
|
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 ──────────────────────────────────────── */
|
/* ── By-tracking merged-cell table ──────────────────────────────────────── */
|
||||||
#trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */
|
#trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */
|
||||||
.ttable { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
.ttable { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
||||||
|
|
|
||||||
|
|
@ -149,8 +149,13 @@
|
||||||
}
|
}
|
||||||
if (!todo.length) return;
|
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
|
// 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 (todo.some(function (p) { return !p.file.handle; })) {
|
||||||
if (!window.app.rootHandle) {
|
if (!window.app.rootHandle) {
|
||||||
toast('The source directory isn’t connected. Re-open the workspace to reconnect it.', 'error');
|
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; }
|
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();
|
var out = outputHandle || await chooseOutput();
|
||||||
if (!out) return;
|
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 s = await copyTo(out, todo);
|
||||||
|
|
||||||
var msg = 'Copy complete — ' + s.copied + ' copied, ' + s.skipped + ' identical skipped'
|
var msg = 'Copy complete — ' + s.copied + ' copied, ' + s.skipped + ' identical skipped'
|
||||||
+ (s.differ ? (', ' + s.differ + ' already exist with different content (left untouched)') : '')
|
+ (s.differ ? (', ' + s.differ + ' already exist with different content (left untouched)') : '')
|
||||||
+ (s.errors ? (', ' + s.errors + ' errors') : '') + '.';
|
+ (s.errors ? (', ' + s.errors + ' errors') : '') + '.';
|
||||||
|
|
@ -174,6 +183,72 @@
|
||||||
return s;
|
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><party>/<received|issued>/<transmittal>/<name></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,
|
// 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).
|
||||||
async function copyTo(out, todo) {
|
async function copyTo(out, todo) {
|
||||||
|
|
@ -205,5 +280,6 @@
|
||||||
plan: plan,
|
plan: plan,
|
||||||
conflictsIn: conflictsIn,
|
conflictsIn: conflictsIn,
|
||||||
copyTo: copyTo,
|
copyTo: copyTo,
|
||||||
|
downloadZip: downloadZip,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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.withPartial).toBe(true); // shown while Partial is on (to-do for this tab)
|
||||||
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 }) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue