/** * 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); var pressedBackdrop = false; back.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === back); }); back.addEventListener('click', function (e) { if (e.target === back && pressedBackdrop) 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 }; })();