diff --git a/classifier/css/layout.css b/classifier/css/layout.css index e126724..8547a1d 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -146,6 +146,10 @@ align-items: flex-end; } +/* Classify-mode source-tree filters (Show Unassigned/Assigned/Excluded). */ +.classify-filters { display: inline-flex; flex-wrap: wrap; gap: 0.3rem 0.75rem; justify-content: flex-end; } +.classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; } + .folder-stats, .file-stats { display: flex; @@ -478,7 +482,13 @@ /* placed-file row in the target pane is clickable (reveal in source) */ .tfile { cursor: pointer; } -.tfile:hover .tfile__name { text-decoration: underline; } +.tfile:hover .tfile__orig { text-decoration: underline; } /* click row (not the name input) → preview */ +input.tfile__name { + flex: 1 1 auto; min-width: 10rem; font: inherit; color: var(--text); + border: 1px solid transparent; background: transparent; border-radius: 3px; padding: 0 0.2rem; +} +input.tfile__name:hover { border-color: var(--border); } +input.tfile__name:focus { border-color: var(--primary); background: var(--bg); outline: none; } /* cross-tree reveal flash */ .reveal-flash, .match-highlight { animation: cl-flash 1.5s ease-out; } diff --git a/classifier/js/app.js b/classifier/js/app.js index 48a60eb..516219a 100644 --- a/classifier/js/app.js +++ b/classifier/js/app.js @@ -143,8 +143,10 @@ sha256Checkbox: document.getElementById('sha256Checkbox'), hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'), hideCompliantLabel: document.getElementById('hideCompliantLabel'), - hideAssignedCheckbox: document.getElementById('hideAssignedCheckbox'), - hideAssignedLabel: document.getElementById('hideAssignedLabel'), + classifyFilters: document.getElementById('classifyFilters'), + showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'), + showAssignedCheckbox: document.getElementById('showAssignedCheckbox'), + showExcludedCheckbox: document.getElementById('showExcludedCheckbox'), // Folder tree folderTree: document.getElementById('folderTree'), @@ -189,7 +191,7 @@ // 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; + if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = !classify; app.modules.classify.setEnabled(classify); if (classify && app.modules.targetTree) { app.modules.targetTree.init(); @@ -223,14 +225,18 @@ // 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); - } - }); + // Classify-mode source-tree filters: show/hide unassigned, assigned, excluded. + function pushClassifyFilters() { + if (app.modules.tree && app.modules.tree.setShowFilters) { + app.modules.tree.setShowFilters({ + unassigned: app.dom.showUnassignedCheckbox.checked, + assigned: app.dom.showAssignedCheckbox.checked, + excluded: app.dom.showExcludedCheckbox.checked, + }); + } } + [app.dom.showUnassignedCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox] + .forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); }); // Collapse tree button app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree); diff --git a/classifier/js/classify.js b/classifier/js/classify.js index a705a84..c7f9a15 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -486,6 +486,23 @@ return cur; } + // A tracking node is a "complete" drop target when it's a leaf whose name + // carries a valid "(STATUS)" — i.e. a file dropped there yields a full name + // with no more levels needed. Used to decide whether a drop should prompt. + function trackingNodeComplete(nodeId) { + var info = infoFor(nodeId); + if (!info || info.kind !== 'tracking') return false; + if ((info.node.children || []).length) return false; + var leaf = parseLeafLabel(info.node.name); + return !!(leaf.status && zddc.isValidStatus(leaf.status)); + } + // Human-readable "root / … / node" path for a tracking node (prompt context). + function trackingPathLabel(nodeId) { + var info = infoFor(nodeId); + if (!info || info.kind !== 'tracking') return ''; + return trackingChain(info).join(' / '); + } + // ── mode ───────────────────────────────────────────────────────────────── function setEnabled(on) { state.enabled = !!on; notify(); } function isEnabled() { return state.enabled; } @@ -506,6 +523,7 @@ addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode, expandFolderPattern: expandFolderPattern, parseFolderLevels: parseFolderLevels, addTrackingPath: addTrackingPath, + trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel, 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 f866be5..288747d 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -50,6 +50,8 @@ els.trackingTree.addEventListener('click', onTrackingClick); els.transmittalTree.addEventListener('click', onTransmittalClick); + els.trackingTree.addEventListener('change', onFileNameChange); + els.transmittalTree.addEventListener('change', onFileNameChange); setupDropZone(els.trackingTree, 'tracking'); setupDropZone(els.transmittalTree, 'transmittal'); @@ -161,11 +163,18 @@ files.forEach(function (f) { var d = C().deriveTarget(f); var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : '')); - row.title = d.errors.length ? d.errors.join('; ') : 'Click to find this file in the source tree'; - row.dataset.key = d.key; // for cross-tree reveal - row.appendChild(el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : ''))); + row.dataset.key = d.key; + var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')); + orig.title = 'Click to preview'; + row.appendChild(orig); row.appendChild(el('span', 'tfile__arrow', '→')); - row.appendChild(el('span', 'tfile__name', d.filename || '(incomplete)')); + // Editable derived filename — edit it to re-file the item. + var name = el('input', 'tfile__name' + (d.errors.length ? ' tfile__name--err' : '')); + name.type = 'text'; + name.value = d.filename || ''; + name.placeholder = '(incomplete)'; + name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item'; + row.appendChild(name); box.appendChild(row); }); return box; @@ -290,21 +299,65 @@ var n = target.closest('.tnode'); return n ? n.dataset.id : null; } - function revealInSource(e) { + function fileByKey(key) { + var files = allFiles(); + for (var i = 0; i < files.length; i++) { if (C().srcKeyForFile(files[i]) === key) return files[i]; } + return null; + } + // Click a placed-file row (anywhere but its editable name) → preview it. + function previewFromTarget(e) { + if (e.target.closest('.tfile__name')) return false; var tf = e.target.closest('.tfile'); - if (tf && tf.dataset.key && window.app.modules.tree.revealFile) { - window.app.modules.tree.revealFile(tf.dataset.key); - return true; + if (!tf || !tf.dataset.key) return false; + var f = fileByKey(tf.dataset.key); + if (f && window.app.modules.preview && window.app.modules.preview.previewFile) { + window.app.modules.preview.previewFile(f); } - return false; + return true; + } + // Edited a placed-file's ZDDC filename → re-derive its tracking placement + // (creating the folder path if needed) + its title override. + function onFileNameChange(e) { + var input = e.target.closest('.tfile__name'); + if (input) commitFilenameEdit(input); + } + function commitFilenameEdit(input) { + var tf = input.closest('.tfile'); + if (!tf || !tf.dataset.key) return; + var parsed = window.zddc.parseFilename((input.value || '').trim()); + if (!parsed || !parsed.valid) { + window.zddc.toast('Not a valid ZDDC filename — expected "TRACKING_REV (STATUS) - Title.ext".', 'warning'); + render(); // restore the derived value + return; + } + var stem = parsed.trackingNumber + '_' + parsed.revision + ' (' + parsed.status + ')'; + var leaf = C().addTrackingPath(null, C().parseFolderLevels(stem)); + C().place([tf.dataset.key], leaf, 'tracking'); + if (parsed.title != null) C().setTitleOverride(tf.dataset.key, parsed.title); + // place/setTitleOverride fire classify.notify → re-render. + } + // Collapse/expand a node and its whole subtree (ctrl/cmd-click a toggle). + function setSubtreeCollapsed(nodeId, collapse) { + var node = C().getNode(nodeId); + if (!node) return; + (function walk(n) { + if ((n.children || []).length) { if (collapse) collapsed[n.id] = true; else delete collapsed[n.id]; } + (n.children || []).forEach(walk); + })(node); } function onTrackingClick(e) { - if (revealInSource(e)) return; + if (previewFromTarget(e)) return; var btn = e.target.closest('[data-act]'); if (!btn) return; var act = btn.dataset.act; var id = closestNodeId(btn); - if (act === 'toggle') { collapsed[id] = !collapsed[id]; render(); return; } + if (act === 'toggle') { + var collapse = !collapsed[id]; + if (e.ctrlKey || e.metaKey) setSubtreeCollapsed(id, collapse); + else if (collapse) collapsed[id] = true; else delete collapsed[id]; + render(); + return; + } if (act === 'add') { 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)', ''); @@ -318,7 +371,7 @@ } } function onTransmittalClick(e) { - if (revealInSource(e)) return; + if (previewFromTarget(e)) return; var btn = e.target.closest('[data-act]'); if (!btn) return; var act = btn.dataset.act; @@ -391,10 +444,26 @@ e.preventDefault(); var keys = window.app.modules.dnd.getDrag(); window.app.modules.dnd.clearDrag(); - if (keys.length) C().place(keys, t.id, axis); + if (!keys.length) return; + if (axis === 'tracking') placeTrackingDrop(keys, t.id); + else C().place(keys, t.id, axis); }); } + // Tracking drop: if the target is already a complete leaf, assign directly; + // otherwise prompt for the remaining levels (parsed + nested under it) so a + // file can be dropped on an existing partial tracking number and completed. + function placeTrackingDrop(keys, nodeId) { + if (C().trackingNodeComplete(nodeId)) { C().place(keys, nodeId, 'tracking'); return; } + var label = C().trackingPathLabel(nodeId); + var input = prompt('Dropping under "' + label + '".\n' + + 'Add the remaining tracking levels (e.g. "0001_0 (IFU)"), or leave blank to drop here:', ''); + if (input === null) return; // cancelled + var levels = C().parseFolderLevels(input.trim()); + var target = levels.length ? C().addTrackingPath(nodeId, levels) : nodeId; + C().place(keys, target, 'tracking'); + } + // Reveal a source key's placement in the target pane (source → target). function reveal(key) { var a = C().getAssignment(key); diff --git a/classifier/js/tree.js b/classifier/js/tree.js index 985fc73..2dea00c 100644 --- a/classifier/js/tree.js +++ b/classifier/js/tree.js @@ -35,35 +35,61 @@ 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(); } + // ── Classify-mode source-tree filters ────────────────────────────────── + // The goal in either target tab is to assign-or-exclude every file. Each + // file falls in one bucket FOR THE ACTIVE AXIS — unassigned / assigned / + // excluded — and three "Show …" toggles control which buckets are visible + // (so unchecking Assigned+Excluded leaves only what's left to do). A folder + // whose whole scanned subtree is filtered away is itself hidden. + var showFilters = { unassigned: true, assigned: true, excluded: true }; + function setShowFilters(f) { + showFilters = { + unassigned: f.unassigned !== false, + assigned: f.assigned !== false, + excluded: f.excluded !== false, + }; + render(); + } + function allFiltersOn() { return showFilters.unassigned && showFilters.assigned && showFilters.excluded; } function activeAxis() { var tt = window.app.modules.targetTree; return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking'; } - function fileDealtWith(file) { + // Bucket a file relative to the active axis: 'excluded' | 'assigned' | 'unassigned'. + function fileCategory(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; + if (a && a.excluded) return 'excluded'; + var assigned = a && (activeAxis() === 'transmittal' ? a.transmittalNodeId : a.trackingNodeId); + return assigned ? 'assigned' : 'unassigned'; } - function subtreeRemaining(folder) { + function fileVisible(file) { return !!showFilters[fileCategory(file)]; } + function subtreeVisibleCount(folder) { var n = 0; - subtreeFiles(folder).forEach(function (f) { if (!fileDealtWith(f)) n++; }); + subtreeFiles(folder).forEach(function (f) { if (fileVisible(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. + // still reveal files) and the active filters leave nothing visible in it. function folderHidden(folder) { - if (!classifyOn() || !hideAssigned) return false; + if (!classifyOn() || allFiltersOn()) return false; if (folder.scanState && folder.scanState !== 'done') return false; - return subtreeRemaining(folder) === 0; + return subtreeVisibleCount(folder) === 0; + } + // All scanned files (for the per-bucket counts on the filter checkboxes). + function allClassifyFiles() { + var out = []; + (window.app.folderTree || []).forEach(function (f) { subtreeFiles(f, out); }); + return out; + } + function updateFilterCounts() { + if (!classifyOn()) return; + var n = { unassigned: 0, assigned: 0, excluded: 0 }; + allClassifyFiles().forEach(function (f) { n[fileCategory(f)]++; }); + ['unassigned', 'assigned', 'excluded'].forEach(function (k) { + var el = document.getElementById('show' + k.charAt(0).toUpperCase() + k.slice(1) + 'Count'); + if (el) el.textContent = '(' + n[k] + ')'; + }); } /** @@ -73,6 +99,7 @@ const container = window.app.dom.folderTree; wireClassifyInteractions(); container.innerHTML = ''; + updateFilterCounts(); if (window.app.folderTree.length === 0) { container.innerHTML = '