/** * 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 * /{received,issued}// * 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 /// — 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 // /// 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) { 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, }; })();