From 8f839fc0c9ad9a8f28fe8d64eea311ec339ff0b5 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 10 Jun 2026 09:31:14 -0500 Subject: [PATCH] feat(classifier): Hide Assigned filter, left-aligned node controls, brace-expand add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Classify & Copy polish — in either target tab the goal is to assign or exclude every left-pane file until nothing remains: - Hide Assigned checkbox (classify mode, in the folder-tree pane header): collapses the source tree to only what's left on the ACTIVE axis — hides files already assigned in the current tab (or excluded) and any folder whose scanned subtree is thereby empty. Re-renders on tab switch; target-tree exposes activeAxis(). - Node add/edit/delete controls moved to the LEFT of the level name and made always-visible (was right-aligned + hover-only), so building/pruning the tracking and transmittal trees is one click. - Brace expansion in the add-folder box: "BMB-187023-{PM,EL,EM}-MOM- {0001-0002,0005}_A (IFR)" creates all 9 folders — {a,b} alternation + {N-M} zero-padded numeric ranges, cartesian product across groups; a multi-create is confirmed first. New classify.expandFolderPattern(). Tests: expandFolderPattern unit cases + a Hide-Assigned DOM test (classify.spec.js → 29 passed; classifier.spec.js → 4 passed). Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/css/layout.css | 5 ++-- classifier/js/app.js | 20 +++++++++++-- classifier/js/classify.js | 56 ++++++++++++++++++++++++++++++++++++ classifier/js/target-tree.js | 44 +++++++++++++++++++++------- classifier/js/tree.js | 37 +++++++++++++++++++++++- classifier/template.html | 7 ++++- tests/classify.spec.js | 51 ++++++++++++++++++++++++++++++++ 7 files changed, 203 insertions(+), 17 deletions(-) diff --git a/classifier/css/layout.css b/classifier/css/layout.css index 3f59fca..c0ce6e8 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -405,8 +405,9 @@ background: var(--primary); color: var(--bg); border-radius: 999px; padding: 0 0.4rem; font-size: 0.7rem; font-weight: 600; } -.tnode__actions { margin-left: auto; display: inline-flex; gap: 0.1rem; opacity: 0; transition: opacity 0.12s; } -.tnode__row:hover .tnode__actions, .tslot__row:hover .tnode__actions { opacity: 1; } +/* Node CRUD controls sit to the LEFT of the level name (always visible) so + building/pruning the tree is a one-click affordance, not a hover-hunt. */ +.tnode__actions { display: inline-flex; gap: 0.1rem; margin-right: 0.15rem; flex: 0 0 auto; } .tnode__act { border: 1px solid var(--border); background: var(--bg); border-radius: var(--radius); cursor: pointer; diff --git a/classifier/js/app.js b/classifier/js/app.js index 3afc440..48a60eb 100644 --- a/classifier/js/app.js +++ b/classifier/js/app.js @@ -142,7 +142,10 @@ exportHashesBtn: document.getElementById('exportHashesBtn'), sha256Checkbox: document.getElementById('sha256Checkbox'), hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'), - + hideCompliantLabel: document.getElementById('hideCompliantLabel'), + hideAssignedCheckbox: document.getElementById('hideAssignedCheckbox'), + hideAssignedLabel: document.getElementById('hideAssignedLabel'), + // Folder tree folderTree: document.getElementById('folderTree'), folderTreePane: document.getElementById('folderTreePane'), @@ -183,6 +186,10 @@ app.dom.modeClassifyBtn.classList.toggle('active', classify); if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify; if (app.dom.targetPane) app.dom.targetPane.hidden = !classify; + // Mode-specific source-tree filters: "Hide Compliant" is for the rename + // grid; "Hide Assigned" is for the classify workflow. + if (app.dom.hideCompliantLabel) app.dom.hideCompliantLabel.hidden = classify; + if (app.dom.hideAssignedLabel) app.dom.hideAssignedLabel.hidden = !classify; app.modules.classify.setEnabled(classify); if (classify && app.modules.targetTree) { app.modules.targetTree.init(); @@ -215,7 +222,16 @@ // Hide compliant toggle app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle); - + + // Hide assigned toggle (classify mode — filters the source tree) + if (app.dom.hideAssignedCheckbox) { + app.dom.hideAssignedCheckbox.addEventListener('change', function () { + if (app.modules.tree && app.modules.tree.setHideAssigned) { + app.modules.tree.setHideAssigned(this.checked); + } + }); + } + // Collapse tree button app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree); diff --git a/classifier/js/classify.js b/classifier/js/classify.js index 824636e..fc2b286 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -394,6 +394,61 @@ notify(); } + // ── add-folder pattern expansion ───────────────────────────────────────── + // Brace expansion for the add-folder box. Supports (non-nested) groups: + // {a,b,c} → alternation: a | b | c + // {0001-0002} → numeric range, zero-padded to the operands' width + // {0001-0002,0005} → mix ranges and literals in one group + // Multiple groups expand as a cartesian product, e.g. + // "X-{PM,EL}-{0001-0002,0005}_A (IFR)" → 6 names. + // A pattern with no braces returns itself (one name). Unbalanced braces are + // treated literally so the user never silently loses input. + function expandGroup(body) { + var out = []; + String(body).split(',').forEach(function (piece) { + var m = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(piece); + if (m) { + var a = m[1], b = m[2]; + var start = parseInt(a, 10), end = parseInt(b, 10); + // Pad when either operand carries a leading zero (e.g. 0001). + var width = (a.length > 1 && a[0] === '0') || (b.length > 1 && b[0] === '0') + ? Math.max(a.length, b.length) : 0; + var step = start <= end ? 1 : -1; + for (var v = start; step > 0 ? v <= end : v >= end; v += step) { + out.push(width ? String(v).padStart(width, '0') : String(v)); + } + } else { + out.push(piece); + } + }); + return out; + } + function expandFolderPattern(pattern) { + var s = String(pattern == null ? '' : pattern); + var parts = []; // each: {lit} or {opts:[...]} + var i = 0; + while (i < s.length) { + var open = s.indexOf('{', i); + if (open === -1) { parts.push({ lit: s.slice(i) }); break; } + var close = s.indexOf('}', open); + if (close === -1) { parts.push({ lit: s.slice(i) }); break; } // unbalanced → literal + if (open > i) parts.push({ lit: s.slice(i, open) }); + parts.push({ opts: expandGroup(s.slice(open + 1, close)) }); + i = close + 1; + } + var results = ['']; + parts.forEach(function (p) { + var opts = p.lit != null ? [p.lit] : p.opts; + var next = []; + results.forEach(function (prefix) { + opts.forEach(function (o) { next.push(prefix + o); }); + }); + results = next; + }); + // Trim + drop empties so a stray comma can't create a blank folder. + return results.map(function (r) { return r.trim(); }).filter(Boolean); + } + // ── mode ───────────────────────────────────────────────────────────────── function setEnabled(on) { state.enabled = !!on; notify(); } function isEnabled() { return state.enabled; } @@ -412,6 +467,7 @@ // trees addTrackingNode: addTrackingNode, addParty: addParty, addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode, + expandFolderPattern: expandFolderPattern, getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, getTransmittalTree: function () { return state.transmittalTree; }, // derive + reverse diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index cb7b39f..ce7e763 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -19,6 +19,7 @@ var collapsed = {}; // nodeId -> true when collapsed (default expanded) var openForm = null; // { partyId, slot } when a bin form is open var initialized = false; + var currentTab = 'tracking'; // 'tracking' | 'transmittal' — the active axis function init() { if (initialized) return; @@ -38,8 +39,9 @@ els.trackingTab.addEventListener('click', function () { showTab('tracking'); }); els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); }); els.addTrackingRootBtn.addEventListener('click', function () { - var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ"):', ''); - if (name && name.trim()) C().addTrackingNode(null, name.trim()); + var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n' + + 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', ''); + addFoldersFromPattern(null, name); }); els.addPartyBtn.addEventListener('click', function () { var name = prompt('Party name (also the transmittal-number prefix):', ''); @@ -86,10 +88,29 @@ function showTab(which) { var t = which === 'transmittal'; + currentTab = t ? 'transmittal' : 'tracking'; els.trackingTab.classList.toggle('active', !t); els.transmittalTab.classList.toggle('active', t); els.trackingPanel.hidden = t; els.transmittalPanel.hidden = !t; + // The "Hide Assigned" filter on the source tree is per-axis, so the + // visible set changes with the active tab — re-render the left tree. + if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); + } + function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; } + + // Expand a brace pattern into folder names and create them (confirming a + // multi-create first). parentId null = root folders. See expandFolderPattern. + function addFoldersFromPattern(parentId, raw) { + if (!raw || !raw.trim()) return; + var names = C().expandFolderPattern(raw); + if (!names.length) return; + if (names.length > 1) { + var shown = names.slice(0, 8).join('\n'); + if (names.length > 8) shown += '\n…and ' + (names.length - 8) + ' more'; + if (!confirm('Create ' + names.length + ' folders?\n\n' + shown)) return; + } + names.forEach(function (nm) { C().addTrackingNode(parentId, nm); }); } // ── render ─────────────────────────────────────────────────────────────── @@ -166,16 +187,15 @@ var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (collapsed[n.id] ? '▸' : '▾')); if (!isLeaf) toggle.dataset.act = 'toggle'; row.appendChild(toggle); - row.appendChild(el('span', 'tnode__name', n.name)); - - var placed = placedMap[n.id] || []; - if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); - row.appendChild(nodeActions([ { act: 'add', label: '+', title: 'Add child folder' }, { act: 'rename', label: '✎', title: 'Rename' }, { act: 'del', label: '🗑', title: 'Delete' }, ])); + row.appendChild(el('span', 'tnode__name', n.name)); + + var placed = placedMap[n.id] || []; + if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); wrap.appendChild(row); if (placed.length) wrap.appendChild(fileList(placed)); @@ -201,11 +221,11 @@ wrap.dataset.id = party.id; var row = el('div', 'tnode__row'); row.appendChild(el('span', 'tnode__icon', '🏢')); - row.appendChild(el('span', 'tnode__name', party.name)); row.appendChild(nodeActions([ { act: 'rename-party', label: '✎', title: 'Rename party' }, { act: 'del-party', label: '🗑', title: 'Delete party' }, ])); + row.appendChild(el('span', 'tnode__name', party.name)); wrap.appendChild(row); SLOTS.forEach(function (slot) { @@ -234,10 +254,10 @@ var wrap = el('div', 'tnode tnode--bin'); wrap.dataset.id = bin.id; var row = el('div', 'tnode__row'); + row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }])); row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)')); var placed = placedMap[bin.id] || []; if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); - row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }])); wrap.appendChild(row); if (placed.length) wrap.appendChild(fileList(placed)); return wrap; @@ -283,8 +303,9 @@ var id = closestNodeId(btn); if (act === 'toggle') { collapsed[id] = !collapsed[id]; render(); return; } if (act === 'add') { - var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)"):', ''); - if (name && name.trim()) C().addTrackingNode(id, name.trim()); + var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)").\n' + + 'Brace patterns expand: {PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)', ''); + addFoldersFromPattern(id, name); } else if (act === 'rename') { var node = C().getNode(id); var nn = prompt('Rename folder:', node ? node.name : ''); @@ -396,6 +417,7 @@ init: init, render: render, showTab: showTab, + activeAxis: activeAxis, reveal: reveal, }; })(); diff --git a/classifier/js/tree.js b/classifier/js/tree.js index 1f49466..985fc73 100644 --- a/classifier/js/tree.js +++ b/classifier/js/tree.js @@ -35,6 +35,37 @@ return dot; } + // ── "Hide Assigned" filter (classify mode) ───────────────────────────── + // The goal in either target tab is to assign-or-exclude every file, so this + // collapses the left tree down to only what's left to deal with on the + // ACTIVE axis: hide files already assigned in the current tab (or excluded), + // and any folder whose whole (scanned) subtree is thereby empty. + var hideAssigned = false; + function setHideAssigned(on) { hideAssigned = !!on; render(); } + function activeAxis() { + var tt = window.app.modules.targetTree; + return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking'; + } + function fileDealtWith(file) { + var c = window.app.modules.classify; + var a = c.getAssignment(c.srcKeyForFile(file)); + if (!a) return false; + if (a.excluded) return true; + return activeAxis() === 'transmittal' ? !!a.transmittalNodeId : !!a.trackingNodeId; + } + function subtreeRemaining(folder) { + var n = 0; + subtreeFiles(folder).forEach(function (f) { if (!fileDealtWith(f)) n++; }); + return n; + } + // Hide a folder only when it's fully scanned (so we never hide one that may + // still reveal files) and nothing in its subtree remains to be dealt with. + function folderHidden(folder) { + if (!classifyOn() || !hideAssigned) return false; + if (folder.scanState && folder.scanState !== 'done') return false; + return subtreeRemaining(folder) === 0; + } + /** * Render the folder tree */ @@ -49,6 +80,7 @@ } window.app.folderTree.forEach(folder => { + if (folderHidden(folder)) return; const element = createFolderElement(folder); container.appendChild(element); }); @@ -241,6 +273,7 @@ const childrenDiv = document.createElement('div'); childrenDiv.className = 'folder-children'; folder.children.forEach(child => { + if (folderHidden(child)) return; const childElement = createFolderElement(child, level + 1); childrenDiv.appendChild(childElement); }); @@ -253,6 +286,7 @@ const filesDiv = document.createElement('div'); filesDiv.className = 'folder-children folder-files'; folder.files.forEach(function (file) { + if (hideAssigned && fileDealtWith(file)) return; filesDiv.appendChild(createFileElement(file, level + 1)); }); div.appendChild(filesDiv); @@ -829,6 +863,7 @@ setupKeyboardShortcuts, expandAll, selectAll, - revealFile + revealFile, + setHideAssigned }; })(); diff --git a/classifier/template.html b/classifier/template.html index 290f85c..c03d3cc 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -57,10 +57,15 @@ Auto-scroll -