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:
parent
6d132572d3
commit
8f839fc0c9
7 changed files with 203 additions and 17 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -57,10 +57,15 @@
|
|||
<input type="checkbox" id="autoScrollCheckbox" checked>
|
||||
Auto-scroll
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<label class="checkbox-label" id="hideCompliantLabel">
|
||||
<input type="checkbox" id="hideCompliantCheckbox">
|
||||
Hide Compliant
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -532,3 +532,54 @@ test('deleting a tracking node clears the files placed in it', async ({ page })
|
|||
}, FILE);
|
||||
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']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue