feat(classifier): Hide Assigned filter, left-aligned node controls, brace-expand add

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 09:31:14 -05:00
parent 6d132572d3
commit 8f839fc0c9
7 changed files with 203 additions and 17 deletions

View file

@ -405,8 +405,9 @@
background: var(--primary); color: var(--bg); background: var(--primary); color: var(--bg);
border-radius: 999px; padding: 0 0.4rem; font-size: 0.7rem; font-weight: 600; 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; } /* Node CRUD controls sit to the LEFT of the level name (always visible) so
.tnode__row:hover .tnode__actions, .tslot__row:hover .tnode__actions { opacity: 1; } 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 { .tnode__act {
border: 1px solid var(--border); background: var(--bg); border: 1px solid var(--border); background: var(--bg);
border-radius: var(--radius); cursor: pointer; border-radius: var(--radius); cursor: pointer;

View file

@ -142,6 +142,9 @@
exportHashesBtn: document.getElementById('exportHashesBtn'), exportHashesBtn: document.getElementById('exportHashesBtn'),
sha256Checkbox: document.getElementById('sha256Checkbox'), sha256Checkbox: document.getElementById('sha256Checkbox'),
hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'), hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'),
hideCompliantLabel: document.getElementById('hideCompliantLabel'),
hideAssignedCheckbox: document.getElementById('hideAssignedCheckbox'),
hideAssignedLabel: document.getElementById('hideAssignedLabel'),
// Folder tree // Folder tree
folderTree: document.getElementById('folderTree'), folderTree: document.getElementById('folderTree'),
@ -183,6 +186,10 @@
app.dom.modeClassifyBtn.classList.toggle('active', classify); app.dom.modeClassifyBtn.classList.toggle('active', classify);
if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify; if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify;
if (app.dom.targetPane) app.dom.targetPane.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); app.modules.classify.setEnabled(classify);
if (classify && app.modules.targetTree) { if (classify && app.modules.targetTree) {
app.modules.targetTree.init(); app.modules.targetTree.init();
@ -216,6 +223,15 @@
// Hide compliant toggle // Hide compliant toggle
app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle); 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 // Collapse tree button
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree); app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);

View file

@ -394,6 +394,61 @@
notify(); 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 ───────────────────────────────────────────────────────────────── // ── mode ─────────────────────────────────────────────────────────────────
function setEnabled(on) { state.enabled = !!on; notify(); } function setEnabled(on) { state.enabled = !!on; notify(); }
function isEnabled() { return state.enabled; } function isEnabled() { return state.enabled; }
@ -412,6 +467,7 @@
// trees // trees
addTrackingNode: addTrackingNode, addParty: addParty, addTrackingNode: addTrackingNode, addParty: addParty,
addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode, addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode,
expandFolderPattern: expandFolderPattern,
getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
getTransmittalTree: function () { return state.transmittalTree; }, getTransmittalTree: function () { return state.transmittalTree; },
// derive + reverse // derive + reverse

View file

@ -19,6 +19,7 @@
var collapsed = {}; // nodeId -> true when collapsed (default expanded) var collapsed = {}; // nodeId -> true when collapsed (default expanded)
var openForm = null; // { partyId, slot } when a bin form is open var openForm = null; // { partyId, slot } when a bin form is open
var initialized = false; var initialized = false;
var currentTab = 'tracking'; // 'tracking' | 'transmittal' — the active axis
function init() { function init() {
if (initialized) return; if (initialized) return;
@ -38,8 +39,9 @@
els.trackingTab.addEventListener('click', function () { showTab('tracking'); }); els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); }); els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
els.addTrackingRootBtn.addEventListener('click', function () { els.addTrackingRootBtn.addEventListener('click', function () {
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ"):', ''); var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
if (name && name.trim()) C().addTrackingNode(null, name.trim()); + 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', '');
addFoldersFromPattern(null, name);
}); });
els.addPartyBtn.addEventListener('click', function () { els.addPartyBtn.addEventListener('click', function () {
var name = prompt('Party name (also the transmittal-number prefix):', ''); var name = prompt('Party name (also the transmittal-number prefix):', '');
@ -86,10 +88,29 @@
function showTab(which) { function showTab(which) {
var t = which === 'transmittal'; var t = which === 'transmittal';
currentTab = t ? 'transmittal' : 'tracking';
els.trackingTab.classList.toggle('active', !t); els.trackingTab.classList.toggle('active', !t);
els.transmittalTab.classList.toggle('active', t); els.transmittalTab.classList.toggle('active', t);
els.trackingPanel.hidden = t; els.trackingPanel.hidden = t;
els.transmittalPanel.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 ─────────────────────────────────────────────────────────────── // ── render ───────────────────────────────────────────────────────────────
@ -166,16 +187,15 @@
var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (collapsed[n.id] ? '▸' : '▾')); var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (collapsed[n.id] ? '▸' : '▾'));
if (!isLeaf) toggle.dataset.act = 'toggle'; if (!isLeaf) toggle.dataset.act = 'toggle';
row.appendChild(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([ row.appendChild(nodeActions([
{ act: 'add', label: '', title: 'Add child folder' }, { act: 'add', label: '', title: 'Add child folder' },
{ act: 'rename', label: '✎', title: 'Rename' }, { act: 'rename', label: '✎', title: 'Rename' },
{ act: 'del', label: '🗑', title: 'Delete' }, { 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); wrap.appendChild(row);
if (placed.length) wrap.appendChild(fileList(placed)); if (placed.length) wrap.appendChild(fileList(placed));
@ -201,11 +221,11 @@
wrap.dataset.id = party.id; wrap.dataset.id = party.id;
var row = el('div', 'tnode__row'); var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__icon', '🏢')); row.appendChild(el('span', 'tnode__icon', '🏢'));
row.appendChild(el('span', 'tnode__name', party.name));
row.appendChild(nodeActions([ row.appendChild(nodeActions([
{ act: 'rename-party', label: '✎', title: 'Rename party' }, { act: 'rename-party', label: '✎', title: 'Rename party' },
{ act: 'del-party', label: '🗑', title: 'Delete party' }, { act: 'del-party', label: '🗑', title: 'Delete party' },
])); ]));
row.appendChild(el('span', 'tnode__name', party.name));
wrap.appendChild(row); wrap.appendChild(row);
SLOTS.forEach(function (slot) { SLOTS.forEach(function (slot) {
@ -234,10 +254,10 @@
var wrap = el('div', 'tnode tnode--bin'); var wrap = el('div', 'tnode tnode--bin');
wrap.dataset.id = bin.id; wrap.dataset.id = bin.id;
var row = el('div', 'tnode__row'); 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)')); row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
var placed = placedMap[bin.id] || []; var placed = placedMap[bin.id] || [];
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length))); if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }]));
wrap.appendChild(row); wrap.appendChild(row);
if (placed.length) wrap.appendChild(fileList(placed)); if (placed.length) wrap.appendChild(fileList(placed));
return wrap; return wrap;
@ -283,8 +303,9 @@
var id = closestNodeId(btn); var id = closestNodeId(btn);
if (act === 'toggle') { collapsed[id] = !collapsed[id]; render(); return; } if (act === 'toggle') { collapsed[id] = !collapsed[id]; render(); return; }
if (act === 'add') { if (act === 'add') {
var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)"):', ''); var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)").\n'
if (name && name.trim()) C().addTrackingNode(id, name.trim()); + 'Brace patterns expand: {PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)', '');
addFoldersFromPattern(id, name);
} else if (act === 'rename') { } else if (act === 'rename') {
var node = C().getNode(id); var node = C().getNode(id);
var nn = prompt('Rename folder:', node ? node.name : ''); var nn = prompt('Rename folder:', node ? node.name : '');
@ -396,6 +417,7 @@
init: init, init: init,
render: render, render: render,
showTab: showTab, showTab: showTab,
activeAxis: activeAxis,
reveal: reveal, reveal: reveal,
}; };
})(); })();

View file

@ -35,6 +35,37 @@
return dot; 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 * Render the folder tree
*/ */
@ -49,6 +80,7 @@
} }
window.app.folderTree.forEach(folder => { window.app.folderTree.forEach(folder => {
if (folderHidden(folder)) return;
const element = createFolderElement(folder); const element = createFolderElement(folder);
container.appendChild(element); container.appendChild(element);
}); });
@ -241,6 +273,7 @@
const childrenDiv = document.createElement('div'); const childrenDiv = document.createElement('div');
childrenDiv.className = 'folder-children'; childrenDiv.className = 'folder-children';
folder.children.forEach(child => { folder.children.forEach(child => {
if (folderHidden(child)) return;
const childElement = createFolderElement(child, level + 1); const childElement = createFolderElement(child, level + 1);
childrenDiv.appendChild(childElement); childrenDiv.appendChild(childElement);
}); });
@ -253,6 +286,7 @@
const filesDiv = document.createElement('div'); const filesDiv = document.createElement('div');
filesDiv.className = 'folder-children folder-files'; filesDiv.className = 'folder-children folder-files';
folder.files.forEach(function (file) { folder.files.forEach(function (file) {
if (hideAssigned && fileDealtWith(file)) return;
filesDiv.appendChild(createFileElement(file, level + 1)); filesDiv.appendChild(createFileElement(file, level + 1));
}); });
div.appendChild(filesDiv); div.appendChild(filesDiv);
@ -829,6 +863,7 @@
setupKeyboardShortcuts, setupKeyboardShortcuts,
expandAll, expandAll,
selectAll, selectAll,
revealFile revealFile,
setHideAssigned
}; };
})(); })();

View file

@ -57,10 +57,15 @@
<input type="checkbox" id="autoScrollCheckbox" checked> <input type="checkbox" id="autoScrollCheckbox" checked>
Auto-scroll Auto-scroll
</label> </label>
<label class="checkbox-label"> <label class="checkbox-label" id="hideCompliantLabel">
<input type="checkbox" id="hideCompliantCheckbox"> <input type="checkbox" id="hideCompliantCheckbox">
Hide Compliant Hide Compliant
</label> </label>
<label class="checkbox-label" id="hideAssignedLabel" hidden
title="Hide files already assigned in the active tab (or excluded), and folders left empty">
<input type="checkbox" id="hideAssignedCheckbox">
Hide Assigned
</label>
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span> <span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
</div> </div>
</div> </div>

View file

@ -532,3 +532,54 @@ test('deleting a tracking node clears the files placed in it', async ({ page })
}, FILE); }, FILE);
expect(after).toBe('none'); expect(after).toBe('none');
}); });
test('expandFolderPattern: alternation, zero-padded ranges, cartesian product', async ({ page }) => {
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
return {
plain: c.expandFolderPattern('Plain'),
alt: c.expandFolderPattern('A-{PM,EL,EM}'),
range: c.expandFolderPattern('X-{0001-0002,0005}'),
full: c.expandFolderPattern('BMB-187023-{PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)'),
unbalanced: c.expandFolderPattern('Lit-{oops'),
};
});
expect(r.plain).toEqual(['Plain']);
expect(r.alt).toEqual(['A-PM', 'A-EL', 'A-EM']);
expect(r.range).toEqual(['X-0001', 'X-0002', 'X-0005']);
expect(r.full.length).toBe(9);
expect(r.full[0]).toBe('BMB-187023-PM-MOM-0001_A (IFR)');
expect(r.full).toContain('BMB-187023-EM-MOM-0005_A (IFR)');
expect(r.unbalanced).toEqual(['Lit-{oops']); // unbalanced brace kept literal
});
test('Hide Assigned: hides files dealt-with on the active axis and folders left empty', async ({ page }) => {
await page.click('#modeClassifyBtn');
const before = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const fA = { originalFilename: 'a1', extension: 'pdf', folderPath: 'A' };
window.app.folderTree = [
{ name: 'A', path: 'A', expanded: true, scanState: 'done', files: [fA], children: [] },
{ name: 'B', path: 'B', expanded: true, scanState: 'done',
files: [{ originalFilename: 'b1', extension: 'pdf', folderPath: 'B' }], children: [] },
];
const t = c.addTrackingNode(null, 'TN'); // assign A's file on the tracking axis
c.place([c.srcKeyForFile(fA)], t, 'tracking');
window.app.modules.tree.render();
return document.querySelectorAll('#folderTree .file-item').length;
});
expect(before).toBe(2); // nothing hidden yet
const after = await page.evaluate(() => {
window.app.modules.tree.setHideAssigned(true);
return {
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map(e => e.textContent),
folderA: !!document.querySelector('#folderTree .folder-item[data-path="A"]'),
folderB: !!document.querySelector('#folderTree .folder-item[data-path="B"]'),
};
});
expect(after.folderA).toBe(false); // A's only file is assigned on the active (tracking) axis → folder hidden
expect(after.folderB).toBe(true); // B still needs a tracking number → stays
expect(after.files).toEqual(['b1.pdf']);
});