/** * 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; // Snapshot-loaded files have no live handle — re-grant read on the // workspace source directory (one click) before copying. 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; } } 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; 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; } // 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, }; })();