From 93ed0d361f9e5088bd3847cacf8cca2379e346db Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 11 Jun 2026 19:39:55 -0500 Subject: [PATCH] feat(classifier): "By existing" tab + multi-select directory picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape the catalog from a button+overlay into a proper tab and let the user choose which directories feed it. - Tabs: the Catalog button becomes a third tab "By existing", grouped with "By tracking number" (both assign a tracking number) and visually separated from "By transmittal" (which assigns the path). A brief goal line above the tabs states the workflow. The overlay #mdlPanel becomes a normal in-flow tab panel. - Load: instead of auto-scanning a whole project, "Load…" opens a lazy, multi-select (checkbox) directory tree (new classifier/js/dir-picker.js). Ticking a directory includes its whole subtree; confirm resolves the topmost ticked handles. Scope follows where the classifier is served: /_apps/… → all accessible projects, under /… → that one project, file:// → a picked folder. The picker is handle-agnostic (HttpDirectoryHandle or native FS). - Rows: every ticked directory is walked recursively into the union of existing files (zddc.parseFilename) and MDL deliverables (mdl/*.yaml → inMdl + title), deduped to one row per tracking number. The "Archive revs" (all) column becomes a single informational "Latest rev" computed via zddc.compareRevisions. Drop still assigns the tracking number only; the Revision cell stays blank. - classify.js is unchanged — the mdl axis model and row shape are reused as-is (Latest rev derives from the preserved archiveRevisions). - Tests: the catalog test now asserts latest-rev; new unit tests cover walkDirInto union/dedupe-to-latest, _latestRevOf draft/modifier ordering, _detectScope routing, and the dir-picker topmost-ticked resolution. 61 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/build.sh | 1 + classifier/css/layout.css | 40 ++++---- classifier/js/dir-picker.js | 135 ++++++++++++++++++++++++++ classifier/js/target-tree.js | 183 ++++++++++++++++++----------------- classifier/template.html | 38 ++++---- tests/classify.spec.js | 96 ++++++++++++++++-- 6 files changed, 361 insertions(+), 132 deletions(-) create mode 100644 classifier/js/dir-picker.js diff --git a/classifier/build.sh b/classifier/build.sh index cfa7358..7087882 100755 --- a/classifier/build.sh +++ b/classifier/build.sh @@ -60,6 +60,7 @@ concat_files \ "js/validator.js" \ "js/scanner.js" \ "js/tree.js" \ + "js/dir-picker.js" \ "js/target-tree.js" \ "js/copy.js" \ "js/spreadsheet.js" \ diff --git a/classifier/css/layout.css b/classifier/css/layout.css index c3c83d9..77565a5 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -571,24 +571,16 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o cursor: wait; } -/* ── Catalog overlay (MDL ∪ archive; seltable rows = drop targets) ───────── */ -.target-pane { position: relative; } -.target-tabs__catalog { margin-left: 0.75rem; } -.catalog-overlay { - position: absolute; inset: 0; z-index: 20; - display: flex; flex-direction: column; min-height: 0; - background: var(--bg); border-left: 2px solid var(--primary); -} -.catalog-overlay[hidden] { display: none; } -.catalog-overlay__head { - display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; - padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); background: var(--bg-secondary, var(--bg)); -} -.catalog-overlay__title { font-weight: 700; font-size: 0.9rem; } -.catalog-overlay__close { margin-left: auto; background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; } -.catalog-overlay__close:hover { color: var(--text); } -.catalog-overlay__table { flex: 1; min-height: 0; } -.catalog-overlay__table .seltable { height: 100%; } +/* ── Target tabs: grouped (assign a tracking number) + separate (route) ───── */ +.pane-header--target { flex-wrap: wrap; } +.target-goal { flex: 1 0 100%; margin: 0 0 0.4rem; font-size: 0.78rem; color: var(--text-muted); line-height: 1.4; } +.target-goal strong { color: var(--text); } +.target-goal em { font-style: normal; font-weight: 600; color: var(--text); } +.target-tabs__group { display: flex; gap: 0.25rem; } +.target-tabs__divider { width: 1px; align-self: stretch; margin: 0.2rem 0.6rem 0; background: var(--border); } +/* The "By existing" catalog is now a normal in-flow tab panel. */ +#mdlTree { flex: 1; min-height: 0; } +#mdlTree .seltable { height: 100%; } .mdl-rev__input { width: 8rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem; @@ -654,6 +646,18 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.9rem; } .copy-choice__btns { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; } +.copy-choice--wide { max-width: 560px; } + +/* ── Directory picker (lazy multi-select tree inside the copy-choice modal) ─ */ +.dir-picker__tree { + max-height: 50vh; overflow: auto; margin: 0 0 1rem; + border: 1px solid var(--border); border-radius: var(--radius); padding: 0.4rem; +} +.dir-picker__row { display: flex; align-items: center; gap: 0.35rem; font-size: 0.85rem; padding: 0.05rem 0; } +.dir-picker__twisty { width: 1rem; text-align: center; cursor: pointer; color: var(--text-muted); user-select: none; } +.dir-picker__name { cursor: pointer; } +.dir-picker__children { margin-left: 1.1rem; } +.dir-picker__err { color: var(--danger); font-size: 0.78rem; margin-left: 1.1rem; } /* ── By-tracking merged-cell table ──────────────────────────────────────── */ #trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */ diff --git a/classifier/js/dir-picker.js b/classifier/js/dir-picker.js new file mode 100644 index 0000000..0788d42 --- /dev/null +++ b/classifier/js/dir-picker.js @@ -0,0 +1,135 @@ +/** + * ZDDC Classifier — lazy, multi-select directory picker (modal). + * + * Given one or more root directory handles, render an expandable checkbox tree + * and let the user TICK the directories whose files they want. Ticking a + * directory includes its whole subtree; a descendant under a ticked ancestor + * shows as checked+disabled (covered). Confirm resolves with the TOPMOST ticked + * handles only (a ticked child under a ticked parent is dropped — the parent's + * recursive walk covers it). Cancel/Esc/backdrop → []. + * + * Handle-agnostic: a "handle" is anything exposing async `values()` (yielding + * child handles {name, kind}) and `getDirectoryHandle(name)` — satisfied by both + * zddc-source.js HttpDirectoryHandle and native FileSystemDirectoryHandle. + * + * window.app.modules.dirPicker.pick(roots) → Promise + * roots: [ { label, handle } ] + */ +(function () { + 'use strict'; + if (!window.app) window.app = {}; + if (!window.app.modules) window.app.modules = {}; + + function elt(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + // Same skip set as the archive walk: dotfiles, system (_), and risk folders. + function hiddenName(nm) { return nm.charAt(0) === '.' || nm.charAt(0) === '_' || nm === 'rsk'; } + + function ancestorChecked(node) { + for (var p = node.parent; p; p = p.parent) { if (p.checked) return true; } + return false; + } + // Topmost ticked handles: a node whose own `checked` is set and which has no + // checked ancestor. Pure over { checked, handle, children } — also the test seam. + function collect(nodes, underChecked, out) { + (nodes || []).forEach(function (n) { + if (n.checked && !underChecked) out.push(n.handle); + collect(n.children, underChecked || !!n.checked, out); + }); + return out; + } + + function pick(roots) { + return new Promise(function (resolve) { + var done = false, rootNodes = []; + function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); } + function onKey(e) { if (e.key === 'Escape') finish([]); } + + var back = elt('div', 'copy-choice__backdrop'); + var box = elt('div', 'copy-choice copy-choice--wide'); + var h = elt('h3', null, 'Choose directories to scan'); + var p = elt('p', null, 'Tick the directories whose files you want in the catalog — subfolders are included. Expand with ▸.'); + var treeWrap = elt('div', 'dir-picker__tree'); + var btns = elt('div', 'copy-choice__btns'); + var go = elt('button', 'btn btn-primary', 'Scan'); go.disabled = true; + go.addEventListener('click', function () { finish(collect(rootNodes, false, [])); }); + var cancel = elt('button', 'btn btn-secondary', 'Cancel'); + cancel.addEventListener('click', function () { finish([]); }); + btns.appendChild(go); btns.appendChild(cancel); + box.appendChild(h); box.appendChild(p); box.appendChild(treeWrap); box.appendChild(btns); + back.appendChild(box); + back.addEventListener('click', function (e) { if (e.target === back) finish([]); }); + document.addEventListener('keydown', onKey); + document.body.appendChild(back); + + function refreshGo() { go.disabled = collect(rootNodes, false, []).length === 0; } + + // Recompute the displayed checkbox state of a subtree: a node under a + // checked ancestor is forced checked + disabled (inherited coverage). + function recompute(node, inherited) { + node.checkbox.disabled = inherited; + node.checkbox.checked = inherited || node.checked; + var below = inherited || node.checked; + node.children.forEach(function (c) { recompute(c, below); }); + } + + function makeNode(handle, label, parent, container) { + var node = { handle: handle, name: label, parent: parent, checked: false, expanded: false, loaded: false, children: [], childrenWrap: null, checkbox: null }; + var rowEl = elt('div', 'dir-picker__row'); + var twisty = elt('span', 'dir-picker__twisty', '▸'); + var cb = elt('input'); cb.type = 'checkbox'; + var nameEl = elt('span', 'dir-picker__name', label); + twisty.addEventListener('click', function () { toggle(node, twisty); }); + nameEl.addEventListener('click', function () { toggle(node, twisty); }); + cb.addEventListener('change', function () { + if (cb.disabled) return; + node.checked = cb.checked; + recompute(node, ancestorChecked(node)); + refreshGo(); + }); + rowEl.appendChild(twisty); rowEl.appendChild(cb); rowEl.appendChild(nameEl); + var kids = elt('div', 'dir-picker__children'); kids.hidden = true; + node.checkbox = cb; node.childrenWrap = kids; + container.appendChild(rowEl); container.appendChild(kids); + return node; + } + + async function toggle(node, twisty) { + node.expanded = !node.expanded; + node.childrenWrap.hidden = !node.expanded; + twisty.textContent = node.expanded ? '▾' : '▸'; + if (node.expanded && !node.loaded) { + node.loaded = true; + twisty.textContent = '…'; + try { + for await (var e of node.handle.values()) { + if (e.kind !== 'directory') continue; + var nm = String(e.name).replace(/\/$/, ''); + if (hiddenName(nm)) continue; + var childHandle = e.getDirectoryHandle ? e : await node.handle.getDirectoryHandle(nm); + var child = makeNode(childHandle, nm, node, node.childrenWrap); + node.children.push(child); + } + } catch (err) { + node.childrenWrap.appendChild(elt('div', 'dir-picker__err', 'Could not read — ' + (err.message || err))); + } + twisty.textContent = node.children.length ? (node.expanded ? '▾' : '▸') : '·'; + // A freshly-loaded subtree inherits an already-checked ancestor. + recompute(node, ancestorChecked(node)); + } + } + + (roots || []).forEach(function (r) { rootNodes.push(makeNode(r.handle, r.label, null, treeWrap)); }); + if (!rootNodes.length) finish([]); + }); + } + + window.app.modules.dirPicker = { + pick: pick, + _collect: function (nodes) { return collect(nodes, false, []); }, // test seam + }; +})(); diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index c5803ab..77da723 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -19,8 +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 tab - var catalogOpen = false; // the Catalog overlay (the 'mdl' axis) is open + var currentTab = 'tracking'; // 'tracking' | 'existing' | 'transmittal' — active tab var mdlTable = null; // the seltable controller for the catalog var mdlPlaced = {}; // latest placed.mdl map (read by the placed-file cell) @@ -29,8 +28,8 @@ initialized = true; els = { trackingTab: document.getElementById('trackingTab'), + existingTab: document.getElementById('existingTab'), transmittalTab: document.getElementById('transmittalTab'), - catalogBtn: document.getElementById('catalogBtn'), trackingPanel: document.getElementById('trackingPanel'), transmittalPanel: document.getElementById('transmittalPanel'), mdlPanel: document.getElementById('mdlPanel'), @@ -38,16 +37,14 @@ transmittalTree: document.getElementById('transmittalTree'), mdlTree: document.getElementById('mdlTree'), loadMdlBtn: document.getElementById('loadMdlBtn'), - catalogCloseBtn: document.getElementById('catalogCloseBtn'), addTrackingRootBtn: document.getElementById('addTrackingRootBtn'), addPartyBtn: document.getElementById('addPartyBtn'), stats: document.getElementById('classifyStats'), }; els.trackingTab.addEventListener('click', function () { showTab('tracking'); }); + if (els.existingTab) els.existingTab.addEventListener('click', function () { showTab('existing'); }); els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); }); - if (els.catalogBtn) els.catalogBtn.addEventListener('click', function () { catalogOpen ? closeCatalog() : openCatalog(); }); - if (els.catalogCloseBtn) els.catalogCloseBtn.addEventListener('click', closeCatalog); if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl); els.addTrackingRootBtn.addEventListener('click', function () { var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n' @@ -101,31 +98,21 @@ } function showTab(which) { - currentTab = which === 'transmittal' ? 'transmittal' : 'tracking'; + currentTab = (which === 'transmittal' || which === 'existing') ? which : 'tracking'; els.trackingTab.classList.toggle('active', currentTab === 'tracking'); + if (els.existingTab) els.existingTab.classList.toggle('active', currentTab === 'existing'); els.transmittalTab.classList.toggle('active', currentTab === 'transmittal'); els.trackingPanel.hidden = currentTab !== 'tracking'; + if (els.mdlPanel) els.mdlPanel.hidden = currentTab !== 'existing'; els.transmittalPanel.hidden = currentTab !== 'transmittal'; + render(); // The source-tree Show filters are 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(); + reRenderSource(); } - // The active axis is the catalog ('mdl') while the overlay is open, else the tab. - function activeAxis() { return catalogOpen ? 'mdl' : (currentTab === 'transmittal' ? 'transmittal' : 'tracking'); } + // The active axis is the catalog ('mdl') on the "By existing" tab, else the tab's. + function activeAxis() { return currentTab === 'existing' ? 'mdl' : (currentTab === 'transmittal' ? 'transmittal' : 'tracking'); } function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); } - function openCatalog() { - catalogOpen = true; - if (els.mdlPanel) els.mdlPanel.hidden = false; - if (els.catalogBtn) els.catalogBtn.classList.add('active'); - render(); - reRenderSource(); - } - function closeCatalog() { - catalogOpen = false; - if (els.mdlPanel) els.mdlPanel.hidden = true; - if (els.catalogBtn) els.catalogBtn.classList.remove('active'); - reRenderSource(); - } // Expand a brace pattern into folder names and create them (confirming a // multi-create first). parentId null = root folders. See expandFolderPattern. @@ -490,7 +477,7 @@ if (!C().getMdlList().length) { mdlTable = null; els.mdlTree.textContent = ''; - els.mdlTree.appendChild(el('div', 'target-empty', 'Nothing loaded yet — “Load…” to pull in the project’s MDL deliverables and archive tracking numbers.')); + els.mdlTree.appendChild(el('div', 'target-empty', 'Nothing loaded yet — “Load…”, tick the directories to scan, and their existing files + MDL deliverables appear here (one row per tracking number, latest revision).')); return; } ensureMdlTable(); @@ -507,7 +494,7 @@ }); cols.push({ key: 'title', title: 'Title', get: function (r) { return r.title || ''; } }); cols.push({ key: 'mdl', title: 'MDL', cls: 'catalog-mdl', get: function (r) { return r.inMdl ? '✓' : ''; } }); - cols.push({ key: 'arev', title: 'Archive revs', get: function (r) { return (r.archiveRevisions || []).join(', '); } }); + cols.push({ key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } }); cols.push({ key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; }, render: function (r, td) { @@ -558,81 +545,82 @@ }); } - // Load the catalog: the union of the project's MDL deliverables and its - // archive tracking numbers, deduped by tracking number. Server reads both; - // a local folder reads just its *.yaml deliverables. Writes/alters nothing — - // the revision cell is classifier-local and starts blank. + // Load the catalog: "Load…" opens a multi-select directory tree (scoped to + // the served context); every ticked directory is walked recursively into the + // union of existing files + MDL deliverables, deduped by tracking number to + // one row at the latest revision. Writes/alters nothing — the revision cell + // is classifier-local and starts blank. function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; } + + // The newest combined " ()" string in a set, by revision token. + function latestRevOf(revs) { + var best = null, bestTok = null; + (revs || []).forEach(function (r) { + var tok = String(r).replace(/\s*\([^)]*\)\s*$/, '').trim(); // "A (IFR)" → "A" + if (best == null || window.zddc.compareRevisions(tok, bestTok) > 0) { best = r; bestTok = tok; } + }); + return best || ''; + } + + // Where is the classifier served? Decides the directory-tree roots. + // 'local' → offline (file://), pick a folder. + // 'all' → standalone /_apps/classifier.html, root at every accessible project. + // {one:p} → under /…, root at just that project. + function detectScope(pathname, hasSource, protocol) { + if (!hasSource || protocol === 'file:') return 'local'; + if (/^\/_apps\//.test(pathname || '')) return 'all'; + var seg = (String(pathname || '').split('/').filter(Boolean)[0]) || ''; + return seg ? { one: seg } : 'all'; + } + async function buildRoots() { + var src = window.zddc && window.zddc.source; + var scope = detectScope(location.pathname, !!src, location.protocol); + if (scope === 'local') { + if (!window.showDirectoryPicker) { window.zddc.toast('Loading a local folder needs the File System Access API (Chromium).', 'error'); return null; } + try { var dir = await window.showDirectoryPicker({ mode: 'read' }); return [{ label: dir.name || 'Selected folder', handle: dir }]; } + catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open the folder — ' + (e.message || e), 'error'); return null; } + } + function archiveOf(rel) { + if (rel.charAt(rel.length - 1) !== '/') rel += '/'; + return new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive'); + } + if (scope === 'all') { + var projects = await window.app.modules.copy.fetchAccessProjects(); + if (projects == null) { window.zddc.toast('Could not load your projects from the server.', 'error'); return null; } + if (!projects.length) { window.zddc.toast('No projects you can access on this server.', 'warning'); return null; } + return projects.map(function (p) { return { label: (p.title ? p.name + ' — ' + p.title : p.name), handle: archiveOf(p.url || ('/' + p.name + '/')) }; }); + } + return [{ label: scope.one, handle: archiveOf('/' + scope.one + '/') }]; + } async function loadMdl() { - if (window.zddc && window.zddc.source && location.protocol !== 'file:') return loadMdlServer(); - return loadMdlLocal(); - } - async function loadMdlLocal() { - if (!window.showDirectoryPicker) { window.zddc.toast('Loading a local MDL needs the File System Access API (Chromium).', 'error'); return; } - var dir; - try { dir = await window.showDirectoryPicker({ mode: 'read' }); } - catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open the folder — ' + (e.message || e), 'error'); return; } - var rows = []; - try { - for await (var entry of dir.values()) { - var nm = String(entry.name).replace(/\/$/, ''); - if (entry.kind !== 'file' || !isRowYaml(nm)) continue; - var obj = null; try { obj = window.jsyaml.load(await (await entry.getFile()).text()); } catch (_) { /* skip */ } - var stem = nm.replace(/\.yaml$/i, ''); - rows.push({ id: stem, party: dir.name || 'local', trackingNumber: stem, title: (obj && obj.title) || '', inMdl: true, archiveRevisions: [], revisionCell: '' }); - } - } catch (e) { window.zddc.toast('Reading the MDL folder failed — ' + (e.message || e), 'error'); return; } - finishLoad(rows); - } - async function loadMdlServer() { - var copy = window.app.modules.copy, src = window.zddc.source; - var projects = await copy.fetchAccessProjects(); - if (projects == null) { window.zddc.toast('Could not load your projects from the server.', 'error'); return; } - if (!projects.length) { window.zddc.toast('No projects you can access on this server.', 'warning'); return; } - var proj = await copy.chooseProject(projects); - if (!proj) return; - var rel = (proj.url || ('/' + proj.name + '/')); if (rel.charAt(rel.length - 1) !== '/') rel += '/'; - var archive = new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive'); + var roots = await buildRoots(); + if (!roots) return; + var picked = await window.app.modules.dirPicker.pick(roots); + if (!picked || !picked.length) return; var byTn = Object.create(null); function ensure(tn) { return byTn[tn] || (byTn[tn] = { tracking: tn, title: '', inMdl: false, party: '', revs: Object.create(null) }); } - window.zddc.toast('Scanning the project MDL + archive…', 'info', { durationMs: 4000 }); - try { - for await (var party of archive.values()) { - if (party.kind !== 'directory') continue; - var pn = String(party.name).replace(/\/$/, ''); - if (pn.charAt(0) === '.' || pn.charAt(0) === '_') continue; - for await (var slot of party.values()) { - if (slot.kind !== 'directory') continue; - var sn = String(slot.name).replace(/\/$/, ''); - if (sn.charAt(0) === '.' || sn.charAt(0) === '_' || sn === 'rsk') continue; - if (sn === 'mdl') { // registered deliverables - for await (var ye of slot.values()) { - var ynm = String(ye.name).replace(/\/$/, ''); - if (ye.kind !== 'file' || !isRowYaml(ynm)) continue; - var obj = null; try { obj = window.jsyaml.load(await (await ye.getFile()).text()); } catch (_) { /* skip */ } - var row = ensure(ynm.replace(/\.yaml$/i, '')); - row.inMdl = true; if (!row.title && obj && obj.title) row.title = obj.title; if (!row.party) row.party = pn; - } - } else { // archive documents → tracking + revisions - await walkArchiveInto(slot, ensure, pn); - } - } - } - } catch (e) { window.zddc.toast('Reading the project failed — ' + (e.message || e), 'error'); return; } + window.zddc.toast('Scanning selected directories…', 'info', { durationMs: 4000 }); + try { for (var i = 0; i < picked.length; i++) await walkDirInto(picked[i], ensure); } + catch (e) { window.zddc.toast('Reading the directories failed — ' + (e.message || e), 'error'); return; } var rows = Object.keys(byTn).map(function (tn) { var x = byTn[tn]; return { id: tn, party: x.party, trackingNumber: tn, title: x.title, inMdl: x.inMdl, archiveRevisions: Object.keys(x.revs).sort(), revisionCell: '' }; }); finishLoad(rows); } - // Recursively collect ZDDC-named archive files under a slot → tracking + - // the set of revisions seen for each. - async function walkArchiveInto(dirH, ensure, party) { + // Walk a ticked directory recursively. A dir named "mdl" (or the ticked dir + // itself being an mdl folder) yields *.yaml deliverables → inMdl + title; + // every other ZDDC-named file is an archive revision of its tracking number. + async function walkDirInto(dirH, ensure) { + var party = (dirH.name && String(dirH.name).replace(/\/$/, '')) || ''; + if (party === 'mdl') return readMdlYamls(dirH, ensure); for await (var entry of dirH.values()) { var nm = String(entry.name).replace(/\/$/, ''); if (entry.kind === 'directory') { - if (nm.charAt(0) === '.' || nm.charAt(0) === '_') continue; - await walkArchiveInto(await dirH.getDirectoryHandle(nm), ensure, party); + if (nm.charAt(0) === '.' || nm.charAt(0) === '_' || nm === 'rsk') continue; + var child = entry.getDirectoryHandle ? entry : await dirH.getDirectoryHandle(nm); + if (nm === 'mdl') await readMdlYamls(child, ensure); + else await walkDirInto(child, ensure); } else { var p = window.zddc.parseFilename(nm); if (p && p.valid && p.trackingNumber) { @@ -644,12 +632,21 @@ } } } + async function readMdlYamls(mdlH, ensure) { + for await (var ye of mdlH.values()) { + var ynm = String(ye.name).replace(/\/$/, ''); + if (ye.kind !== 'file' || !isRowYaml(ynm)) continue; + var obj = null; try { obj = window.jsyaml.load(await (await ye.getFile()).text()); } catch (_) { /* skip */ } + var row = ensure(ynm.replace(/\.yaml$/i, '')); + row.inMdl = true; if (!row.title && obj && obj.title) row.title = obj.title; + } + } function finishLoad(rows) { C().setMdlList(rows); - openCatalog(); + showTab('existing'); window.zddc.toast(rows.length - ? ('Catalog: ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' (MDL ∪ archive). Filter, drag files on, set revisions.') - : 'Nothing found in the MDL or archive.', rows.length ? 'success' : 'warning'); + ? ('Catalog: ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.') + : 'No files or deliverables in the selected directories.', rows.length ? 'success' : 'warning'); } // ── events ───────────────────────────────────────────────────────────── @@ -854,7 +851,7 @@ var a = C().getAssignment(key); if (!a) return; if (a.mdlNodeId) { - openCatalog(); + showTab('existing'); if (mdlTable) { mdlTable.renderBody(); } } else if (a.trackingNodeId) { showTab('tracking'); collapsed = {}; render(); @@ -880,5 +877,9 @@ activeAxis: activeAxis, setNameFilter: setNameFilter, reveal: reveal, + // test seams (pure) + _detectScope: detectScope, + _latestRevOf: latestRevOf, + _walkDirInto: walkDirInto, }; })(); diff --git a/classifier/template.html b/classifier/template.html index 76131e3..0e50bc3 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -158,11 +158,17 @@
-
+
+

Each file needs a tracking number (revision + status + title) and a transmittal folder. Name it — build one under By tracking number or reuse one under By existing — then route it under By transmittal.

- - - +
+ + +
+ +
+ +
@@ -194,20 +200,18 @@ placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
+ +
- -
diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 08d237d..4ff80ad 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -1249,7 +1249,7 @@ test('classify: an MDL placement names a file; revision from the cell, transmitt expect(r.fileTitleName).toContain('messy scan 47'); // title toggle → the file's own title }); -test('Catalog: shows archive revs, drop on a row names the file, bulk revision applies', async ({ page }) => { +test('By existing: shows latest rev, drop on a row names the file, bulk revision applies', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; @@ -1257,22 +1257,106 @@ test('Catalog: shows archive revs, drop on a row names the file, bulk revision a const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' }; window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }]; const key = c.srcKeyForFile(f); - // Catalog rows = MDL ∪ archive merged: one MDL+archive row, one archive-only. + // Catalog rows = files ∪ MDL deliverables, deduped per tracking number. c.setMdlList([ { id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'] }, { id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', inMdl: false, archiveRevisions: ['0 (IFC)'] }, ]); - tt.render(); // builds the catalog seltable into #mdlTree + tt.showTab('existing'); // shows the catalog panel + builds the seltable into #mdlTree const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]'); - const archiveRevsShown = !!row && row.textContent.includes('A (IFR)') && row.textContent.includes('B (IFC)'); + // Latest rev only: B (IFC) > A (IFR), so the cell shows B (IFC), not A (IFR). + const latestShown = !!row && row.textContent.includes('B (IFC)') && !row.textContent.includes('A (IFR)'); window.app.modules.dnd.setDrag([key]); row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop the file on m1 const placed = (c.getAssignment(key) || {}).mdlNodeId; c.setRevisionCells(['m1', 'm2'], 'A (IFR)'); // ctrl-Enter bulk path - return { hasRow: !!row, archiveRevsShown, placed, named: c.deriveTarget(f).filename }; + return { hasRow: !!row, latestShown, placed, named: c.deriveTarget(f).filename }; }); expect(r.hasRow).toBe(true); - expect(r.archiveRevsShown).toBe(true); // merged archive revisions shown (informational) + expect(r.latestShown).toBe(true); // only the latest archive revision shown expect(r.placed).toBe('m1'); // drop = tracking number only expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // revision from the bulk-set column feeds the name }); + +test('By existing: walkDirInto unions files + mdl deliverables, deduped to the latest revision', async ({ page }) => { + await page.click('#modeClassifyBtn'); + const r = await page.evaluate(async () => { + const tt = window.app.modules.targetTree; + function fdir(name, children) { + return { + name, kind: 'directory', + async *values() { for (const ch of children) yield ch; }, + async getDirectoryHandle(n) { const c = children.find(x => x.name === n && x.kind === 'directory'); if (!c) throw new Error('nf'); return c; }, + }; + } + function ffile(name, text) { return { name, kind: 'file', async getFile() { return { text: async () => text }; } }; } + // archive/PartyA/{mdl/.yaml, issued/T1/}, archive/PartyB/issued/T2/<~A draft> + const root = fdir('archive', [ + fdir('PartyA', [ + fdir('mdl', [ffile('ACM-PRJ-EL-SPC-0001.yaml', 'title: Switchgear Spec\n')]), + fdir('issued', [fdir('T1', [ + ffile('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf', ''), + ffile('ACM-PRJ-EL-SPC-0001_B (IFC) - Spec.pdf', ''), + ffile('notes.txt', ''), // non-ZDDC → ignored + ])]), + ]), + fdir('PartyB', [fdir('issued', [fdir('T2', [ + ffile('ACM-PRJ-EL-SPC-0001_~A (IFA) - Draft.pdf', ''), // older draft, other party + ])])]), + fdir('_system', [fdir('issued', [ffile('ACM-PRJ-EL-SPC-9999_A (IFA) - hidden.pdf', '')])]), // skipped + ]); + const byTn = Object.create(null); + const ensure = (tn) => byTn[tn] || (byTn[tn] = { tracking: tn, title: '', inMdl: false, party: '', revs: Object.create(null) }); + await tt._walkDirInto(root, ensure); + const keys = Object.keys(byTn); + const x = byTn['ACM-PRJ-EL-SPC-0001']; + return { + trackingNumbers: keys, + inMdl: !!(x && x.inMdl), + title: x && x.title, + revs: x && Object.keys(x.revs).sort(), + latest: tt._latestRevOf(x && Object.keys(x.revs)), + latestDraftLoses: tt._latestRevOf(['~A (IFR)', 'A (IFC)']), + latestModifierWins: tt._latestRevOf(['A (IFR)', 'A+B1 (IFC)']), + }; + }); + expect(r.trackingNumbers).toEqual(['ACM-PRJ-EL-SPC-0001']); // one row; _system skipped, .txt ignored + expect(r.inMdl).toBe(true); // the mdl/*.yaml registered it + expect(r.title).toBe('Switchgear Spec'); // title from the deliverable yaml + expect(r.revs).toEqual(['A (IFR)', 'B (IFC)', '~A (IFA)']); // revisions unioned across parties + expect(r.latest).toBe('B (IFC)'); // B > A > ~A + expect(r.latestDraftLoses).toBe('A (IFC)'); // ~A < A + expect(r.latestModifierWins).toBe('A+B1 (IFC)'); // A < A+B1 +}); + +test('By existing: _detectScope routes by URL/protocol', async ({ page }) => { + const r = await page.evaluate(() => { + const tt = window.app.modules.targetTree; + return { + apps: tt._detectScope('/_apps/classifier.html', true, 'https:'), + project: tt._detectScope('/Project-1/archive/PartyA/incoming/', true, 'https:'), + offline: tt._detectScope('/anything', false, 'file:'), + offlineHttp: tt._detectScope('/x', true, 'file:'), + }; + }); + expect(r.apps).toBe('all'); + expect(r.project).toEqual({ one: 'Project-1' }); + expect(r.offline).toBe('local'); + expect(r.offlineHttp).toBe('local'); +}); + +test('By existing: dir-picker resolves the topmost ticked directories only', async ({ page }) => { + const r = await page.evaluate(() => { + const dp = window.app.modules.dirPicker; + const childOfA = { handle: 'A/x', checked: true, children: [] }; + const A = { handle: 'A', checked: true, children: [childOfA] }; + const grand = { handle: 'B/y/z', checked: false, children: [] }; + const childOfB = { handle: 'B/y', checked: true, children: [grand] }; + const B = { handle: 'B', checked: false, children: [childOfB] }; + const unchecked = { handle: 'C', checked: false, children: [] }; + return dp._collect([A, B, unchecked]); + }); + // A is ticked (its ticked child A/x is dropped — covered by A); B itself isn't + // ticked but its child B/y is, so B/y is included; C contributes nothing. + expect(r).toEqual(['A', 'B/y']); +});