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>
285 lines
13 KiB
JavaScript
285 lines
13 KiB
JavaScript
/**
|
||
* 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
|
||
* <party>/{received,issued}/<DATE_TN (STATUS) - TITLE>/<TRACKING_REV (STATUS) - TITLE.ext>
|
||
* 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 readSource(srcFileObj);
|
||
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;
|
||
}
|
||
|
||
// Resolve a source file's live handle. Fresh-scan files already carry one;
|
||
// snapshot-loaded files resolve lazily from the workspace root by path.
|
||
async function srcHandle(fileObj) {
|
||
if (fileObj.handle) return fileObj.handle;
|
||
if (!window.app.rootHandle) throw new Error('source directory not connected');
|
||
return window.app.modules.scanner.resolveFileHandle(window.app.rootHandle, fileObj);
|
||
}
|
||
|
||
// Read a source file's bytes (a File or Blob). A zip member is extracted
|
||
// from its archive (lazily reloaded from the root); a plain file is read
|
||
// through its resolved handle. The source is never written either way.
|
||
async function readSource(fileObj) {
|
||
if (fileObj.isVirtual) {
|
||
return window.app.modules.scanner.extractZipMember(window.app.rootHandle, fileObj);
|
||
}
|
||
return (await srcHandle(fileObj)).getFile();
|
||
}
|
||
|
||
// 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 readSource(p.file); // 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;
|
||
|
||
// 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 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');
|
||
return;
|
||
}
|
||
var srcOk = await window.app.modules.persist.verifyPermission(window.app.rootHandle, false);
|
||
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\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') : '') + '.';
|
||
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;
|
||
}
|
||
|
||
// 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,
|
||
// 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,
|
||
downloadZip: downloadZip,
|
||
};
|
||
})();
|