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>
135 lines
6.9 KiB
JavaScript
135 lines
6.9 KiB
JavaScript
/**
|
|
* 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
|
|
};
|
|
})();
|