From 1f8b4e4aaaf3581bf42204d7b4c4db4690f695f3 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 10 Jun 2026 14:34:25 -0500 Subject: [PATCH] feat(classifier): zips are single files by default, toggle to expand as a folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously every .zip was auto-expanded into a folder of members (and was even double-represented as both a file and a folder node). Now a .zip is one classifiable file by default; right-click it → "Expand as folder" to pull its members into the fileset, and right-click an expanded archive (or a member) → "Collapse to single file" to go back. The toggle sits with Exclude in the context menu. - scanner: stop creating zip-root nodes during the scan; expandZipAsFolder / collapseZipToFile mutate the tree in place (re-reading members from the live handle or, for a restored workspace, lazily from the root) and recompute subtree totals. Mode is encoded by the tree shape, so it persists in the snapshot as-is. - classify.dropAssignments clears the assignments that cease to exist when a zip flips mode (the single-file key on expand; the member keys on collapse). - copy already handles both: a zip-as-file copies whole; members extract from the archive. Also: a folder whose entire subtree is excluded now renders its name struck through, mirroring the excluded-file style. Tests: collapse restores the single .zip + drops member assignments; a fully-excluded folder gets the struck-through class (48 green). Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/css/layout.css | 1 + classifier/js/classify.js | 11 ++++- classifier/js/scanner.js | 87 +++++++++++++++++++++++++++++++++------ classifier/js/tree.js | 53 ++++++++++++++++++++---- tests/classify.spec.js | 45 ++++++++++++++++++++ 5 files changed, 176 insertions(+), 21 deletions(-) diff --git a/classifier/css/layout.css b/classifier/css/layout.css index bb62a90..df324be 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -502,6 +502,7 @@ .cl-dot--done { background: var(--success); border-color: var(--success); } .cl-dot--excluded { background: var(--text-muted); border-color: var(--text-muted); opacity: 0.6; } .file-item.excluded .file-name { text-decoration: line-through; color: var(--text-muted); } +.folder-item.excluded .folder-name { text-decoration: line-through; color: var(--text-muted); } /* placed-file row in the target pane is clickable (reveal in source) */ .tfile { cursor: pointer; } diff --git a/classifier/js/classify.js b/classifier/js/classify.js index c386d14..161bed2 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -129,6 +129,15 @@ }); notify(); } + // Forget any assignment for these source keys (e.g. when a .zip flips + // between single-file and folder mode and the old keys cease to exist). + function dropAssignments(keys) { + var changed = false; + (keys || []).forEach(function (k) { + if (state.assignments[k]) { delete state.assignments[k]; changed = true; } + }); + if (changed) notify(); + } function setTitleOverride(key, title) { var a = assignmentFor(key); a.titleOverride = title && title.trim() ? title.trim() : null; @@ -550,7 +559,7 @@ srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle, // assignments assignmentFor: assignmentFor, getAssignment: getAssignment, - place: place, setExcluded: setExcluded, + place: place, setExcluded: setExcluded, dropAssignments: dropAssignments, setTitleOverride: setTitleOverride, // trees addTrackingNode: addTrackingNode, addParty: addParty, diff --git a/classifier/js/scanner.js b/classifier/js/scanner.js index 746b7d8..cc64fe7 100644 --- a/classifier/js/scanner.js +++ b/classifier/js/scanner.js @@ -323,18 +323,9 @@ fo.folderPath = node.path; files.push(fo); if (scanStats) scanStats.files++; - if (fo.extension === 'zip' && typeof JSZip !== 'undefined') { - // Don't read the archive during the listing — make an - // expandable, lazy zip node scanned on open (scanZipNode). - const zipName = zddc.joinExtension(fo.originalFilename, fo.extension); - const zipPath = node.path + '/' + zipName; - const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath }; - const zipNode = makeNode(zh, zipPath, node); - zipNode._zipFileObj = fo; - zipNode.scanState = 'zip-pending'; - childDirs.push(zipNode); - if (scanStats) scanStats.folders++; - } + // A .zip is a single file by default (one classifiable unit). + // The user can later "Expand as folder" (expandZipAsFolder) to + // pull its members into the fileset. } else if (entry.kind === 'directory') { const childPath = node.path + '/' + entry.name; childDirs.push(makeNode(entry, childPath, node)); @@ -869,6 +860,76 @@ if (!entry) throw new Error('zip member not found: ' + fileObj.zipEntryPath); return await entry.async('blob'); } + + // ── per-zip mode toggle (single file ⇄ expandable folder) ─────────────── + function findNodeByPath(path) { + var hit = null; + (function walk(ns) { (ns || []).forEach(function (n) { if (hit) return; if (n.path === path) hit = n; else walk(n.children); }); })(window.app.folderTree || []); + return hit; + } + // Recompute subtree totals after a structural change (expand/collapse a zip). + function recomputeTotals() { + (function walk(ns) { + (ns || []).forEach(function (n) { + walk(n.children || []); + var rf = (n.files || []).length, rd = (n.children || []).length; + (n.children || []).forEach(function (c) { rf += c.runFiles || 0; rd += c.runDirs || 0; }); + n.runFiles = rf; n.runDirs = rd; + n.fileCount = (n.files || []).length; n.subdirCount = (n.children || []).length; + }); + })(window.app.folderTree || []); + } + // Turn a .zip FILE into an expandable archive folder in place: scan its + // members into the fileset and drop the now-meaningless single-file + // assignment. Members come from the live handle, or (snapshot-restored) are + // re-read from the workspace root via scanZipNode's fallback. + async function expandZipAsFolder(file) { + var parent = findNodeByPath(file.folderPath); + if (!parent) return null; + var zipName = zddc.joinExtension(file.originalFilename, file.extension); + var zipPath = parent.path + '/' + zipName; + var zipNode = makeNode({ name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath }, zipPath, parent); + zipNode._zipFileObj = file.handle ? file : null; // null → scanZipNode resolves from root + zipNode.scanState = 'zip-pending'; + zipNode.expanded = true; + parent.files = (parent.files || []).filter(function (f) { return f !== file; }); + parent.children = parent.children || []; + parent.children.push(zipNode); + var c = window.app.modules.classify; + if (c && c.dropAssignments) c.dropAssignments([c.srcKeyForFile(file)]); + await scanZipNode(zipNode); + recomputeTotals(); + return zipNode; + } + // Collapse an expanded archive folder back to a single .zip file, dropping + // every member's assignment. + function collapseZipToFile(zipNode) { + if (!zipNode || !zipNode.isZipRoot) return null; + var parent = zipNode.parent || findNodeByPath(zipNode.path.slice(0, zipNode.path.lastIndexOf('/'))); + if (!parent) return null; + var c = window.app.modules.classify; + if (c && c.dropAssignments) { + var keys = []; + (function walk(n) { (n.files || []).forEach(function (f) { keys.push(c.srcKeyForFile(f)); }); (n.children || []).forEach(walk); })(zipNode); + c.dropAssignments(keys); + } + var split = zddc.splitExtension(zipNode.name); + var file = { + handle: (zipNode._zipFileObj && zipNode._zipFileObj.handle) || null, + folderHandle: (zipNode._zipFileObj && zipNode._zipFileObj.folderHandle) || null, + originalFilename: split.name, extension: split.extension, + size: null, lastModified: null, + trackingNumber: '', revision: '', status: '', title: '', + isDirty: false, error: false, errorMessage: '', validation: null, sha256: null, + folderPath: parent.path, + }; + parent.children = (parent.children || []).filter(function (n) { return n !== zipNode; }); + parent.files = parent.files || []; + parent.files.push(file); + zipCache.delete(zipNode.zipPath); + recomputeTotals(); + return file; + } async function resolveDirHandle(rootHandle, relPath) { var cur = rootHandle; var parts = (relPath || '').split('/').filter(Boolean); @@ -938,6 +999,8 @@ resolveDirHandle, ensureZipLoaded, extractZipMember, + expandZipAsFolder, + collapseZipToFile, resumeScan }; })(); diff --git a/classifier/js/tree.js b/classifier/js/tree.js index 3141075..4a3b3e7 100644 --- a/classifier/js/tree.js +++ b/classifier/js/tree.js @@ -307,10 +307,12 @@ } item.appendChild(icon); - // Classify mode: an aggregate state dot for the folder's subtree. + // Classify mode: an aggregate state dot for the folder's subtree, and a + // struck-through name when the WHOLE subtree is excluded (mirrors files). if (classifyOn()) { const agg = aggregateState(subtreeFiles(folder)); if (agg) item.appendChild(stateDot(agg)); + if (agg === 'excluded') item.classList.add('excluded'); } // Folder name @@ -899,7 +901,31 @@ } } - // ── context menu (exclude / include / clear) ─────────────────────────── + // ── per-zip mode toggle (single file ⇄ expandable folder) ─────────────── + function persistTreeChange() { + var ws = window.app.modules.workspace; + if (ws && ws.onRescanned) ws.onRescanned(); + } + async function expandZip(file) { + if (!file.handle && !window.app.rootHandle) { + if (window.zddc) window.zddc.toast('Connect the source directory first to expand this archive.', 'warning'); + return; + } + try { + var node = await window.app.modules.scanner.expandZipAsFolder(file); + if (node) { render(); persistTreeChange(); } + } catch (e) { + if (window.zddc) window.zddc.toast('Couldn’t expand the archive — ' + (e.message || e), 'error'); + } + } + function collapseZip(zipNode) { + if (!zipNode) return; + window.app.modules.scanner.collapseZipToFile(zipNode); + render(); + persistTreeChange(); + } + + // ── context menu (exclude / include / clear / zip mode) ───────────────── var menuEl = null; function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } } function showMenu(x, y, items) { @@ -938,15 +964,26 @@ if (a.trackingNodeId) items.push({ label: 'Clear tracking', fn: function () { c.place([key], null, 'tracking'); } }); if (a.transmittalNodeId) items.push({ label: 'Clear transmittal', fn: function () { c.place([key], null, 'transmittal'); } }); } + var file = findFileByKey(key); + if (file && file.isVirtual) { + items.push({ label: 'Collapse archive to single file', fn: function () { collapseZip(findFolderByPath(file.zipPath)); } }); + } else if (file && file.extension === 'zip') { + items.push({ label: 'Expand as folder', fn: function () { expandZip(file); } }); + } } else { var folder = findFolderByPath(folderEl.dataset.path); + if (folder && folder.isZipRoot) { + items.push({ label: 'Collapse to single file', fn: function () { collapseZip(folder); } }); + } var keys = keysFor(subtreeFiles(folder || { files: [], children: [] })); - if (!keys.length) return; - var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; }); - items.push({ - label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')', - fn: function () { c.setExcluded(keys, !allExcl); }, - }); + if (keys.length) { + var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; }); + items.push({ + label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')', + fn: function () { c.setExcluded(keys, !allExcl); }, + }); + } + if (!items.length) return; } showMenu(e.clientX, e.clientY, items); } diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 209df5d..980e958 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -912,3 +912,48 @@ test('workspace: import recreates a transferable record (snapshot + map, no hand expect(r.excluded).toBe(true); // classifications came across expect(r.noHandle).toBe(true); // source handle intentionally absent (re-attach on this browser) }); + +test('zip mode: collapse turns an expanded archive back into one .zip file', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const r = await page.evaluate(() => { + const c = window.app.modules.classify, sc = window.app.modules.scanner; + const member = { originalFilename: 'spec', extension: 'pdf', folderPath: 'Root/docs.zip', isVirtual: true, zipPath: 'Root/docs.zip', zipEntryPath: 'spec.pdf' }; + const zipNode = { name: 'docs.zip', path: 'Root/docs.zip', isZipRoot: true, zipPath: 'Root/docs.zip', files: [member], children: [] }; + const root = { name: 'Root', path: 'Root', files: [], children: [zipNode] }; + zipNode.parent = root; + window.app.folderTree = [root]; + c.setExcluded([c.srcKeyForFile(member)], true); + const hadAssign = !!(c.getAssignment('docs.zip/spec.pdf') || {}).excluded; + sc.collapseZipToFile(zipNode); + const file = root.files[0]; + return { + hadAssign, + noChild: root.children.length === 0, + fileName: file && (file.originalFilename + '.' + file.extension), + droppedMemberAssign: !c.getAssignment('docs.zip/spec.pdf'), + }; + }); + expect(r.hadAssign).toBe(true); // member had an assignment + expect(r.noChild).toBe(true); // archive folder removed + expect(r.fileName).toBe('docs.zip'); // single .zip file restored + expect(r.droppedMemberAssign).toBe(true); // member assignment cleared +}); + +test('a fully-excluded folder is struck through like its files', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const r = await page.evaluate(() => { + const c = window.app.modules.classify, tree = window.app.modules.tree; + const f1 = { originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' }; + const f2 = { originalFilename: 'b', extension: 'pdf', folderPath: 'Docs' }; + window.app.folderTree = [{ name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [], files: [f1, f2] }]; + tree.render(); + const sel = '#folderTree .folder-item[data-path="Docs"]'; + const before = document.querySelector(sel).classList.contains('excluded'); + c.setExcluded([c.srcKeyForFile(f1), c.srcKeyForFile(f2)], true); + tree.render(); + const after = document.querySelector(sel).classList.contains('excluded'); + return { before, after }; + }); + expect(r.before).toBe(false); // not struck through while active + expect(r.after).toBe(true); // struck through once the whole subtree is excluded +});