feat(classifier): "By existing" tab + multi-select directory picker

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 <project>/… → 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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-11 19:39:55 -05:00
parent 6c3c58bc70
commit 93ed0d361f
6 changed files with 361 additions and 132 deletions

View file

@ -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" \

View file

@ -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 */

135
classifier/js/dir-picker.js Normal file
View file

@ -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<handle[]>
* 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
};
})();

View file

@ -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 projects 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 "<rev> (<status>)" 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 <project>/…, 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,
};
})();

View file

@ -158,11 +158,17 @@
<!-- Target Trees (Classify & Copy mode) — default view -->
<main class="target-pane" id="targetPane">
<div class="pane-header">
<div class="pane-header pane-header--target">
<p class="target-goal">Each file needs a <strong>tracking number</strong> (revision + status + title) and a <strong>transmittal folder</strong>. Name it — build one under <em>By tracking number</em> or reuse one under <em>By existing</em> — then route it under <em>By transmittal</em>.</p>
<div class="target-tabs" role="tablist">
<div class="target-tabs__group">
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
<button class="target-tab" id="existingTab" role="tab" title="Reuse a tracking number already in the project MDL or archive — drag files onto a row to assign it.">By existing</button>
</div>
<span class="target-tabs__divider" aria-hidden="true"></span>
<div class="target-tabs__group">
<button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
<button id="catalogBtn" class="btn btn-secondary btn-sm target-tabs__catalog" title="Open the catalog — everything the project knows: MDL deliverables archive tracking numbers. Drag files onto a row to name them.">⊞ Catalog</button>
</div>
</div>
<div class="pane-header-right">
<span id="classifyStats" class="file-stats"></span>
@ -194,20 +200,18 @@
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
<div id="transmittalTree" class="target-tree"></div>
</section>
</div>
<!-- Catalog overlay (everything the project knows): opened by the
Catalog button. Covers the target pane; the left filetree stays
the drag source. Read-only on the MDL/archive; the Revision column
is classifier-local. -->
<section id="mdlPanel" class="catalog-overlay" hidden>
<div class="catalog-overlay__head">
<span class="catalog-overlay__title">Catalog — MDL archive</span>
<!-- "By existing": the catalog — files + MDL deliverables from the
directories you tick, deduped to one row per tracking number at
the latest revision. Read-only on the MDL/archive; the Revision
column is classifier-local. The left filetree stays the drag source. -->
<section id="mdlPanel" class="target-panel" hidden>
<div class="target-panel__toolbar">
<button id="loadMdlBtn" class="btn btn-sm btn-secondary">⊞ Load…</button>
<span class="target-hint">Drag files onto a row to assign its tracking number; set the Revision column (ctrl-shift select + ctrl-Enter to set many at once).</span>
<button id="catalogCloseBtn" class="catalog-overlay__close" title="Close">&times;</button>
<span class="target-hint">“Load…”, tick the directories to scan, then drag files onto a row to assign its tracking number; set the Revision column (ctrl-shift select + ctrl-Enter to set many).</span>
</div>
<div id="mdlTree" class="catalog-overlay__table"></div>
<div id="mdlTree" class="target-tree"></div>
</section>
</div>
</main>
</div>

View file

@ -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/<tn>.yaml, issued/T1/<A,B revs>}, 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']);
});