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
-