Compare commits

..

No commits in common. "main" and "form-v0.0.26" have entirely different histories.

45 changed files with 1293 additions and 4890 deletions

View file

@ -227,20 +227,6 @@ Format: `trackingNumber_revision (status) - title.extension`
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
- Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
### Pre-push PII guard (run before EVERY push)
`main` was rewritten once to scrub a leaked work email (history reset to a single clean commit; all old tags deleted; versioning rebootstrapped at `v0.0.26`). A leaked address persists in **history + tags**, not just files — so it must never re-enter. Before any push:
```sh
# Flags any email NOT a known synthetic placeholder or the maintainer contact.
git grep -InE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' \
| grep -viE '@example\.(com|org|io|net)|caseywitt@proton\.me|@(my|your)company\.com|@(partner|acme|beta|vendor|evil|x|company|host|admin|anywhere|other)\.(com|org)|@regulator\.gov|@(zddc\.)?varasys\.io|@bitnest\.cc|@proton\.me|@nhn\.com|@ex\.io'
```
- **Empty output = clean.** Any line is a STOP: confirm it's a synthetic placeholder; if it's a real personal/work address, replace it with an `@example.com` placeholder before pushing (and extend the allowlist above only for genuinely-synthetic example domains).
- **Conventions** (the scrub genericized everything): no real personal/work emails — use `@example.com`. The only real address allowed anywhere is the maintainer contact **`caseywitt@proton.me`** (`SECURITY.md` + as the git commit author). Generic personas only — `admin` / `alice` / `sam`; party name **Acme**.
- **Never** push a branch still carrying pre-scrub history, and **never** push stale local tags (the old 165 are gone; `zddc-server-vX.Y.Z` triggers the release+deploy pipeline).
### Releasing — lockstep stable + beta snapshot
**Lockstep convention.** Every stable cut bumps all 8 artifacts (7 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all 8 tools) + 1``_coordinated_next_stable` in `shared/build-lib.sh`.

View file

@ -7,7 +7,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Commit freely** — make commits as appropriate for the work being performed. Each commit should be a coherent, reviewable unit (no WIP/checkpoint noise). The default rule "never commit without explicit ask" does NOT apply in this repo.
- **Push only when explicitly told**`git push` requires a fresh request from the user every time. Approval to commit does not carry forward to push, and approval to push once does not carry forward to a later push.
- **No squashing on push** — keep granular history. Each commit should already be meaningful (per the rule above), so squashing erases useful detail rather than removing noise. Multi-commit branches with a clean history are preferred over force-pushed squash-merges.
- **Pre-push PII guard — run before EVERY push.** `main` history was rewritten once to scrub a leaked work email; a leak persists in history + tags, not just files. Before any push, run the guard in AGENTS.md ("Pre-push PII guard"). No real personal/work emails: use `@example.com` in examples; the only real address allowed is the maintainer contact `caseywitt@proton.me` (SECURITY.md + as commit author). Generic personas only (admin / alice / sam); party name **Acme**. Never push stale local tags, and never push a branch still carrying pre-scrub history.
## Authoritative docs — read these first

View file

@ -173,12 +173,8 @@
box.querySelector('#acc-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
// Close on a genuine backdrop click only — not when a drag that began
// inside the dialog (selecting text in an input) ends out here.
var pressedBackdrop = false;
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
overlay.addEventListener('click', function (e) {
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
document.addEventListener('keydown', onKeydown);

View file

@ -82,12 +82,8 @@
box.querySelector('#ct-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
// Close on a genuine backdrop click only — not when a drag that began
// inside the dialog (selecting text in an input) ends out here.
var pressedBackdrop = false;
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
overlay.addEventListener('click', function (e) {
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
document.addEventListener('keydown', onKeydown);
submit.addEventListener('click', function () {

View file

@ -792,13 +792,6 @@
function openPartyPicker(opts) {
return new Promise(function (resolve) {
var kindWord = opts.kind === 'folder' ? 'folder' : 'file';
// The "+ New party" affordance is gated on create authority over ssr/
// (pre-checked in createInAggregator). When denied, disable it and say
// who can — role-first text inline, the specific people in the tooltip.
var newPartyAllowed = opts.canNewParty !== false;
var newPartyNote = newPartyAllowed ? '(registers a new party)'
: (opts.newPartyHint && opts.newPartyHint.text) || 'You cant register a new party here.';
var newPartyTitle = (!newPartyAllowed && opts.newPartyHint && opts.newPartyHint.title) || '';
var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
@ -817,10 +810,10 @@
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/&lt;party&gt;/</code>.' +
'</p>' +
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
(partyList || '<em style="color:#888;">No parties yet.</em>') +
'<label title="' + escapeHtml(newPartyTitle) + '" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;' + (newPartyAllowed ? 'cursor:pointer;' : 'opacity:0.6;') + '">' +
'<input type="radio" name="pp-party" value="__new__"' + (newPartyAllowed ? '' : ' disabled') + '>' +
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</span></span></label>' +
(partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
'<input type="radio" name="pp-party" value="__new__">' +
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' +
'</div>' +
'<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' +
'<label for="pp-newparty">New party name</label><br>' +
@ -848,11 +841,7 @@
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
function cancel() { close(); resolve(null); }
box.querySelector('#pp-cancel').addEventListener('click', cancel);
// Close on a genuine backdrop click only — NOT when a drag that began
// inside the dialog (e.g. selecting text in an input) ends out here.
var pressedBackdrop = false;
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
overlay.addEventListener('click', function (e) { if (e.target === overlay && pressedBackdrop) cancel(); });
overlay.addEventListener('click', function (e) { if (e.target === overlay) cancel(); });
box.querySelector('#pp-submit').addEventListener('click', function () {
var sel = box.querySelector('input[name="pp-party"]:checked');
if (!sel) { statusError('Pick a party.'); return; }
@ -881,28 +870,8 @@
async function createInAggregator(agg, kind) {
var up = window.app.modules.upload;
if (!up) return;
var cap = window.zddc && window.zddc.cap;
var ssrPath = '/' + agg.project + '/ssr/';
var slotPath = '/' + agg.project + '/' + agg.slot + '/';
// Pre-check create authority BEFORE any data entry: registering a new
// party needs `c` on ssr/, creating under an existing party needs `c` on
// the slot. If the user can do neither, don't open the form just to deny
// them — say (subtly) who can. If they can only use existing parties,
// open the picker with the "+ New party" option disabled + explained.
var canNewParty = true, ssrView = null, slotView = null;
if (cap && cap.at) {
slotView = await cap.at(slotPath);
ssrView = await cap.at(ssrPath);
var canSlot = !slotView || cap.has({ verbs: slotView.path_verbs }, 'c');
canNewParty = !ssrView || cap.has({ verbs: ssrView.path_verbs }, 'c');
if (slotView && !canSlot && !canNewParty) {
statusError(cap.denyHint(slotView, 'c').text);
return;
}
}
var parties = await fetchParties(agg.project);
var newPartyHint = (!canNewParty && ssrView && cap.denyHint) ? cap.denyHint(ssrView, 'c') : null;
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties, canNewParty: canNewParty, newPartyHint: newPartyHint });
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties });
if (!choice) return;
// Party names are validated to a URL-safe charset, so no encoding
// needed for the party segment; makeDir/makeFile encode the leaf.
@ -925,10 +894,7 @@
} catch (e) {
var msg = (e && e.message) || String(e);
if (/\b403\b/.test(msg)) {
// Name who can — best-effort, for the path the denial came from.
var denied = choice.isNew ? ssrPath : ('/' + agg.project + '/' + agg.slot + '/' + choice.party + '/');
var v = (cap && cap.at) ? await cap.at(denied) : null;
statusError(v && cap.denyHint ? cap.denyHint(v, 'c').text : 'Not allowed — you dont have create access here.');
statusError('Not allowed — registering a new party requires the document-controller role.');
} else if (/\b409\b/.test(msg)) {
statusError('Unknown party — register it first (document controller).');
} else {

View file

@ -195,10 +195,8 @@
box.querySelector('#stage-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
var pressedBackdrop = false;
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
overlay.addEventListener('click', function (e) {
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
box.querySelector('#stage-submit').addEventListener('click', function () {
var sel = box.querySelector('input[name="stage-target"]:checked');
@ -248,10 +246,8 @@
box.querySelector('#unstage-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
var pressedBackdrop = false;
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
overlay.addEventListener('click', function (e) {
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
box.querySelector('#unstage-submit').addEventListener('click', function () {
var target = input.value.trim();

View file

@ -26,7 +26,6 @@ concat_files \
"../shared/profile-menu.css" \
"../shared/logo.css" \
"css/base.css" \
"../shared/seltable.css" \
"css/layout.css" \
"css/spreadsheet.css" \
> "$css_temp"
@ -57,13 +56,13 @@ concat_files \
"js/classify.js" \
"js/workspace.js" \
"js/dnd.js" \
"../shared/seltable.js" \
"js/seltable.js" \
"js/validator.js" \
"js/scanner.js" \
"js/tree.js" \
"js/dir-picker.js" \
"js/target-tree.js" \
"js/copy.js" \
"js/mdl-instantiate.js" \
"js/spreadsheet.js" \
"js/selection.js" \
"js/preview.js" \

View file

@ -184,7 +184,7 @@
/* Folder Item */
.folder-item {
display: flex;
align-items: flex-start; /* toggle/icon sit on the name line; count drops below */
align-items: center;
padding: 0.5rem;
cursor: pointer;
border-radius: var(--radius);
@ -269,15 +269,8 @@
color: var(--text-muted);
}
/* Name + count stacked vertically (count below the name, not right-aligned). */
.folder-namebox {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.folder-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -286,6 +279,7 @@
.folder-count {
font-size: 11px;
color: var(--text-muted);
margin-left: 0.5rem;
}
.folder-children {
@ -577,70 +571,51 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
cursor: wait;
}
/* ── 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. */
#worklistTable { flex: 1; min-height: 0; }
#worklistTable .seltable { height: 100%; }
/* Editable "From a list" cells fill the column (the table is width:auto, so
the column sizes to its header/content, and the input never widens it). */
.worklist-rev__input, .worklist-tn__input, .worklist-title__input {
width: 100%; min-width: 4rem; box-sizing: border-box;
padding: 0.15rem 0.35rem; border: 1px solid var(--border);
/* ── By-MDL panel (seltable rows = deliverable drop targets) ─────────────── */
#mdlPanel .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;
}
.worklist-tn__input { font-family: var(--mono, monospace); }
.worklist-rev__input.is-warn, .worklist-tn__input.is-warn { border-color: var(--warning, #b8860b); }
.worklist-src { white-space: nowrap; }
.src-badge {
display: inline-block; margin-right: 0.25rem; padding: 0 0.3rem; border-radius: 0.7rem;
font-size: 0.64rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em;
border: 1px solid var(--border); color: var(--text-muted);
}
.src-badge--mdl { color: var(--primary); border-color: var(--primary); }
.src-badge--arch { color: var(--text-secondary, var(--text-muted)); }
.src-badge--pasted { color: var(--text-muted); }
.src-badge--new { color: #fff; background: var(--warning, #b8860b); border-color: var(--warning, #b8860b); }
.target-toggle { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); cursor: pointer; }
.seltable__extra { white-space: normal; }
.mdlfile__name { font-size: 0.78rem; }
#worklistPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
#worklistPanel .tfile--err .mdlfile__name { color: var(--danger); }
#worklistPanel .tfile__remove { opacity: 0.6; }
#worklistPanel .tfile:hover .tfile__remove { opacity: 1; }
#mdlPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
#mdlPanel .tfile--err .mdlfile__name { color: var(--danger); }
#mdlPanel .tfile__remove { opacity: 0.6; }
#mdlPanel .tfile:hover .tfile__remove { opacity: 1; }
/* Paste + Match dialogs (inside the .copy-choice modal shell) */
.scratch-modal__body { margin: 0 0 1rem; }
.scratch-paste__ta {
width: 100%; box-sizing: border-box; resize: vertical; font-family: var(--mono, monospace);
font-size: 0.8rem; padding: 0.4rem 0.5rem; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text);
/* ── MDL-from-archive overlay ───────────────────────────────────────────── */
.mdl-overlay { position: fixed; inset: 0; z-index: 1100; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
.mdl-overlay__box { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 10px 40px rgba(0,0,0,0.3); width: 100%; max-width: 1000px; height: 80vh; display: flex; flex-direction: column; }
.mdl-overlay__head { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); }
.mdl-overlay__head h2 { margin: 0; font-size: 1.1rem; }
.mdl-overlay__close { background: none; border: none; font-size: 1.6rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; }
.mdl-overlay__close:hover { color: var(--text); }
.mdl-overlay__status { padding: 0.4rem 1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); }
.mdl-overlay__table { flex: 1; min-height: 0; }
.mdl-overlay__foot { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
/* ── Shared selectable + autofilter table (seltable) ────────────────────── */
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.seltable__filter {
flex: 1; min-width: 8rem; padding: 0.3rem 0.5rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.85rem;
}
.scratch-paste__preview, .scratch-match__list { max-height: 38vh; overflow: auto; margin-top: 0.6rem; }
.scratch-preview__table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
.scratch-preview__table th, .scratch-preview__table td { text-align: left; padding: 0.15rem 0.4rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
.scratch-preview__table th { color: var(--text-muted); font-size: 0.66rem; text-transform: uppercase; }
.scratch-preview__skip { color: var(--danger); font-size: 0.76rem; padding: 0.1rem 0; }
.scratch-preview__more { color: var(--text-muted); font-size: 0.76rem; padding: 0.2rem 0; }
.scratch-match__fuzzy { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); }
.scratch-match__row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; padding: 0.15rem 0; cursor: pointer; }
.scratch-match__row--review { opacity: 0.85; } /* not an exact 1:1 — needs a look */
.scratch-match__row--review .scratch-match__conf { color: var(--warning, #b8860b); }
.scratch-match__file { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scratch-match__arrow { color: var(--text-muted); }
.scratch-match__tn { font-family: var(--mono, monospace); }
.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; min-width: 6rem; text-align: right; white-space: nowrap; }
.worklist-cur { font-family: var(--mono, monospace); color: var(--text-muted); }
/* The base seltable rules live in shared/seltable.css (bundled by build.sh and
shared with the tables tool); only the classifier-specific catalog bits
(.seltable__extra, .worklist-rev__input, .worklist-*, .src-badge, #worklistTable) are
here. */
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
.seltable__table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
.seltable__table thead th {
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
.seltable__row { cursor: pointer; user-select: none; }
.seltable__row:hover { background: var(--bg-hover); }
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
/* ── Copy destination dialog ────────────────────────────────────────────── */
.copy-choice__backdrop {
@ -663,18 +638,6 @@ 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 */
@ -707,15 +670,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.trev__inner .tcell__name { color: var(--primary); }
.tcell__preview { text-decoration: none; cursor: pointer; }
.tcell__preview:hover { text-decoration: underline; }
/* The hover-only node controls must NOT reserve column width (they're invisible
normally). Float them over the right of the cell instead of leaving them in
flow .tcell__inner is sticky, so it's the positioning context so each
column sizes to its value alone. */
.ttable .tnode__actions {
position: absolute; right: 0.25rem; top: 50%; transform: translateY(-50%);
margin-left: 0; padding-left: 0.25rem; background: var(--bg); pointer-events: none;
}
.ttable__cell:hover .tnode__actions, .ttable__rev:hover .tnode__actions { opacity: 1; pointer-events: auto; }
.ttable__cell:hover .tnode__actions, .ttable__rev:hover .tnode__actions { opacity: 1; }
.ttable .drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
.ttable__file { padding: 0.1rem 0.4rem; }
.ttable__drop { color: var(--text-muted); font-style: italic; font-size: 0.75rem; }
@ -731,33 +686,3 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.tfile__badge { font-size: 0.78rem; flex: 0 0 auto; }
.tfile__badge--ok { color: var(--success, #16a34a); }
.tfile__badge--err { color: var(--danger); }
/* ── By-tracking flat editable grid (one row per file) ──────────────────── */
.ttable--grid { width: auto; }
.ttable--grid td.tg-td { padding: 0.1rem 0.35rem; vertical-align: middle; }
.ttable--grid th.tg-th { white-space: nowrap; } /* .column-resizer (spreadsheet.css) sits in the sticky th */
.tg-input {
width: 100%; min-width: 4rem; box-sizing: border-box;
padding: 0.12rem 0.3rem; border: 1px solid transparent; border-radius: var(--radius);
background: transparent; color: var(--text); font: inherit; font-size: 0.8rem;
}
.tg-input:hover { border-color: var(--border); }
.tg-input:focus { border-color: var(--primary); background: var(--bg); outline: none; }
.tg-tn .tg-input { font-family: var(--mono, monospace); }
.tg-input.is-warn { border-color: var(--warning, #b8860b); }
.tg-orig__link { color: var(--text-muted); white-space: nowrap; text-decoration: none; cursor: pointer; }
.tg-orig__link:hover { text-decoration: underline; }
.tg-status, .tg-x { text-align: center; }
.tg-x__btn { opacity: 0.5; }
.tg-row:hover .tg-x__btn { opacity: 1; }
.tg-row--err .tg-status { color: var(--danger); }
.tg-drop-hover { outline: 2px dashed var(--primary); outline-offset: -3px; background: var(--primary-light); }
/* "Columns ▾" chooser menu */
.col-chooser {
position: fixed; z-index: 9600; background: var(--bg);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 6px 18px rgba(0,0,0,0.18); padding: 0.3rem; min-width: 11rem;
}
.col-chooser__item { display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.4rem; font-size: 0.83rem; cursor: pointer; border-radius: var(--radius); }
.col-chooser__item:hover { background: var(--bg-hover); }

View file

@ -374,6 +374,8 @@
if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); });
if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); });
if (app.dom.checkDuplicatesBtn) app.dom.checkDuplicatesBtn.addEventListener('click', function () { app.modules.copy.audit(); });
var mdlBtn = document.getElementById('mdlInstantiateBtn');
if (mdlBtn) mdlBtn.addEventListener('click', function () { app.modules.mdlInstantiate.open(); });
// Live source-tree filter (matches file path + name; reveals the hierarchy).
if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () {

View file

@ -33,9 +33,7 @@
// table columns + (later) revision-modifier menus. Editable by the user.
var DEFAULT_FIELDS = [
{ name: 'ORIG', optional: false },
{ name: 'PHASE', optional: false },
{ name: 'PROJECT', optional: false },
{ name: 'AREA', optional: false },
{ name: 'PROJ', optional: false },
{ name: 'DISC', optional: false },
{ name: 'TYPE', optional: false },
{ name: 'SEQ', optional: false },
@ -59,8 +57,7 @@
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
outputName: null, // remembered output directory display name
config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers)
worklist: [], // "From a list" scratch rows: [ { id, trackingNumber, title, revisionCell, source, archiveRevisions } ]
trackingWorkset: Object.create(null), // srcKeys shown as rows in the By-tracking grid (set: key->true)
mdlList: [], // loaded MDL deliverables (drop targets): [ { id, party, trackingNumber, title, revisionCell } ]
};
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
@ -126,7 +123,7 @@
function assignmentFor(key) {
var a = state.assignments[key];
if (!a) {
a = { trackingNodeId: null, transmittalNodeId: null, excluded: false, titleOverride: null };
a = { trackingNodeId: null, transmittalNodeId: null, mdlNodeId: null, excluded: false, titleOverride: null, titleFromDeliverable: true };
state.assignments[key] = a;
}
return a;
@ -135,19 +132,22 @@
function getAssignment(key) { return state.assignments[key] || null; }
function cleanAssignment(key) {
var a = state.assignments[key];
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) {
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.mdlNodeId && !a.excluded && !a.titleOverride) {
delete state.assignments[key];
}
}
// Place keys onto a node along one axis ('tracking' | 'transmittal').
// nodeId null clears that axis. (The "From a list" tab also produces
// 'tracking' placements — see assignFromRow.)
// nodeId null clears that axis.
function place(keys, nodeId, axis) {
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
var field = axis === 'transmittal' ? 'transmittalNodeId' : axis === 'mdl' ? 'mdlNodeId' : 'trackingNodeId';
keys.forEach(function (k) {
var a = assignmentFor(k);
a[field] = nodeId || null;
// Tracking and MDL are alternative NAME sources — placing on one
// clears the other so the file has a single name origin.
if (axis === 'mdl' && nodeId) a.trackingNodeId = null;
else if (axis === 'tracking' && nodeId) a.mdlNodeId = null;
a.excluded = false; // placing un-excludes
cleanAssignment(k);
});
@ -158,7 +158,7 @@
keys.forEach(function (k) {
var a = assignmentFor(k);
a.excluded = !!excluded;
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; }
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; a.mdlNodeId = null; }
cleanAssignment(k);
});
clearHashConflicts();
@ -198,8 +198,9 @@
});
});
});
// Scratch-list rows are NOT placement targets — drops materialize real
// tracking-tree nodes (assignFromRow), so the list isn't indexed here.
(state.mdlList || []).forEach(function (row) {
nodeIndex[row.id] = { node: row, kind: 'mdl', parent: null };
});
}
function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; }
function infoFor(id) { return nodeIndex[id] || null; }
@ -332,10 +333,26 @@
};
if (out.excluded) return out;
// Axis 1 — NAME, always the tracking tree. The "From a list" tab drops
// also produce tracking-tree placements (assignFromRow), so there is a
// single name origin.
if (a.trackingNodeId) {
// Axis 1 — NAME. An MDL deliverable (alternative to the tracking tree)
// supplies the tracking number + title; its revision comes from the
// classifier-local revision cell. Otherwise the tracking tree.
if (a.mdlNodeId) {
var mi = infoFor(a.mdlNodeId);
if (mi && mi.kind === 'mdl') {
var row = mi.node;
out.tracking = row.trackingNumber || '';
var ml = parseLeafLabel(row.revisionCell || '');
out.revision = ml.revision; out.status = ml.status;
out.trackingLeaf = true;
if (!a.titleOverride && a.titleFromDeliverable !== false && row.title) out.title = row.title;
if (!out.tracking) out.errors.push('deliverable has no tracking number');
if (!out.revision) out.errors.push('set a revision for this deliverable (e.g. "A (IFR)")');
else if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"');
else if (!out.status) out.errors.push('revision needs a "(STATUS)" — e.g. "A (IFR)"');
} else {
out.errors.push('deliverable no longer loaded');
}
} else if (a.trackingNodeId) {
var ti = infoFor(a.trackingNodeId);
if (ti && ti.kind === 'tracking') {
var chain = trackingChain(ti); // [root … node]
@ -379,6 +396,15 @@
return out;
}
// Files currently placed in a node (reverse lookup over all source files).
function filesInNode(nodeId, axis, allFiles) {
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
return (allFiles || []).filter(function (f) {
var a = state.assignments[srcKeyForFile(f)];
return a && a[field] === nodeId;
});
}
// Per-file classification state for the left-tree markers.
// 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none'
function fileState(file) {
@ -416,12 +442,7 @@
transmittalTree: state.transmittalTree,
outputName: state.outputName,
config: state.config,
// Strip the transient row→keys hint (`placed`) — it's rebuilt as
// drops happen and would otherwise bloat every autosave.
worklist: state.worklist.map(function (r) {
return { id: r.id, party: r.party, trackingNumber: r.trackingNumber, title: r.title, revisionCell: r.revisionCell, source: r.source, archiveRevisions: r.archiveRevisions };
}),
trackingWorkset: Object.keys(state.trackingWorkset),
mdlList: state.mdlList,
};
}
function load(obj) {
@ -431,91 +452,19 @@
state.transmittalTree = obj.transmittalTree || [];
state.outputName = obj.outputName || null;
state.config = normalizeConfig(obj.config);
state.worklist = (Array.isArray(obj.worklist) ? obj.worklist : []).map(normalizeRow);
state.trackingWorkset = Object.create(null);
(Array.isArray(obj.trackingWorkset) ? obj.trackingWorkset : []).forEach(function (k) { state.trackingWorkset[k] = true; });
state.mdlList = Array.isArray(obj.mdlList) ? obj.mdlList : [];
rebuildIndex();
migrateLegacyMdl(obj.worklist); // BEFORE anything can prune; materializes old mdl placements
notify();
}
// Pre-"From a list" workspaces stored a separate `mdlNodeId` axis pointing at
// a row id. Materialize each into a real tracking placement so the
// classification survives the model change, then drop the dead fields.
function migrateLegacyMdl(rawRows) {
var byOldId = Object.create(null);
(rawRows || []).forEach(function (r) { if (r && r.id) byOldId[r.id] = r; });
Object.keys(state.assignments).forEach(function (k) {
var a = state.assignments[k];
if (!a) return;
if (a.mdlNodeId) {
var r = byOldId[a.mdlNodeId];
if (!a.trackingNodeId && r && r.trackingNumber) {
var leaf = addTrackingPath(null, parseFolderLevels(r.trackingNumber + '_' + (r.revisionCell || PENDING_REV)));
a.trackingNodeId = leaf;
if (!a.titleOverride && r.title && a.titleFromDeliverable !== false) a.titleOverride = r.title;
}
delete a.mdlNodeId;
}
if ('titleFromDeliverable' in a) delete a.titleFromDeliverable;
cleanAssignment(k);
});
}
// Reset clears the CLASSIFICATION but keeps the pattern config — it's a
// per-project setting, not part of the data being cleared.
function reset() {
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
state.outputName = null;
state.trackingWorkset = Object.create(null);
rebuildIndex();
notify();
}
// ── By-tracking grid (one editable row per file) ─────────────────────────
// The grid is a flat presentation over the tracking-tree placement model.
// `trackingWorkset` tracks files put on the grid so a dropped file shows as a
// row before it has a tracking number; a file with a real tracking placement
// (named here OR via the "From a list" tab) is always a row too.
function addToTrackingGrid(keys) {
var changed = false;
(keys || []).forEach(function (k) { if (!state.trackingWorkset[k]) { state.trackingWorkset[k] = true; changed = true; } });
if (changed) notify();
}
function removeFromTrackingGrid(key) {
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
delete state.trackingWorkset[key];
place([key], null, 'tracking');
if (old) pruneEmptyTrackingChain(old);
notify();
}
function trackingGridKeys() {
var set = Object.create(null);
Object.keys(state.trackingWorkset).forEach(function (k) { set[k] = true; });
Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].trackingNodeId) set[k] = true; });
return Object.keys(set);
}
// Re-materialize a file's tracking placement from a full identity. The caller
// passes ALL three fields (current values for the ones it didn't edit), read
// from deriveTarget — so this module needs no file objects. A blank revision
// lands on the PENDING_REV placeholder leaf (incomplete until set); a blank
// tracking number clears the placement (the row stays, unfilled).
function setFileIdentity(key, ident) {
ident = ident || {};
var tracking = (ident.tracking == null ? '' : String(ident.tracking)).trim();
var rev = (ident.rev == null ? '' : String(ident.rev)).trim();
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
if (tracking) {
var leaf = addTrackingPath(null, parseFolderLevels(tracking + '_' + (rev || PENDING_REV)));
place([key], leaf, 'tracking');
if (old && old !== leaf) pruneEmptyTrackingChain(old);
} else {
place([key], null, 'tracking');
if (old) pruneEmptyTrackingChain(old);
}
setTitleOverride(key, ident.title || '');
state.trackingWorkset[key] = true;
notify();
}
// ── pattern config ───────────────────────────────────────────────────────
function normalizeConfig(c) {
var d = defaultConfig();
@ -533,236 +482,39 @@
function getTrackingFields() { return state.config.trackingFields; }
function setConfig(c) { state.config = normalizeConfig(c); notify(); }
// ── "From a list" scratch worklist ───────────────────────────────────────
// A temporary list of known/typed tracking numbers (from the archive/MDL, a
// paste, or a name-match). Dropping a file on a row MATERIALIZES a real
// tracking-tree placement (assignFromRow) — the list is pure input, so it can
// be cleared without losing any classification. `placed` is a transient
// row→keys hint (not the source of truth, not serialized) used to re-stamp a
// row's files when its tracking number / revision is edited.
function rowSource(r) {
if (r && r.source) return { mdl: !!r.source.mdl, archive: !!r.source.archive, pasted: !!r.source.pasted };
return { mdl: !!(r && r.inMdl), archive: !!(r && Array.isArray(r.archiveRevisions) && r.archiveRevisions.length), pasted: false };
}
function normalizeRow(r) {
r = r || {};
return {
id: r.id || uid(), party: r.party || '',
trackingNumber: (r.trackingNumber || '').trim(), title: r.title || '',
revisionCell: r.revisionCell || '',
// The file's existing name (pasted col 4) — a join key for name-match.
currentName: (r.currentName || '').trim(),
source: rowSource(r),
archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [],
placed: Object.create(null),
};
}
function setWorklist(rows) { state.worklist = (rows || []).map(normalizeRow); notify(); }
function appendWorklist(rows) {
var byTn = Object.create(null);
state.worklist.forEach(function (r) { if (r.trackingNumber) byTn[r.trackingNumber] = r; });
(rows || []).forEach(function (raw) {
var r = normalizeRow(raw), ex = r.trackingNumber ? byTn[r.trackingNumber] : null;
if (ex) {
if (!ex.revisionCell && r.revisionCell) ex.revisionCell = r.revisionCell;
if (!ex.title && r.title) ex.title = r.title;
ex.source.mdl = ex.source.mdl || r.source.mdl;
ex.source.archive = ex.source.archive || r.source.archive;
ex.source.pasted = ex.source.pasted || r.source.pasted;
if (r.archiveRevisions.length && !ex.archiveRevisions.length) ex.archiveRevisions = r.archiveRevisions;
} else {
state.worklist.push(r);
if (r.trackingNumber) byTn[r.trackingNumber] = r;
}
// ── MDL deliverables (the "By MDL" drop-target axis) ─────────────────────
function setMdlList(rows) {
state.mdlList = (rows || []).map(function (r) {
return {
id: r.id || uid(), party: r.party || '',
trackingNumber: r.trackingNumber || '', title: r.title || '',
revisionCell: r.revisionCell || '',
};
});
notify();
}
function clearWorklist() { state.worklist = []; notify(); } // rows only — assignments survive
function getWorklist() { return state.worklist; }
function getWorklistRow(id) { return state.worklist.filter(function (r) { return r.id === id; })[0] || null; }
// Build (creating folders as needed) the tracking-tree leaf a row points at:
// "<tracking>_<rev (status)>". A tracking number's last segment is an
// ancestor and the revision is ALWAYS the leaf, so a blank revision gets a
// PENDING_REV placeholder leaf (the file shows incomplete until set; the
// placeholder is pruned when the real revision lands).
var PENDING_REV = 'pending';
function leafForRow(row) {
var tn = (row.trackingNumber || '').trim();
if (!tn) return null;
var rev = (row.revisionCell || '').trim() || PENDING_REV;
return addTrackingPath(null, parseFolderLevels(tn + '_' + rev));
}
function assignFromRow(keys, row) {
var leaf = leafForRow(row);
if (!leaf || !keys || !keys.length) return;
place(keys, leaf, 'tracking');
keys.forEach(function (k) {
row.placed[k] = true;
if (row.title && row.title.trim()) {
var a = state.assignments[k];
if (a && !a.titleOverride) setTitleOverride(k, row.title);
}
});
notify();
}
function nodeHasFiles(nodeId) {
for (var k in state.assignments) { if (state.assignments[k].trackingNodeId === nodeId) return true; }
return false;
}
// Delete a now-empty tracking leaf and any ancestors it leaves empty (so a
// re-stamp doesn't litter the By-tracking tree with stale folders).
function pruneEmptyTrackingChain(nodeId) {
var info = infoFor(nodeId);
while (info && info.kind === 'tracking') {
if ((info.node.children || []).length || nodeHasFiles(info.node.id)) break;
var parent = info.parent; // parent NODE (or null)
deleteNode(info.node.id); // rebuilds the index
info = parent ? infoFor(parent.id) : null;
}
}
// Re-point a row's already-dropped files at the row's current leaf (after its
// tracking number or revision was edited). Skips keys the user has since
// un-placed elsewhere; prunes the leaves it empties.
function restampRow(row) {
var keys = Object.keys(row.placed || {});
if (!keys.length) return;
var leaf = leafForRow(row);
if (!leaf) return;
var old = Object.create(null);
keys.forEach(function (k) {
// Drop placements pointing at deliverables no longer loaded.
var valid = Object.create(null);
state.mdlList.forEach(function (r) { valid[r.id] = true; });
Object.keys(state.assignments).forEach(function (k) {
var a = state.assignments[k];
if (a && a.trackingNodeId) { if (a.trackingNodeId !== leaf) old[a.trackingNodeId] = true; a.trackingNodeId = leaf; }
else delete row.placed[k]; // user un-placed it elsewhere — don't resurrect
if (a.mdlNodeId && !valid[a.mdlNodeId]) { a.mdlNodeId = null; cleanAssignment(k); }
});
clearHashConflicts();
Object.keys(old).forEach(pruneEmptyTrackingChain);
notify();
}
function unassignRowFile(row, key) {
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
if (row && row.placed) delete row.placed[key];
place([key], null, 'tracking');
if (old) pruneEmptyTrackingChain(old);
}
function setRowTracking(rowId, tn) {
var r = getWorklistRow(rowId); if (!r) return;
r.trackingNumber = (tn == null ? '' : String(tn)).trim();
restampRow(r); notify();
}
function setRowTitle(rowId, title) {
var r = getWorklistRow(rowId); if (!r) return;
r.title = (title == null ? '' : String(title));
Object.keys(r.placed || {}).forEach(function (k) { if (state.assignments[k]) setTitleOverride(k, r.title); });
rebuildIndex();
notify();
}
function getMdlList() { return state.mdlList; }
function getMdlRow(id) { var i = infoFor(id); return (i && i.kind === 'mdl') ? i.node : null; }
function setRevisionCell(rowId, value) { setRevisionCells([rowId], value); }
function setRevisionCells(rowIds, value) {
var set = Object.create(null); (rowIds || []).forEach(function (i) { set[i] = true; });
var changed = false;
state.worklist.forEach(function (r) {
if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); restampRow(r); changed = true; }
});
state.mdlList.forEach(function (r) { if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); changed = true; } });
if (changed) notify();
}
// ── paste parsing + name matching (pure helpers, unit-tested) ─────────────
// Parse Excel/TSV text into scratch rows. Columns: Tracking ⇥ Rev(Status) ⇥
// Title; a 4th bare-status column merges into the revision; a lone cell that
// parses as a full ZDDC filename is split; a header row is skipped.
// FIXED schema, by column position (no variant detection): a header row is
// skipped, then each line is tracking_number ⇥ rev (status) ⇥ title ⇥
// current name. Trailing columns may be omitted (currentName/title blank).
function parsePastedRows(text) {
function unq(s) {
s = (s == null ? '' : String(s)).trim();
if (s.length >= 2 && s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') s = s.slice(1, -1).replace(/""/g, '"');
return s.trim();
}
var lines = String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n');
var rows = [], skipped = [], sawData = false;
lines.forEach(function (raw, i) {
if (!raw.trim()) return;
var cells = raw.split('\t').map(unq);
var c0 = cells[0] || '';
// Skip a leading header row (first cell is a header word, not a tn).
if (!sawData && /^(tracking|number|no\.?|doc(ument)?|drawing|item)\b/i.test(c0) && c0.indexOf('-') === -1) return;
if (!c0) { skipped.push({ line: i + 1, reason: 'no tracking number', text: raw }); return; }
sawData = true;
rows.push({
trackingNumber: c0,
revisionCell: (cells[1] || '').trim(),
title: cells[2] || '',
currentName: cells[3] || '',
source: { pasted: true },
});
});
return { rows: rows, skipped: skipped };
}
function normTok(s) { return String(s == null ? '' : s).toUpperCase().replace(/[^A-Z0-9]/g, ''); }
function dropExt(s) { return String(s == null ? '' : s).replace(/\.[^.\/\\]+$/, ''); }
function nameKey(s) { return dropExt(s).toLowerCase().replace(/[^a-z0-9]+/g, ''); }
function nameTokens(s) { return dropExt(s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); }
// Score a pasted "current name" against a file's name: 1 = exact (normalized,
// extension dropped), 0.60.95 = token coverage, 0.7 = a clean substring,
// 0 = no match. Token-set beats raw substring (survives reordering).
function nameScore(rowName, fileFull) {
var rk = nameKey(rowName); if (!rk) return 0;
var fk = nameKey(fileFull);
if (rk === fk) return 1;
var rt = nameTokens(rowName);
if (rt.length) {
var ft = Object.create(null); nameTokens(fileFull).forEach(function (t) { ft[t] = true; });
var hit = 0; rt.forEach(function (t) { if (ft[t]) hit++; });
var cov = hit / rt.length;
if (cov >= 0.6) return Math.min(0.95, 0.6 + 0.35 * cov);
}
var a = rk.length <= fk.length ? rk : fk, b = rk.length <= fk.length ? fk : rk;
if (a.length >= 4 && b.indexOf(a) !== -1) return 0.7;
return 0;
}
// Propose file↔row matches. PRIMARY signal is the pasted "current name"
// column (nameScore); FALLBACK is the tracking number embedded in the
// filename (opts.fuzzy also tries the digit-run). Each proposal carries a
// confidence and an `auto` flag — true only for an exact 1:1 match (conf 1,
// the unique conf-1 match for BOTH its file and its row), the only kind safe
// to assign without confirmation.
function proposeMatches(files, rows, opts) {
opts = opts || {};
var named = (rows || []).filter(function (r) { return (r.currentName || '').trim(); });
var out = [];
(files || []).forEach(function (f) {
var full = zddc.joinExtension(f.originalFilename, f.extension);
var best = null;
named.forEach(function (r) {
var s = nameScore(r.currentName, full);
if (s > 0 && (!best || s > best.confidence)) best = { row: r, confidence: s, via: 'name' };
});
if (!best) { // fallback: tracking number in the filename
var nameNorm = normTok(full), nameDigits = nameNorm.replace(/[^0-9]/g, '');
(rows || []).forEach(function (r) {
var tn = r.trackingNumber || ''; if (!tn) return;
var tnNorm = normTok(tn), conf = 0;
if (full.indexOf(tn) !== -1) conf = 1;
else if (tnNorm && nameNorm.indexOf(tnNorm) !== -1) conf = 0.8;
else if (opts.fuzzy) { var d = tnNorm.replace(/[^0-9]/g, ''); if (d && nameDigits.indexOf(d) !== -1) conf = 0.5; }
if (conf && (!best || conf > best.confidence)) best = { row: r, confidence: conf, via: 'tracking' };
});
}
if (best) out.push({ file: f, row: best.row, confidence: best.confidence, via: best.via, auto: false });
});
// Auto-assignable = exact + unambiguous both ways (so duplicate names
// never silently grab the wrong file).
var rowEx = Object.create(null), fileEx = Object.create(null);
out.forEach(function (p) {
if (p.confidence !== 1) return;
rowEx[p.row.id || p.row.trackingNumber] = (rowEx[p.row.id || p.row.trackingNumber] || 0) + 1;
fileEx[srcKeyForFile(p.file)] = (fileEx[srcKeyForFile(p.file)] || 0) + 1;
});
out.forEach(function (p) {
if (p.confidence === 1) p.auto = rowEx[p.row.id || p.row.trackingNumber] === 1 && fileEx[srcKeyForFile(p.file)] === 1;
});
return out;
function setTitleFromDeliverable(key, fromDeliverable) {
var a = assignmentFor(key);
a.titleFromDeliverable = !!fromDeliverable;
cleanAssignment(key);
notify();
}
// ── add-folder pattern expansion ─────────────────────────────────────────
@ -939,19 +691,13 @@
transmittalRecord: transmittalRecord,
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
// By-tracking grid
addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid,
trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity,
setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist,
getWorklist: getWorklist, getWorklistRow: getWorklistRow,
assignFromRow: assignFromRow, unassignRowFile: unassignRowFile,
setRowTracking: setRowTracking, setRowTitle: setRowTitle,
setMdlList: setMdlList, getMdlList: getMdlList, getMdlRow: getMdlRow,
setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells,
parsePastedRows: parsePastedRows, proposeMatches: proposeMatches,
setTitleFromDeliverable: setTitleFromDeliverable,
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
getTransmittalTree: function () { return state.transmittalTree; },
// derive + reverse
deriveTarget: deriveTarget,
deriveTarget: deriveTarget, filesInNode: filesInNode,
fileState: fileState, stats: stats,
// persistence
serialize: serialize, load: load, reset: reset,

View file

@ -326,9 +326,7 @@
row.appendChild(go); row.appendChild(cancel);
box.appendChild(h); box.appendChild(p); box.appendChild(sel); box.appendChild(row);
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(null); });
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
document.addEventListener('keydown', onKey);
document.body.appendChild(back);
});
@ -361,9 +359,7 @@
row.appendChild(btn('Cancel', 'btn-secondary', null));
box.appendChild(h); box.appendChild(p); box.appendChild(row);
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(null); });
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
document.addEventListener('keydown', onKey);
document.body.appendChild(back);
});

View file

@ -1,137 +0,0 @@
/**
* 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);
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
};
})();

View file

@ -0,0 +1,209 @@
/**
* ZDDC Classifier instantiate MDL deliverables from existing archive files.
*
* Catch-up flow: the archive already holds issued documents, but the Master
* Deliverables List is empty. This reads a project's archive subtree as a flat
* file list, lets the user build a selection set (autofilter + ctrl-shift via
* the shared seltable), dedupes the selected files to one deliverable per
* tracking number, and PUTs a new deliverable .yaml into the originator's
* `archive/<originator>/mdl/` on the server. Server-only (needs http + auth).
*
* A deliverable .yaml's filename IS its tracking number; the server pins
* `originator` from the folder and composes the filename, so the body carries
* only project/discipline/type/sequence/suffix + title.
*/
(function () {
'use strict';
if (!window.app) window.app = {};
if (!window.app.modules) window.app.modules = {};
function T(m, l, o) { if (window.zddc && window.zddc.toast) window.zddc.toast(m, l, o); }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
// ── pure core (test seams) ───────────────────────────────────────────────
// A tracking number → deliverable {tracking, originator, body{...}} or null
// if it doesn't fit the MDL schema (needs orig-proj-disc-type-seq, + suffix).
function deliverableFromFile(f) {
var segs = String(f.tracking || '').split('-');
if (segs.length < 5) return null;
var body = { project: segs[1], discipline: segs[2], type: segs[3], sequence: segs[4], title: f.title || '' };
if (segs.length >= 6) body.suffix = segs.slice(5).join('-');
return { tracking: f.tracking, originator: segs[0], body: body };
}
// Dedupe a list of archive files to one deliverable per tracking number.
function dedupe(files) {
var seen = Object.create(null), out = [];
(files || []).forEach(function (f) {
if (seen[f.tracking]) return;
var d = deliverableFromFile(f);
if (d) { seen[f.tracking] = true; out.push(d); }
});
return out;
}
// Recursively walk an archive directory handle → flat list of ZDDC-named
// files (skips dot/underscore folders; non-ZDDC names like the mdl yamls
// naturally fall out because parseFilename rejects them).
async function walkArchive(rootHandle) {
var out = [];
async function walk(dirH, parts) {
for await (var entry of dirH.values()) {
var nm = String(entry.name || '').replace(/\/$/, '');
if (entry.kind === 'directory') {
var c = nm.charAt(0);
if (c === '.' || c === '_' || nm === 'mdl' || nm === 'rsk') continue;
var childH = await dirH.getDirectoryHandle(nm);
await walk(childH, parts.concat(nm));
} else {
var p = window.zddc.parseFilename(nm);
if (p && p.valid) {
out.push({
id: parts.concat(nm).join('/'),
party: parts[0] || '', slot: parts[1] || '', transmittal: parts[2] || '',
name: nm, tracking: p.trackingNumber, revision: p.revision, status: p.status, title: p.title,
});
}
}
}
}
await walk(rootHandle, []);
return out;
}
// Write one deliverable into <archiveRoot>/<originator>/mdl/<tracking>.yaml.
// Returns 'created' | 'skipped' (already present). Throws on server error.
async function instantiateOne(archiveRoot, d) {
var dir = await archiveRoot.getDirectoryHandle(d.originator, { create: true });
dir = await dir.getDirectoryHandle('mdl', { create: true });
var fname = d.tracking + '.yaml';
try { await dir.getFileHandle(fname); return 'skipped'; } catch (e) { /* NotFound → create */ }
var yaml = window.jsyaml.dump(d.body);
var fh = await dir.getFileHandle(fname, { create: true });
var w = await fh.createWritable();
await w.write(new Blob([yaml], { type: 'application/yaml' }));
await w.close();
return 'created';
}
async function instantiateAll(archiveRoot, deliverables, onProgress) {
var s = { created: 0, skipped: 0, errors: 0 };
for (var i = 0; i < deliverables.length; i++) {
if (onProgress) onProgress(i + 1, deliverables.length, deliverables[i].tracking);
try { s[await instantiateOne(archiveRoot, deliverables[i])]++; }
catch (e) { s.errors++; T('Failed to create ' + deliverables[i].tracking + ' — ' + (e.message || e), 'error'); }
}
return s;
}
// ── UI ───────────────────────────────────────────────────────────────────
var overlay = null, statusEl = null, table = null, files = [], archiveRoot = null;
function close() { if (overlay) { overlay.remove(); overlay = null; table = null; } }
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
async function open() {
var copy = window.app.modules.copy;
var src = window.zddc && window.zddc.source;
if (!src || location.protocol === 'file:') {
T('Populating the MDL from the archive needs the classifier served by a zddc-server (open it over http).', 'error');
return;
}
var projects = await copy.fetchAccessProjects();
if (projects == null) { T('Could not load your projects from the server.', 'error'); return; }
if (!projects.length) { T('No projects you can access on this server.', 'warning'); return; }
var proj = await copy.chooseProject(projects);
if (!proj) return;
buildOverlay(proj);
await scan(proj);
}
function buildOverlay(proj) {
close();
overlay = el('div', 'mdl-overlay');
var box = el('div', 'mdl-overlay__box');
var head = el('div', 'mdl-overlay__head');
head.appendChild(el('h2', null, 'Populate MDL from archive — ' + (proj.title || proj.name)));
var x = el('button', 'mdl-overlay__close', '×'); x.title = 'Close'; x.addEventListener('click', close);
head.appendChild(x);
box.appendChild(head);
statusEl = el('div', 'mdl-overlay__status', 'Scanning archive…');
box.appendChild(statusEl);
var host = el('div', 'mdl-overlay__table');
box.appendChild(host);
var foot = el('div', 'mdl-overlay__foot');
var create = el('button', 'btn btn-primary', 'Create deliverables');
create.addEventListener('click', function () { runCreate(create); });
foot.appendChild(create);
var cancel = el('button', 'btn btn-secondary', 'Close'); cancel.addEventListener('click', close);
foot.appendChild(cancel);
box.appendChild(foot);
overlay.appendChild(box);
document.body.appendChild(overlay);
table = window.app.modules.seltable.create({
container: host,
filterPlaceholder: 'Filter by party, transmittal, tracking number, title…',
rows: function () { return files; },
rowId: function (r) { return r.id; },
columns: [
{ key: 'party', title: 'Party' },
{ key: 'slot', title: 'Slot' },
{ key: 'transmittal', title: 'Transmittal' },
{ key: 'tracking', title: 'Tracking number' },
{ key: 'revision', title: 'Rev', get: function (r) { return r.revision + (r.status ? ' (' + r.status + ')' : ''); } },
{ key: 'title', title: 'Title' },
],
onSelectionChange: function (ids) { create.textContent = ids.length ? ('Create deliverables (' + dedupe(selectedFiles(ids)).length + ')') : 'Create deliverables'; },
});
table.render();
}
function selectedFiles(ids) {
var set = {}; ids.forEach(function (i) { set[i] = true; });
return files.filter(function (f) { return set[f.id]; });
}
async function scan(proj) {
var src = window.zddc.source;
var rel = (proj.url || ('/' + proj.name + '/'));
if (rel.charAt(rel.length - 1) !== '/') rel += '/';
try {
archiveRoot = new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive');
setStatus('Scanning archive…');
files = await walkArchive(archiveRoot);
table.renderBody();
setStatus(files.length + ' document file' + (files.length === 1 ? '' : 's') + ' found. Filter + ctrl-shift select, then “Create deliverables”.');
} catch (e) {
setStatus('Archive scan failed — ' + (e.message || e));
T('Archive scan failed — ' + (e.message || e), 'error');
}
}
async function runCreate(btn) {
if (!table) return;
var sel = table.getSelection();
if (!sel.length) { T('Select some archive files first (filter + ctrl-shift).', 'warning'); return; }
var deliverables = dedupe(selectedFiles(sel));
if (!deliverables.length) { T('None of the selected files have a tracking number that fits the deliverable schema.', 'warning'); return; }
if (!confirm('Create ' + deliverables.length + ' deliverable(s) in the project MDL?\n\n'
+ 'One .yaml per tracking number, in archive/<originator>/mdl/. Already-present ones are skipped.')) return;
btn.disabled = true;
var s = await instantiateAll(archiveRoot, deliverables, function (i, n, tn) { setStatus('Creating ' + i + '/' + n + ' — ' + tn); });
btn.disabled = false;
setStatus(s.created + ' created, ' + s.skipped + ' already there'
+ (s.errors ? (', ' + s.errors + ' failed') : '') + '. ' + files.length + ' files scanned.');
T('MDL: ' + s.created + ' created, ' + s.skipped + ' already there'
+ (s.errors ? (', ' + s.errors + ' failed') : '') + '.', s.errors ? 'warning' : 'success');
}
window.app.modules.mdlInstantiate = {
open: open,
// test seams
deliverableFromFile: deliverableFromFile,
dedupe: dedupe,
walkArchive: walkArchive,
instantiateOne: instantiateOne,
instantiateAll: instantiateAll,
};
})();

View file

@ -8,39 +8,32 @@
let resizingColumn = null;
let startX = 0;
let startWidth = 0;
let activeTable = null;
let activeOnResize = null;
/**
* Initialize column resizing on a table. Defaults to the rename-in-place
* spreadsheet when no table is passed (back-compatible). onResize(table) is
* called after each drag ends, so a caller can persist the new widths.
* Initialize column resizing
*/
function init(table, onResize) {
table = table || (window.app.dom && window.app.dom.spreadsheet);
if (!table) return;
function init() {
const table = window.app.dom.spreadsheet;
const headers = table.querySelectorAll('thead th');
headers.forEach(th => {
// Skip if resize handle already exists
if (th.querySelector('.column-resizer')) return;
// Add resize handle
const resizer = document.createElement('div');
resizer.className = 'column-resizer';
th.appendChild(resizer);
// Mouse down on resizer
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
resizingColumn = th;
startX = e.pageX;
startWidth = th.offsetWidth;
activeTable = table;
activeOnResize = onResize || null;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
@ -68,8 +61,6 @@
resizingColumn = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
if (activeOnResize && activeTable) { try { activeOnResize(activeTable); } catch (_) { /* ignore */ } }
activeTable = null; activeOnResize = null;
}
// Export module

View file

@ -1,18 +1,20 @@
/**
* ZDDC shared selectable + autofilter table (used by the classifier catalog
* and the tables tool's "Add from archive").
* ZDDC Classifier shared selectable + autofilter table.
*
* A flat table with PER-COLUMN autofilters (one input per column, AND-combined,
* each an AND of space-separated terms) plus an optional programmatic global
* filter, and powerful selection for building complex sets quickly:
* A flat table with one global autofilter (AND of space-separated terms over
* every column) and powerful selection for building complex sets quickly:
* click replace selection + set anchor
* ctrl/cmd-click toggle one row
* shift-click range from the anchor (replaces the selection)
* ctrl-shift-click ADD the anchorrow range to the existing selection
* ctrl/cmd-Enter fire onActivate(selectedIds) a bulk action
* Esc clear
* Ranges run over the CURRENTLY FILTERED order. Selection is keyed by a stable
* Ranges run over the CURRENTLY FILTERED order, so "filter to a transmittal,
* then shift-select the visible block" works. Selection is keyed by a stable
* rowId so it survives filtering and re-render.
*
* Used by the MDL instantiate flow (Phase 1) and the By-MDL drop-target table
* (Phase 2).
*/
(function () {
'use strict';
@ -39,28 +41,17 @@
var getRows = (typeof opts.rows === 'function') ? opts.rows : function () { return opts.rows || []; };
var selected = Object.create(null); // id -> true
var anchorId = null;
var globalTerms = []; // programmatic global filter (tests/reveal)
var colFilters = Object.create(null); // colKey -> terms[] (the per-column autofilters)
var ft = []; // global filter terms
function rows() { return getRows() || []; }
function colByKey(k) { for (var i = 0; i < columns.length; i++) { if (columns[i].key === k) return columns[i]; } return null; }
function colVal(col, row) { return col.get ? col.get(row) : (row[col.key] == null ? '' : row[col.key]); }
function rowBlob(row) { var s = ''; for (var i = 0; i < columns.length; i++) { s += colVal(columns[i], row) + ' '; } return s; }
function rowMatches(row) {
if (globalTerms.length && !hit(rowBlob(row), globalTerms)) return false;
for (var k in colFilters) {
var col = colByKey(k);
if (col && !hit(colVal(col, row), colFilters[k])) return false;
}
return true;
}
function filtered() { return rows().filter(rowMatches); }
function filtered() { return ft.length ? rows().filter(function (r) { return hit(rowBlob(r), ft); }) : rows().slice(); }
function getSelection() { return Object.keys(selected); }
function getFilteredRows() { return filtered(); }
function fireSel() { if (opts.onSelectionChange) opts.onSelectionChange(getSelection()); }
function setFilter(q) { globalTerms = terms(q); renderBody(); }
function setColFilter(colKey, q) { var t = terms(q); if (t.length) colFilters[colKey] = t; else delete colFilters[colKey]; renderBody(); }
function setFilter(q) { ft = terms(q); renderBody(); }
function selectAllFiltered() { filtered().forEach(function (r) { selected[rowId(r)] = true; }); anchorId = null; renderBody(); fireSel(); }
function clearSel() { selected = Object.create(null); anchorId = null; renderBody(); fireSel(); }
@ -84,12 +75,15 @@
container.textContent = '';
container.classList.add('seltable');
var bar = elt('div', 'seltable__bar');
var filterEl = elt('input', 'seltable__filter'); filterEl.type = 'search';
filterEl.placeholder = opts.filterPlaceholder || 'Filter…'; filterEl.spellcheck = false;
filterEl.addEventListener('input', function () { setFilter(this.value); });
var allBtn = elt('button', 'btn btn-sm btn-secondary', 'Select filtered');
allBtn.addEventListener('click', selectAllFiltered);
var clrBtn = elt('button', 'btn btn-sm btn-secondary', 'Clear');
clrBtn.addEventListener('click', clearSel);
countEl = elt('span', 'seltable__count');
bar.appendChild(allBtn); bar.appendChild(clrBtn); bar.appendChild(countEl);
bar.appendChild(filterEl); bar.appendChild(allBtn); bar.appendChild(clrBtn); bar.appendChild(countEl);
container.appendChild(bar);
var scroll = elt('div', 'seltable__scroll');
@ -97,22 +91,7 @@
var thead = elt('thead'), htr = elt('tr');
columns.forEach(function (c) { htr.appendChild(elt('th', c.cls || null, c.title || c.key)); });
if (opts.rowExtra) htr.appendChild(elt('th', 'seltable__extrah', opts.extraTitle || ''));
thead.appendChild(htr);
// Per-column autofilter row.
var ftr = elt('tr', 'seltable__filters');
columns.forEach(function (c) {
var th = elt('th');
if (c.filterable !== false) {
var inp = elt('input', 'seltable__colfilter'); inp.type = 'search'; inp.placeholder = 'filter…'; inp.spellcheck = false;
inp.setAttribute('data-no-select', '');
inp.addEventListener('input', function () { setColFilter(c.key, this.value); });
th.appendChild(inp);
}
ftr.appendChild(th);
});
if (opts.rowExtra) ftr.appendChild(elt('th'));
thead.appendChild(ftr);
table.appendChild(thead);
thead.appendChild(htr); table.appendChild(thead);
bodyEl = elt('tbody'); table.appendChild(bodyEl);
scroll.appendChild(table); container.appendChild(scroll);
@ -132,7 +111,7 @@
var tr = elt('tr', 'seltable__row' + (selected[id] ? ' is-selected' : ''));
tr.dataset.id = id;
tr.addEventListener('click', function (e) {
if (e.target.closest('input,button,select,a,[data-no-select]')) return;
if (e.target.closest('input,button,select,a,[data-no-select]')) return; // let controls work
onRowClick(e, row, fr);
});
if (opts.onRowDrop) {
@ -158,14 +137,15 @@
});
if (countEl) {
var nSel = getSelection().length;
countEl.textContent = fr.length + ' shown' + (nSel ? ' · ' + nSel + ' selected' : '');
countEl.textContent = fr.length + ' shown' + (nSel ? (' · ' + nSel + ' selected') : '');
}
}
return {
render: render, renderBody: renderBody,
getSelection: getSelection, getFilteredRows: getFilteredRows,
setFilter: setFilter, setColFilter: setColFilter, selectAllFiltered: selectAllFiltered, clear: clearSel,
setFilter: setFilter, selectAllFiltered: selectAllFiltered, clear: clearSel,
// test seam: simulate a row click with modifier keys.
clickRow: function (id, mods) {
var fr = filtered();
var row = fr.filter(function (r) { return String(rowId(r)) === String(id); })[0];

File diff suppressed because it is too large Load diff

View file

@ -5,17 +5,6 @@
(function() {
'use strict';
// ── Sorting ────────────────────────────────────────────────────────────
// Render the tree in a stable, human order: case-insensitive, natural
// (so "Rev 2" sorts before "Rev 10"). Non-mutating — sort copies at render.
function cmpName(a, b) { return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); }
function sortedFolders(list) { return (list || []).slice().sort(function (a, b) { return cmpName(a.name, b.name); }); }
function sortedFiles(list) {
return (list || []).slice().sort(function (a, b) {
return cmpName(window.zddc.joinExtension(a.originalFilename, a.extension), window.zddc.joinExtension(b.originalFilename, b.extension));
});
}
// ── Classify & Copy helpers ────────────────────────────────────────────
function classifyOn() {
var c = window.app.modules.classify;
@ -69,8 +58,8 @@
var tt = window.app.modules.targetTree;
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
}
function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId'; }
// Bucket a file relative to the active axis (tracking | transmittal):
function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : ax === 'mdl' ? 'mdlNodeId' : 'trackingNodeId'; }
// Bucket a file relative to the active axis (tracking | transmittal | mdl):
// 'excluded' | 'assigned' (on this axis) | 'partial' (assigned on a DIFFERENT
// axis only — the to-do for this tab) | 'unassigned' (no axis).
function fileCategory(file) {
@ -79,7 +68,7 @@
if (a && a.excluded) return 'excluded';
var ax = activeAxis();
if (a && a[axisField(ax)]) return 'assigned';
var others = ['tracking', 'transmittal'].filter(function (x) { return x !== ax; });
var others = ['tracking', 'transmittal', 'mdl'].filter(function (x) { return x !== ax; });
var any = a && others.some(function (x) { return a[axisField(x)]; });
return any ? 'partial' : 'unassigned';
}
@ -111,40 +100,42 @@
var visible = null; // { folders, files } while filtering, else null
function computeVisible() {
var c = window.app.modules.classify;
var folders = Object.create(null), files = Object.create(null), counts = Object.create(null);
var folders = Object.create(null), files = Object.create(null), open = Object.create(null);
var nf = filterActive();
function walk(folder, ancMatched) {
var selfMatch = nf && nameHit(folder.path || folder.name);
var matched = ancMatched || selfMatch;
var show = false, hasFile = false, descMatch = false;
// Post-filter counts for the row's "direct+total" badge: direct =
// immediate visible children/files, total = visible across the subtree.
var dDir = 0, tDir = 0, dFile = 0, tFile = 0;
(folder.children || []).forEach(function (ch) {
var r = walk(ch, matched);
if (r.show) { show = true; dDir++; tDir += 1 + r.tDir; }
if (r.show) show = true;
if (r.hasFile) hasFile = true;
if (r.subtreeMatch) descMatch = true; // a child leads to a match
tFile += r.tFile;
});
(folder.files || []).forEach(function (f) {
hasFile = true;
if (!classifyAllows(f)) return;
var fileMatch = nf && nameHit(c.srcKeyForFile(f));
if (!nf || matched || fileMatch) { files[c.srcKeyForFile(f)] = true; show = true; dFile++; }
if (!nf || matched || fileMatch) { files[c.srcKeyForFile(f)] = true; show = true; }
if (fileMatch) descMatch = true; // a match sits directly in this folder
});
tFile += dFile;
if (matched) show = true;
// "Show Empty" off → hide folders whose whole subtree holds no files.
if (!hasFile && !showEmpty && !matched) show = false;
if (show) folders[folder.path] = true;
counts[folder.path] = { dDir: dDir, tDir: tDir, dFile: dFile, tFile: tFile };
return { show: show, hasFile: hasFile, subtreeMatch: descMatch || selfMatch, tDir: tDir, tFile: tFile };
// Auto-open ONLY the connector folders on the path down to a match —
// never the matched node itself. Terminal matches and everything
// off-path keep their real collapse state; the root's expand-all
// covers the rest. (Search reveals where hits are; it doesn't reshape
// the tree.)
if (nf && descMatch) open[folder.path] = true;
return { show: show, hasFile: hasFile, subtreeMatch: descMatch || selfMatch };
}
(window.app.folderTree || []).forEach(function (root) { walk(root, false); });
return { folders: folders, files: files, counts: counts };
return { folders: folders, files: files, open: open };
}
// True only for folders the search needs opened to expose a hit beneath them.
function autoOpen(folder) { return !!(visible && visible.open && visible.open[folder.path]); }
function folderShown(folder) { return !visible || !!visible.folders[folder.path]; }
function fileShown(file) {
if (!classifyAllows(file)) return false;
@ -184,7 +175,7 @@
return;
}
sortedFolders(window.app.folderTree).forEach(folder => {
window.app.folderTree.forEach(folder => {
if (!folderShown(folder)) return;
const element = createFolderElement(folder);
container.appendChild(element);
@ -217,13 +208,8 @@
const done = st === 'done';
// When fully scanned both numbers are blue; .done turns the labels blue too.
if (done) el.classList.add('done');
// While a filter (autofilter or a Show checkbox) is narrowing the tree,
// the badge counts what's VISIBLE; otherwise the raw scanned totals.
const vc = visible && visible.counts && visible.counts[folder.path];
const dDir = vc ? vc.dDir : (folder.subdirCount || 0);
const tDir = vc ? vc.tDir : (folder.runDirs || 0);
const dFile = vc ? vc.dFile : (folder.fileCount || 0);
const tFile = vc ? vc.tFile : (folder.runFiles || 0);
const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
const frag = document.createDocumentFragment();
frag.appendChild(document.createTextNode('('));
@ -305,7 +291,7 @@
// expandable so its files can be revealed and dragged.
|| (classifyOn() && folder.files && folder.files.length > 0);
if (mightHaveChildren) {
toggle.textContent = folder.expanded ? '▼' : '▶';
toggle.textContent = (folder.expanded || autoOpen(folder)) ? '▼' : '▶';
toggle.addEventListener('click', (e) => {
e.stopPropagation();
const recursive = e.ctrlKey || e.metaKey;
@ -336,24 +322,18 @@
if (agg === 'excluded') item.classList.add('excluded');
}
// Name + counts stacked vertically: the count badge sits BELOW the name
// rather than right-aligned on the row.
const namebox = document.createElement('div');
namebox.className = 'folder-namebox';
// Folder name
const name = document.createElement('span');
name.className = 'folder-name';
name.textContent = folder.name;
namebox.appendChild(name);
item.appendChild(name);
// Subfolder / file counts (immediate). Greyed via the row's .scanning
// class until the subtree is fully scanned.
const count = document.createElement('span');
count.className = 'folder-count';
populateCount(count, folder);
namebox.appendChild(count);
item.appendChild(namebox);
item.appendChild(count);
// Extract button for ZIP roots
if (folder.isZipRoot) {
@ -375,15 +355,12 @@
div.appendChild(item);
// Children render ONLY when the user has expanded this folder. The
// autofilter and Show toggles never change expand/collapse state — they
// hide/show rows in place. A collapsed folder stays collapsed even if it
// contains matches (it's still shown, so the user can open it); this lets
// you filter within one subtree without the rest expanding.
if (folder.expanded && folder.children && folder.children.length > 0) {
// Children — when expanded, or opened on the path to a search hit below.
// The Show toggles never force-expand; search opens only connector folders.
if ((folder.expanded || autoOpen(folder)) && folder.children && folder.children.length > 0) {
const childrenDiv = document.createElement('div');
childrenDiv.className = 'folder-children';
sortedFolders(folder.children).forEach(child => {
folder.children.forEach(child => {
if (!folderShown(child)) return;
const childElement = createFolderElement(child, level + 1);
childrenDiv.appendChild(childElement);
@ -391,12 +368,12 @@
div.appendChild(childrenDiv);
}
// Classify mode: list this folder's own files (draggable leaves) only
// when the user has expanded it (the filter never force-expands).
if (classifyOn() && folder.expanded && folder.files && folder.files.length > 0) {
// Classify mode: list this folder's own files (draggable leaves) when
// expanded (or opened to reveal a search hit), so they can be dropped.
if (classifyOn() && (folder.expanded || autoOpen(folder)) && folder.files && folder.files.length > 0) {
const filesDiv = document.createElement('div');
filesDiv.className = 'folder-children folder-files';
sortedFiles(folder.files).forEach(function (file) {
folder.files.forEach(function (file) {
if (!fileShown(file)) return;
filesDiv.appendChild(createFileElement(file, level + 1));
});

View file

@ -34,6 +34,7 @@
<button id="modeClassifyBtn" class="mode-btn active" title="Map files onto tracking numbers and transmittals, then copy renamed copies to an output directory — the source is never modified">Classify &amp; copy</button>
<button id="modeRenameBtn" class="mode-btn" title="Edit a spreadsheet and rename the files in place (edits the source)">Rename in place</button>
</div>
<button id="mdlInstantiateBtn" class="btn btn-secondary btn-sm" title="Populate a project's Master Deliverables List from its existing archive files (server)">⊞ MDL from archive</button>
<button id="workspacesBtn" class="btn btn-secondary btn-sm" title="Workspaces — open or create a classification project">≡ Workspaces</button>
<button id="connectDirBtn" class="btn btn-primary btn-sm" title="Connect this workspace's source directory to preview, copy, or finish scanning" hidden>⮷ Connect directory</button>
</div>
@ -158,17 +159,11 @@
<!-- Target Trees (Classify & Copy mode) — default view -->
<main class="target-pane" id="targetPane">
<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 drag onto a row under <em>From a list</em> (loaded from the archive/MDL or pasted from Excel) — then route it under <em>By transmittal</em>.</p>
<div class="pane-header">
<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="worklistTab" role="tab" title="Drag files onto a list of tracking numbers — loaded from the archive/MDL, pasted from Excel, or auto-matched by name.">From a list</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>
</div>
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
<button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
<button class="target-tab" id="mdlTab" role="tab">By MDL</button>
</div>
<div class="pane-header-right">
<span id="classifyStats" class="file-stats"></span>
@ -184,11 +179,11 @@
<div class="target-body">
<section id="trackingPanel" class="target-panel">
<div class="target-panel__toolbar">
<button id="trackingColsBtn" class="btn btn-sm btn-secondary" title="Show or hide columns">Columns ▾</button>
<span class="target-hint">Drag files in, then type each ones tracking number, revision (e.g. “A (IFR)”), and title. A file thats already ZDDC-named fills in automatically. Columns are hideable + resizable.</span>
<button id="addTrackingRootBtn" class="btn btn-sm btn-secondary">+ Root folder</button>
<span class="target-hint">Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”.</span>
</div>
<input type="search" id="trackingFilterInput" class="tree-filter target-filter" spellcheck="false"
placeholder="Filter the grid…" aria-label="Filter the tracking grid">
placeholder="Filter the tracking tree…" aria-label="Filter tracking tree">
<div id="trackingTree" class="target-tree"></div>
</section>
<section id="transmittalPanel" class="target-panel" hidden>
@ -200,21 +195,12 @@
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
<div id="transmittalTree" class="target-tree"></div>
</section>
<!-- "From a list": a scratch worklist of tracking numbers — Load them
from the archive/MDL, Paste rows from Excel, or ⚡ Match by name.
Dragging a file onto a row MATERIALIZES a real "By tracking number"
placement, so Clear keeps every assignment. The left filetree is
the drag source. -->
<section id="worklistPanel" class="target-panel" hidden>
<section id="mdlPanel" class="target-panel" hidden>
<div class="target-panel__toolbar">
<button id="loadWorklistBtn" class="btn btn-sm btn-secondary" title="Add tracking numbers from the project archive/MDL (pick directories to scan).">⊞ Load…</button>
<button id="pasteRowsBtn" class="btn btn-sm btn-secondary" title="Paste rows from Excel: Tracking · Rev (Status) · Title.">⎘ Paste rows…</button>
<button id="matchNamesBtn" class="btn btn-sm btn-secondary" title="Auto-suggest assignments by matching unassigned filenames against the list.">⚡ Match names</button>
<button id="clearListBtn" class="btn btn-sm btn-secondary" title="Empty the list. Every assignment is kept — see By tracking number.">Clear list</button>
<label class="target-toggle" title="Hide rows that already have files assigned."><input type="checkbox" id="hideAssignedToggle"> Hide assigned</label>
<span class="target-hint">Drag files onto a row to name them; edit the Tracking number / Revision inline (ctrl-shift + ctrl-Enter sets many). Clearing the list keeps every assignment.</span>
<button id="loadMdlBtn" class="btn btn-sm btn-secondary">⊞ Load MDL…</button>
<span class="target-hint">Deliverables become drop targets — set a revision, then drag files on. Ctrl-shift select rows + ctrl-Enter to set a revision on many at once.</span>
</div>
<div id="worklistTable" class="target-tree"></div>
<div id="mdlTree" class="target-tree"></div>
</section>
</div>
</main>

View file

@ -472,21 +472,6 @@ body {
text-decoration: underline;
}
/* Subtle standalone-tools footer. */
.landing-apps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin: 2rem auto 1rem;
padding: 0 1rem;
max-width: 60rem;
font-size: 0.82rem;
color: var(--text-muted, #6b7280);
}
.landing-apps__label { color: var(--text-muted, #6b7280); }
.landing-apps .browse-link { margin-top: 0; }
#projectView ol {
padding-left: 1.5rem;
}

View file

@ -195,15 +195,6 @@
</div><!-- /projectView -->
</main>
<!-- Subtle links to the standalone tools (run against your own local files,
no server). Served at /_apps/<tool>.html by zddc-server. -->
<footer class="landing-apps">
<span class="landing-apps__label">Standalone tools for your own files:</span>
<a class="browse-link" href="/_apps/browse.html">Browse</a>
<a class="browse-link" href="/_apps/archive.html">Archive</a>
<a class="browse-link" href="/_apps/classifier.html">Classifier</a>
</footer>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">

View file

@ -95,14 +95,6 @@ export default defineConfig({
name: 'tables',
testMatch: 'tables.spec.js',
},
{
name: 'cap',
testMatch: 'cap.spec.js',
},
{
name: 'tables-mdl',
testMatch: 'tables-mdl.spec.js',
},
{
name: 'zddc-filter',
testMatch: 'zddc-filter.spec.js',

View file

@ -98,48 +98,6 @@
a: 'edit access rules'
};
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
@ -153,9 +111,8 @@
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
var body = null;
try {
body = await resp.clone().json();
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
@ -170,16 +127,6 @@
msg = prefix + 'Forbidden.';
}
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
@ -212,5 +159,5 @@
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();

View file

@ -1,47 +0,0 @@
/* Shared selectable + autofilter table (seltable) + its hosting overlay
Used by the tables tool's "Add from archive". The classifier carries an
equivalent copy inline in its layout.css for the catalog. */
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
.seltable__table thead th {
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
.seltable__table thead tr.seltable__filters th { top: 1.55rem; padding: 0.15rem 0.35rem; }
.seltable__colfilter {
width: 100%; min-width: 2rem; box-sizing: border-box; padding: 0.15rem 0.35rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text); font-size: 0.74rem; font-weight: 400; text-transform: none; letter-spacing: 0;
}
.seltable__row { cursor: pointer; user-select: none; }
.seltable__row:hover { background: var(--bg-hover); }
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
/* ── "Add deliverables from archive" overlay (project MDL rollup) ─────────── */
.mdlarch-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center; padding: 1.5rem;
}
.mdlarch-overlay__box {
display: flex; flex-direction: column; min-height: 0;
width: min(960px, 95vw); height: min(80vh, 760px);
background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.mdlarch-overlay__head { display: flex; align-items: center; gap: 0.75rem; padding: 0.85rem 1.1rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.mdlarch-overlay__head h2 { margin: 0; font-size: 1.05rem; flex: 1; }
.mdlarch-overlay__close { border: none; background: none; color: var(--text-muted); font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.25rem; }
.mdlarch-overlay__close:hover { color: var(--text); }
.mdlarch-overlay__status { padding: 0.5rem 1.1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.mdlarch-overlay__table { flex: 1; min-height: 0; display: flex; }
.mdlarch-overlay__table .seltable { height: 100%; flex: 1; }
.mdlarch-overlay__foot { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0.75rem 1.1rem; border-top: 1px solid var(--border); flex: 0 0 auto; }

View file

@ -25,7 +25,6 @@ concat_files \
"../shared/profile-menu.css" \
"../shared/logo.css" \
"../shared/context-menu.css" \
"../shared/seltable.css" \
"css/table.css" \
"../form/css/form.css" \
> "$css_temp"
@ -47,7 +46,6 @@ concat_files \
"../shared/profile-menu.js" \
"../shared/cap.js" \
"../shared/context-menu.js" \
"../shared/seltable.js" \
"js/mode.js" \
"js/app.js" \
"js/context.js" \
@ -63,7 +61,6 @@ concat_files \
"js/export.js" \
"js/render.js" \
"js/api-actions.js" \
"js/mdl-from-archive.js" \
"js/main.js" \
"../form/js/app.js" \
"../form/js/context.js" \

View file

@ -152,9 +152,7 @@
if (verbs.indexOf('c') === -1) {
addRowBtn.classList.add('is-disabled');
addRowBtn.setAttribute('aria-disabled', 'true');
// Tell them who can (subtly): role-first text + people in the tooltip.
var hint = window.zddc.cap.denyHint ? window.zddc.cap.denyHint(view, 'c') : null;
addRowBtn.title = hint ? (hint.text + (hint.title ? ' (' + hint.title + ')' : '')) : "You don't have create access in this folder.";
addRowBtn.title = "You don't have create access in this folder.";
// Swallow clicks so the no-op feedback is the
// tooltip, not a 403 toast on submission.
addRowBtn.addEventListener('click', function (ev) {
@ -169,11 +167,6 @@
}
}
// "Add from archive" — shown only on the project MDL rollup (own gating).
if (app.modules.mdlFromArchive && app.modules.mdlFromArchive.setup) {
app.modules.mdlFromArchive.setup(ctx);
}
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];

View file

@ -1,184 +0,0 @@
// mdl-from-archive.js — "Add from archive" for the project MDL rollup.
//
// The MDL owns the workflow of registering deliverables; this is the catch-up
// path. On the project rollup (<project>/mdl/), walk the project archive into a
// shared seltable (autofilter + ctrl-shift selection), dedupe the selection to
// one deliverable per tracking number, and PUT a deliverable .yaml into each
// originator's archive/<originator>/mdl/. The body's identity fields are split
// from the tracking number positionally per the project's own table columns
// (originator is folder-pinned, so omitted); the server composes/validates the
// filename. Server-only.
(function (app) {
'use strict';
function T(m, l, o) { if (window.zddc && window.zddc.toast) window.zddc.toast(m, l, o); }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function ctxObj() { return (app && app.context) || {}; }
// The tracking-number identity fields, in order, from the table columns:
// everything between `originator` and `title` (e.g. phase, project, area,
// discipline, type, sequence, suffix). originator is folder-pinned.
function identityFields() {
var cols = (ctxObj().columns || []).map(function (c) { return c && c.field; }).filter(Boolean);
var oi = cols.indexOf('originator'), ti = cols.indexOf('title');
return cols.slice(oi >= 0 ? oi + 1 : 0, ti >= 0 ? ti : cols.length);
}
// tracking → { tracking, originator, body{identity fields + title} }, or null
// if it can't supply the originator + at least one identity segment.
function deliverableFromFile(f, idFields) {
var segs = String(f.tracking || '').split('-');
if (segs.length < 2) return null;
var rest = segs.slice(1), body = {};
idFields.forEach(function (name, i) { if (rest[i] != null && rest[i] !== '') body[name] = rest[i]; });
if (!Object.keys(body).length) return null;
body.title = f.title || '';
return { tracking: f.tracking, originator: segs[0], body: body };
}
function dedupe(files, idFields) {
var seen = Object.create(null), out = [];
(files || []).forEach(function (f) {
if (seen[f.tracking]) return;
var d = deliverableFromFile(f, idFields);
if (d) { seen[f.tracking] = true; out.push(d); }
});
return out;
}
async function walkArchive(rootHandle) {
var out = [];
async function walk(dirH, parts) {
for await (var entry of dirH.values()) {
var nm = String(entry.name || '').replace(/\/$/, '');
if (entry.kind === 'directory') {
var c = nm.charAt(0);
if (c === '.' || c === '_' || nm === 'mdl' || nm === 'rsk') continue;
await walk(await dirH.getDirectoryHandle(nm), parts.concat(nm));
} else {
var p = window.zddc.parseFilename(nm);
if (p && p.valid && p.trackingNumber) {
out.push({
id: parts.concat(nm).join('/'),
party: parts[0] || '', slot: parts[1] || '', transmittal: parts[2] || '',
tracking: p.trackingNumber, revision: p.revision, status: p.status, title: p.title,
});
}
}
}
}
await walk(rootHandle, []);
return out;
}
async function instantiateOne(archiveRoot, d) {
var dir = await archiveRoot.getDirectoryHandle(d.originator, { create: true });
dir = await dir.getDirectoryHandle('mdl', { create: true });
var fname = d.tracking + '.yaml';
try { await dir.getFileHandle(fname); return 'skipped'; } catch (e) { /* NotFound → create */ }
var fh = await dir.getFileHandle(fname, { create: true });
var w = await fh.createWritable();
await w.write(new Blob([window.jsyaml.dump(d.body)], { type: 'application/yaml' }));
await w.close();
return 'created';
}
// ── UI ───────────────────────────────────────────────────────────────────
var overlay = null, statusEl = null, table = null, files = [], archiveRoot = null;
function close() { if (overlay) { overlay.remove(); overlay = null; table = null; } }
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
function archiveBaseUrl() {
var proj = (location.pathname || '/').replace(/\/mdl\/.*$/, '/'); // <project>/
return location.origin + proj + 'archive/';
}
async function open() {
var src = window.zddc && window.zddc.source;
if (!src || (location.protocol !== 'http:' && location.protocol !== 'https:')) {
T('Adding from the archive needs the tables page served by a zddc-server.', 'error'); return;
}
buildOverlay();
try {
archiveRoot = new src.HttpDirectoryHandle(archiveBaseUrl(), 'archive');
setStatus('Scanning archive…');
files = await walkArchive(archiveRoot);
table.renderBody();
setStatus(files.length + ' document file' + (files.length === 1 ? '' : 's') + ' found. Filter + ctrl-shift select, then “Create deliverables”.');
} catch (e) { setStatus('Archive scan failed — ' + (e.message || e)); T('Archive scan failed — ' + (e.message || e), 'error'); }
}
function buildOverlay() {
close();
overlay = el('div', 'mdlarch-overlay');
var box = el('div', 'mdlarch-overlay__box');
var head = el('div', 'mdlarch-overlay__head');
head.appendChild(el('h2', null, 'Add deliverables from archive'));
var x = el('button', 'mdlarch-overlay__close', '×'); x.title = 'Close'; x.addEventListener('click', close);
head.appendChild(x); box.appendChild(head);
statusEl = el('div', 'mdlarch-overlay__status', 'Scanning archive…'); box.appendChild(statusEl);
var host = el('div', 'mdlarch-overlay__table'); box.appendChild(host);
var foot = el('div', 'mdlarch-overlay__foot');
var create = el('button', 'btn btn-primary', 'Create deliverables');
create.addEventListener('click', function () { runCreate(create); });
var cancel = el('button', 'btn btn-secondary', 'Close'); cancel.addEventListener('click', close);
foot.appendChild(create); foot.appendChild(cancel); box.appendChild(foot);
overlay.appendChild(box); document.body.appendChild(overlay);
table = window.app.modules.seltable.create({
container: host,
extraTitle: '',
rows: function () { return files; },
rowId: function (r) { return r.id; },
columns: [
{ key: 'party', title: 'Party' },
{ key: 'slot', title: 'Slot' },
{ key: 'transmittal', title: 'Transmittal' },
{ key: 'tracking', title: 'Tracking number' },
{ key: 'revision', title: 'Rev', get: function (r) { return r.revision + (r.status ? ' (' + r.status + ')' : ''); } },
{ key: 'title', title: 'Title' },
],
});
table.render();
}
async function runCreate(btn) {
if (!table) return;
var sel = table.getSelection();
if (!sel.length) { T('Select some archive files first (filter + ctrl-shift).', 'warning'); return; }
var picked = {}; sel.forEach(function (i) { picked[i] = true; });
var deliverables = dedupe(files.filter(function (f) { return picked[f.id]; }), identityFields());
if (!deliverables.length) { T('None of the selected files split into deliverable fields.', 'warning'); return; }
if (!confirm('Create ' + deliverables.length + ' deliverable(s) in the project MDL?\n\nOne .yaml per tracking number, in archive/<originator>/mdl/. Already-present ones are skipped.')) return;
btn.disabled = true;
var s = { created: 0, skipped: 0, errors: 0 };
for (var i = 0; i < deliverables.length; i++) {
setStatus('Creating ' + (i + 1) + '/' + deliverables.length + ' — ' + deliverables[i].tracking);
try { s[await instantiateOne(archiveRoot, deliverables[i])]++; }
catch (e) { s.errors++; T('Failed to create ' + deliverables[i].tracking + ' — ' + (e.message || e), 'error'); }
}
btn.disabled = false;
setStatus(s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '.');
T('MDL: ' + s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '. Reload to see them.', s.errors ? 'warning' : 'success');
}
// Show the toolbar button only on the project MDL rollup (addable:false +
// an mdl path), over http, gated on create permission. Called from main.js
// init once the context is known.
function setup(ctx) {
var btn = document.getElementById('table-add-from-archive');
if (!btn) return;
var onHttp = location.protocol === 'http:' || location.protocol === 'https:';
var isMdlRollup = ctx && ctx.addable === false && /\/mdl\/(table\.html)?$/.test(location.pathname || '');
if (!(onHttp && isMdlRollup)) return;
btn.hidden = false;
btn.addEventListener('click', open);
if (window.zddc && window.zddc.cap) {
window.zddc.cap.at(archiveBaseUrl().replace(location.origin, '')).then(function (view) {
var verbs = (view && view.path_verbs) || '';
if (verbs.indexOf('c') === -1) { btn.classList.add('is-disabled'); btn.title = "You don't have create access in this project's archive."; }
});
}
}
app.modules.mdlFromArchive = {
setup: setup, open: open,
// test seams
identityFields: identityFields, deliverableFromFile: deliverableFromFile,
dedupe: dedupe, walkArchive: walkArchive, instantiateOne: instantiateOne,
};
})(window.tablesApp);

View file

@ -43,7 +43,6 @@
<div class="table-toolbar__right">
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
<button type="button" id="table-add-from-archive" class="btn btn-secondary btn-sm" hidden title="Register deliverables from existing archive files (project MDL rollup)"> From archive</button>
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div>
</div>

View file

@ -271,10 +271,11 @@ test.describe('Browse menu — context & tiers', () => {
expect(res.rwd).toContain('Delete…');
});
// New folder / New file are not toolbar buttons — they live in the
// row/pane context menu (see the "keyboard menu key and kebab" test).
test('toolbar Sort and Show-hidden drive state', async ({ page }) => {
test('toolbar Sort and Show-hidden drive state; New buttons present', async ({ page }) => {
await openWithTree(page);
await expect(page.locator('#newFolderBtn')).toBeVisible();
await expect(page.locator('#newFileBtn')).toBeVisible();
await page.locator('#sortSelect').selectOption('date:-1');
expect(await page.evaluate(() => window.app.state.sort)).toEqual({ key: 'date', dir: -1 });

View file

@ -1,66 +0,0 @@
import { test, expect } from '@playwright/test';
import * as path from 'path';
// shared/cap.js — the "who can?" helpers (denyHint / whoCan) + handleForbidden
// enrichment. cap.js is bundled into every server-mode tool; tables.html is a
// convenient host. Pure helpers run fine on a file:// page (cap.at short-circuits
// offline, but denyHint/whoCan/handleForbidden don't need the network).
const HOST = 'file://' + path.resolve('tables/dist/tables.html');
async function load(page) {
await page.goto(HOST, { waitUntil: 'load' });
await page.waitForFunction(() => window.zddc && window.zddc.cap && window.zddc.cap.denyHint);
}
test.describe('cap.js — who-can hints', () => {
test('denyHint is role-first with people as the tooltip detail', async ({ page }) => {
await load(page);
const h = await page.evaluate(() => {
const view = { path_who_can: { c: { roles: ['document_controller'], people: ['alice@example.com', 'bob@example.com'] } } };
return window.zddc.cap.denyHint(view, 'c');
});
expect(h.text).toBe('Only the document controller can create here.'); // role-first, humanized
expect(h.title).toBe('alice@example.com, bob@example.com'); // people in the tooltip
});
test('denyHint names people when no role grants the verb', async ({ page }) => {
await load(page);
const h = await page.evaluate(() =>
window.zddc.cap.denyHint({ path_who_can: { w: { people: ['sam@example.com'] } } }, 'w'));
expect(h.text).toBe('Ask sam@example.com to write here.');
});
test('denyHint falls back to "an administrator" when nobody is named', async ({ page }) => {
await load(page);
const h = await page.evaluate(() => window.zddc.cap.denyHint({ path_who_can: {} }, 'd'));
expect(h.text).toBe('Ask an administrator to delete here.');
});
test('whoCan reads either a path view or a 403 body', async ({ page }) => {
await load(page);
const r = await page.evaluate(() => {
const fromView = window.zddc.cap.whoCan({ path_who_can: { c: { roles: ['r1'] } } }, 'c');
const fromBody = window.zddc.cap.whoCan({ missing_verb: 'c', who_can: { roles: ['r2'] } }, 'c');
const miss = window.zddc.cap.whoCan({ path_who_can: { w: {} } }, 'c');
return { fromView: fromView && fromView.roles[0], fromBody: fromBody && fromBody.roles[0], miss };
});
expect(r.fromView).toBe('r1');
expect(r.fromBody).toBe('r2');
expect(r.miss).toBeNull();
});
test('handleForbidden appends the who-can hint from the 403 body', async ({ page }) => {
await load(page);
const msg = await page.evaluate(async () => {
let captured = '';
window.zddc.toast = (m) => { captured = m; return document.createElement('div'); };
const body = JSON.stringify({ error: 'Forbidden', missing_verb: 'c', who_can: { roles: ['document_controller'], people: ['alice@example.com'] } });
const resp = new Response(body, { status: 403, headers: { 'Content-Type': 'application/json' } });
await window.zddc.cap.handleForbidden(resp, { context: 'Create' });
return captured;
});
expect(msg).toContain('You do not have create access here.');
expect(msg).toContain('Only the document controller can create here.'); // who-can appended
});
});

View file

@ -152,51 +152,49 @@ test('mode switch swaps the spreadsheet pane for the target pane', async ({ page
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false);
});
test('target tree renders the By-tracking grid and tabs switch', async ({ page }) => {
test('target tree renders structure and tabs switch', async ({ page }) => {
await page.click('#modeClassifyBtn');
await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-PROJ-EL-DWG-0001', rev: 'A (IFR)', title: 'Spec' });
const acme = c.addTrackingNode(null, 'ACME-PROJ');
c.addTrackingNode(acme, 'A (IFR)');
const party = c.addParty('ClientCorp');
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
window.app.modules.targetTree.render();
});
// The grid shows the file's tracking number in an editable cell.
await expect(page.locator('#trackingTree .ttable--grid')).toBeVisible();
await expect(page.locator('#trackingTree .tg-tn .tg-input')).toHaveValue('ACME-PROJ-EL-DWG-0001');
// Tracking panel visible by default with the table rendered.
await expect(page.locator('#trackingTree .ttable__cell .tcell__name', { hasText: 'ACME-PROJ' })).toBeVisible();
await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: 'A (IFR)' })).toBeVisible();
// Switch to transmittal tab.
await page.click('#transmittalTab');
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
await expect(page.locator('#transmittalTree .tnode--bin .tnode__name', { hasText: 'ClientCorp-TRN-0007' })).toBeVisible();
});
test('"+ Root folder" button (prompt) parses a name into nested levels', async ({ page }) => {
await page.click('#modeClassifyBtn');
page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)'));
await page.click('#addTrackingRootBtn');
// "CPO-0001_0 (IFU)" → CPO / 0001 columns + "0 (IFU)" revision cell.
await expect(page.locator('#trackingTree .tcell__name', { hasText: 'CPO' })).toBeVisible();
await expect(page.locator('#trackingTree .tcell__name', { hasText: '0001' })).toBeVisible();
await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: '0 (IFU)' })).toBeVisible();
});
// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
test('dropping files onto the By-tracking grid adds rows and auto-fills ZDDC names', async ({ page }) => {
test('dropping a file onto a tracking leaf assigns it', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify; c.reset();
const plain = { originalFilename: 'messy scan', extension: 'pdf', folderPath: 'R' };
const named = { originalFilename: 'ACME-MECH-0001_A (IFR) - Pump', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [plain, named], children: [] }];
const tt = window.app.modules.targetTree; tt.render();
const grid = document.querySelector('#trackingTree');
function drop(key) { window.app.modules.dnd.setDrag([key]); grid.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); }
drop(c.srcKeyForFile(plain));
drop(c.srcKeyForFile(named));
return {
rows: c.trackingGridKeys().length,
namedTn: c.deriveTarget(named).tracking, namedRev: c.deriveTarget(named).revision,
plainTn: c.deriveTarget(plain).tracking,
};
const c = window.app.modules.classify;
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
window.app.modules.targetTree.render();
const row = document.querySelector('#trackingTree .ttable__rev[data-id]');
const key = 'Sub/foundation.pdf';
window.app.modules.dnd.setDrag([key]);
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
return { assigned: c.assignmentFor(key).trackingNodeId, leaf };
});
expect(r.rows).toBe(2); // both files added as rows
expect(r.namedTn).toBe('ACME-MECH-0001'); // the ZDDC-named file auto-filled
expect(r.namedRev).toBe('A');
expect(r.plainTn).toBe(''); // the plain file is a blank row to fill in
expect(r.assigned).toBe(r.leaf);
});
test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => {
@ -249,33 +247,6 @@ test('source file rows render with a state dot in classify mode', async ({ page
await expect(page.locator('#folderTree .file-item .cl-dot--none')).toBeVisible();
});
test('Folder Tree renders folders and files in natural, case-insensitive order', async ({ page }) => {
await page.click('#modeClassifyBtn');
const order = await page.evaluate(() => {
window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
children: [
{ name: 'Beta', path: 'Root/Beta', scanState: 'done', children: [], files: [] },
{ name: 'alpha', path: 'Root/alpha', scanState: 'done', children: [], files: [] },
{ name: 'Rev 10', path: 'Root/Rev 10', scanState: 'done', children: [], files: [] },
{ name: 'Rev 2', path: 'Root/Rev 2', scanState: 'done', children: [], files: [] },
],
files: [
{ originalFilename: 'zeta', extension: 'pdf', folderPath: 'Root' },
{ originalFilename: 'Apple', extension: 'pdf', folderPath: 'Root' },
{ originalFilename: 'banana', extension: 'pdf', folderPath: 'Root' },
],
}];
window.app.modules.tree.render();
return {
folders: Array.from(document.querySelectorAll('#folderTree .folder-children .folder-name')).map(e => e.textContent.trim()),
files: Array.from(document.querySelectorAll('#folderTree .folder-files .file-name')).map(e => e.textContent.trim()),
};
});
expect(order.folders).toEqual(['alpha', 'Beta', 'Rev 2', 'Rev 10']); // case-insensitive + natural (2 < 10)
expect(order.files).toEqual(['Apple.pdf', 'banana.pdf', 'zeta.pdf']); // case-insensitive
});
test('classify: single-click a source file triggers preview', async ({ page }) => {
await page.click('#modeClassifyBtn');
const previewed = await page.evaluate(() => {
@ -665,26 +636,24 @@ test('trackingNodeComplete: true only for a leaf with a valid status', async ({
expect(r).toEqual({ root: false, num: false, leaf: true, bare: false });
});
test('editing grid cells re-files the file onto the new tracking path', async ({ page }) => {
test('editing a placed files filename re-files it onto the parsed tracking path', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
const c = window.app.modules.classify;
c.reset();
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'OLD'), 'A (IFR)');
const file = { folderPath: 'Root/Sub', originalFilename: 'doc', extension: 'pdf' };
const key = c.srcKeyForFile(file);
const leaf = c.addTrackingPath(null, c.parseFolderLevels('OLD-0001_A (IFR)'));
c.place([key], leaf, 'tracking');
window.app.folderTree = [{ name: 'Sub', path: 'Root/Sub', expanded: true, scanState: 'done', children: [], files: [file] }];
function editCell(cls, val) {
tt.render(); // re-render so we edit the live input each time
const inp = document.querySelector('#trackingTree .' + cls + ' .tg-input');
inp.value = val; inp.dispatchEvent(new Event('change', { bubbles: true }));
}
editCell('tg-tn', 'CPO-0002');
editCell('tg-rev', '0 (IFU)');
editCell('tg-title', 'New Title');
window.app.folderTree = [{
name: 'Sub', path: 'Sub', expanded: true, scanState: 'done', children: [], files: [file],
}];
window.app.modules.targetTree.render();
const input = document.querySelector('#trackingTree .tfile__name');
input.value = 'CPO-0002_0 (IFU) - New Title.pdf';
input.dispatchEvent(new Event('change', { bubbles: true }));
const d = c.deriveTarget(file);
return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title };
return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title, complete: d.complete };
});
expect(r.tracking).toBe('CPO-0002');
expect(r.revision).toBe('0');
@ -733,53 +702,45 @@ test('dataset (filename-based): import reconstruction rebuilds tracking + shared
expect(r.excluded).toBe(true);
});
test('source-tree filter hides non-matches in place; never changes expand state', async ({ page }) => {
test('source-tree filter reveals matches with their folder hierarchy', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [{
name: 'Project', path: 'Project', expanded: true, scanState: 'done', files: [], children: [
// EXPANDED: its match shows in place, the non-match is hidden.
{ name: 'Electrical', path: 'Project/Electrical', expanded: true, scanState: 'done', children: [], files: [
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
{ name: 'Electrical', path: 'Project/Electrical', expanded: false, scanState: 'done', children: [], files: [
{ originalFilename: 'Master Deliverables List', extension: 'xlsx', folderPath: 'Project/Electrical' },
{ originalFilename: 'Switchgear Spec', extension: 'pdf', folderPath: 'Project/Electrical' },
] },
// COLLAPSED but ALSO holds a match — it must stay collapsed (shown
// as a row, file NOT revealed): the filter never auto-expands.
{ name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [
{ originalFilename: 'master deliverables draft', extension: 'pdf', folderPath: 'Project/Civil' },
{ originalFilename: 'Site Plan', extension: 'pdf', folderPath: 'Project/Civil' },
] },
],
}];
window.app.modules.tree.render();
window.app.modules.tree.setNameFilter('master deliverables');
const civil = window.app.folderTree[0].children[1];
return {
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent),
folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path).sort(),
civilStillCollapsed: civil.expanded === false,
folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path),
};
});
expect(r.files).toEqual(['Master Deliverables List.xlsx']); // expanded folder: match in place, Switchgear hidden
expect(r.folders).toEqual(['Project', 'Project/Civil', 'Project/Electrical']); // Civil shown (has a match) but collapsed
expect(r.civilStillCollapsed).toBe(true); // the filter did NOT expand it
expect(r.files).toEqual(['Master Deliverables List.xlsx']); // only the match shown
expect(r.folders).toEqual(['Project', 'Project/Electrical']); // path revealed; Civil hidden
});
test('the By-tracking grid filter narrows rows by name/tracking', async ({ page }) => {
test('tracking-tree filter reveals matching nodes and hides the rest', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
const names = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const a = { originalFilename: 'pump', extension: 'pdf', folderPath: 'R' };
const b = { originalFilename: 'valve', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [a, b], children: [] }];
c.setFileIdentity(c.srcKeyForFile(a), { tracking: 'CPO-0001', rev: 'A (IFR)', title: 'Pump' });
c.setFileIdentity(c.srcKeyForFile(b), { tracking: 'XYZ-0009', rev: 'A (IFR)', title: 'Valve' });
tt.render();
tt.setNameFilter('CPO');
return Array.from(document.querySelectorAll('#trackingTree .tg-tn .tg-input')).map((e) => e.value);
c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)'));
c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)'));
window.app.modules.targetTree.render();
window.app.modules.targetTree.setNameFilter('CPO');
return Array.from(document.querySelectorAll('#trackingTree .tcell__name')).map((e) => e.textContent);
});
expect(r).toContain('CPO-0001');
expect(r).not.toContain('XYZ-0009');
expect(names).toContain('CPO');
expect(names).toContain('0001');
expect(names).not.toContain('XYZ');
});
test('Show Empty off hides folders that contain no files', async ({ page }) => {
@ -815,19 +776,18 @@ test('toggling a Show filter preserves collapse state (no force-expand)', async
// A Show toggle must not expand the collapsed parent…
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false });
const afterToggle = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
// …and neither does the name filter — it hides/shows in place, never expands.
// …whereas a name search still reveals the match by auto-expanding.
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: true });
tree.setNameFilter('a.pdf');
const afterSearch = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
return { collapsed, afterToggle, afterSearch, parentCollapsed: window.app.folderTree[0].expanded === false };
return { collapsed, afterToggle, afterSearch };
});
expect(r.collapsed).toEqual(['Project']); // child hidden — parent collapsed
expect(r.afterToggle).toEqual(['Project']); // Show toggle leaves it collapsed
expect(r.afterSearch).toEqual(['Project']); // name filter leaves it collapsed (no force-expand)
expect(r.parentCollapsed).toBe(true); // expand state untouched
expect(r.afterSearch).toEqual(['Project', 'Project/Sub']); // name search auto-expands to the match
});
test('filter does not open collapsed branches; non-matching siblings hide', async ({ page }) => {
test('search opens only the branch with a hit, leaving siblings collapsed', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [{
@ -842,46 +802,15 @@ test('filter does not open collapsed branches; non-matching siblings hide', asyn
}];
const tree = window.app.modules.tree;
tree.render();
tree.setNameFilter('switchgear'); // a file deep in the (collapsed) Electrical branch
tree.setNameFilter('switchgear'); // a file deep in the Electrical branch
return {
folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path),
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent),
};
});
// Project contains a match so it's shown — but stays COLLAPSED, so Electrical
// isn't rendered and the hit isn't revealed (the user expands to reach it).
// Civil has no match and is hidden.
expect(r.folders).toEqual(['Project']);
expect(r.files).toEqual([]);
});
test('folder count badge shows post-filter totals', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
subdirCount: 2, runDirs: 2, fileCount: 0, runFiles: 3, files: [], children: [
{ name: 'A', path: 'Root/A', expanded: true, scanState: 'done', subdirCount: 0, runDirs: 0, fileCount: 2, runFiles: 2, children: [], files: [
{ originalFilename: 'alpha report', extension: 'pdf', folderPath: 'Root/A' },
{ originalFilename: 'beta memo', extension: 'pdf', folderPath: 'Root/A' },
] },
{ name: 'B', path: 'Root/B', expanded: true, scanState: 'done', subdirCount: 0, runDirs: 0, fileCount: 1, runFiles: 1, children: [], files: [
{ originalFilename: 'gamma note', extension: 'pdf', folderPath: 'Root/B' },
] },
],
}];
const tree = window.app.modules.tree;
const rootCount = () => { const e = document.querySelector('#folderTree .folder-item .folder-count'); return e ? e.textContent : null; };
tree.render();
const before = rootCount(); // no filter → raw scan totals
tree.setNameFilter('alpha'); // matches one file, in folder A only
const after = rootCount();
return { before, after };
});
expect(r.before).toContain('2 folders'); // raw: 2 subfolders…
expect(r.before).toContain('0+3 files'); // …3 files in the subtree
expect(r.after).toContain('1 folder'); // filtered: only A is visible
expect(r.after).toContain('0+1 file'); // …holding the single matching file
// Path to the hit opens; the unrelated Civil sibling is not force-opened (stays out).
expect(r.folders).toEqual(['Project', 'Project/Electrical']);
expect(r.files).toEqual(['Switchgear Spec.pdf']);
});
test('snapshot: a scanned zip subtree round-trips with its virtual members', async ({ page }) => {
@ -1029,47 +958,51 @@ test('a fully-excluded folder is struck through like its files', async ({ page }
expect(r.after).toBe(true); // struck through once the whole subtree is excluded
});
test('grid: hiding a column drops its cells; a status badge reflects completeness', async ({ page }) => {
test('By-tracking table merges shared ancestors and aligns revisions', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset();
try { localStorage.removeItem('zddc.classifier.trackingCols'); } catch (_) {}
const f = { originalFilename: 'x', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-MECH-0001', rev: 'A (IFR)', title: 'X' });
c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_2025-11-17 (IFI)'));
c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_A (IFR)'));
c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)'));
tt.render();
const titleBefore = !!document.querySelector('#trackingTree .tg-title');
const badge = document.querySelector('#trackingTree .tg-status .tfile__badge');
// Hide the Title column via the persisted prefs, then re-render.
localStorage.setItem('zddc.classifier.trackingCols', JSON.stringify({ hidden: { title: true } }));
tt.render();
const titleAfter = !!document.querySelector('#trackingTree .tg-title');
try { localStorage.removeItem('zddc.classifier.trackingCols'); } catch (_) {}
return { titleBefore, titleAfter, badge: badge && badge.textContent };
const cellByName = (n) => Array.from(document.querySelectorAll('#trackingTree .ttable__cell .tcell__name'))
.filter((e) => e.textContent === n).map((e) => e.closest('td'))[0];
const lku = cellByName('LKU'), cpo = cellByName('CPO');
return {
lkuSpan: lku ? lku.rowSpan : 0,
cpoSpan: cpo ? cpo.rowSpan : 0,
revs: Array.from(document.querySelectorAll('#trackingTree .ttable__rev .tcell__name')).map((e) => e.textContent),
};
});
expect(r.titleBefore).toBe(true);
expect(r.titleAfter).toBe(false); // Title column hidden + persists across re-render
expect(r.badge).toBe('✓'); // complete (tracking + rev + status all set)
expect(r.lkuSpan).toBe(2); // the LKU ancestor cell spans its two revisions (merged)
expect(r.cpoSpan).toBe(1);
// The revisions live in one aligned column; the date revision stays intact.
expect(r.revs).toEqual(['2025-11-17 (IFI)', 'A (IFR)', '0 (IFU)']);
});
test('grid: the original-name cell is a preview link', async ({ page }) => {
test('revision cell links to preview its file and shows no count bubble', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
c.reset();
const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root' };
window.app.folderTree = [{ name: 'Root', path: 'Root', files: [f], children: [] }];
let previewed = null;
window.app.modules.preview.previewFile = (file) => { previewed = file.originalFilename; };
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-MECH-0001', rev: 'A (IFR)', title: 'Foundation' });
const leaf = c.addTrackingPath(null, c.parseFolderLevels('ACME-MECH-0001_A (IFR)'));
c.place([c.srcKeyForFile(f)], leaf, 'tracking');
tt.render();
const link = document.querySelector('#trackingTree .tg-orig .tg-orig__link');
if (link) link.click();
return { text: link && link.textContent, previewed };
const rev = document.querySelector('#trackingTree .ttable__rev');
const link = rev.querySelector('.tcell__preview[data-preview-key]');
return {
hasPreview: !!link,
previewKey: link && link.dataset.previewKey,
hasBadge: !!rev.querySelector('.tnode__badge'),
};
});
expect(r.text).toBe('foundation.pdf');
expect(r.previewed).toBe('foundation'); // clicking the name previews the file
expect(r.hasPreview).toBe(true); // revision name is a preview link
expect(r.previewKey).toBe('foundation.pdf');
expect(r.hasBadge).toBe(false); // no count bubble
});
test('Show Partial surfaces files assigned in the other tab only', async ({ page }) => {
@ -1283,7 +1216,69 @@ test('seltable: autofilter + ctrl-shift selection builds complex sets', async ({
expect(r.ctrlShiftRange).toBe('c,d'); // ctrl-shift range runs over the FILTERED order
});
test('From a list: a drop materializes a real tracking placement; row revision + transmittal complete it', async ({ page }) => {
test('mdl-instantiate: walks the archive subtree and dedupes to one deliverable per tracking number', async ({ page }) => {
const r = await page.evaluate(async () => {
const M = window.app.modules.mdlInstantiate;
const file = (name) => ({ kind: 'file', name });
const dir = (name, dirs, files) => ({
kind: 'directory', name,
getDirectoryHandle: async (n) => dirs[n],
values: async function* () { for (const d of Object.values(dirs)) yield d; for (const f of files) yield f; },
});
const T1 = dir('T1', {}, [
file('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'),
file('ACM-PRJ-EL-SPC-0001_B (IFC) - Spec.pdf'), // 2nd revision of same deliverable
file('ACM-PRJ-ME-DWG-0003_0 (IFC) - Plan.pdf'),
file('notes.txt'), // non-ZDDC → ignored
]);
const issued = dir('issued', { T1 }, []);
const mdl = dir('mdl', {}, [file('ACM-PRJ-EL-SPC-0001.yaml')]); // mdl/ skipped
const root = dir('archive', { ACM: dir('ACM', { issued, mdl }, []) }, []);
const files = await M.walkArchive(root);
const dd = M.dedupe(files);
const spc = dd.find((d) => d.tracking === 'ACM-PRJ-EL-SPC-0001');
return {
count: files.length, party: files[0].party, slot: files[0].slot, transmittal: files[0].transmittal,
deliverables: dd.map((d) => d.tracking).sort(),
originator: spc.originator, body: spc.body, hasOriginator: 'originator' in spc.body,
};
});
expect(r.count).toBe(3); // 2 SPC revisions + 1 DWG (txt + the mdl yaml ignored)
expect(r.party).toBe('ACM');
expect(r.slot).toBe('issued');
expect(r.transmittal).toBe('T1');
expect(r.deliverables).toEqual(['ACM-PRJ-EL-SPC-0001', 'ACM-PRJ-ME-DWG-0003']); // deduped by tracking number
expect(r.originator).toBe('ACM');
expect(r.body).toEqual({ project: 'PRJ', discipline: 'EL', type: 'SPC', sequence: '0001', title: 'Spec' });
expect(r.hasOriginator).toBe(false); // server pins originator from the folder
});
test('mdl-instantiate: writes the deliverable yaml then skips an existing one', async ({ page }) => {
const r = await page.evaluate(async () => {
const M = window.app.modules.mdlInstantiate;
const store = {};
const mkdir = (base) => ({
getDirectoryHandle: async (n) => mkdir(base + n + '/'),
getFileHandle: async (n, opts) => {
const full = base + n;
if ((!opts || !opts.create) && !(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; }
return { createWritable: async () => ({ write: async (b) => { store[full] = await b.text(); }, close: async () => {} }) };
},
});
const d = { tracking: 'ACM-PRJ-EL-SPC-0001', originator: 'ACM', body: { project: 'PRJ', discipline: 'EL', type: 'SPC', sequence: '0001', title: 'Spec' } };
const first = await M.instantiateOne(mkdir(''), d);
const root = mkdir(''); // fresh facade over the same store
const second = await M.instantiateOne(root, d);
const path = Object.keys(store)[0];
return { first, second, path, parsed: window.jsyaml.load(store[path]) };
});
expect(r.first).toBe('created');
expect(r.second).toBe('skipped'); // already present → resumable
expect(r.path).toBe('ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml');
expect(r.parsed).toEqual({ project: 'PRJ', discipline: 'EL', type: 'SPC', sequence: '0001', title: 'Spec' });
});
test('classify: an MDL placement names a file; revision from the cell, transmittal for the path', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
@ -1291,36 +1286,32 @@ test('From a list: a drop materializes a real tracking placement; row revision +
const f = { originalFilename: 'messy scan 47', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
c.setWorklist([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Switchgear Spec' }]);
c.assignFromRow([key], c.getWorklistRow('m1')); // blank revision → partial
const placedTracking = !!(c.getAssignment(key) || {}).trackingNodeId; // a REAL tracking placement
const beforeRev = c.deriveTarget(f);
c.setRevisionCell('m1', 'A (IFR)'); // re-stamps onto the leaf
const named = c.deriveTarget(f);
c.setMdlList([{ id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Switchgear Spec' }]);
c.place([key], 'm1', 'mdl');
const beforeRev = c.deriveTarget(f); // no revision yet
c.setRevisionCell('m1', 'A (IFR)');
const named = c.deriveTarget(f); // named, but no transmittal → not complete
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
c.place([key], bin, 'transmittal');
const full = c.deriveTarget(f);
c.setTitleOverride(key, ''); // use the file's own title instead
c.setTitleFromDeliverable(key, false); // use the file's own title instead
const fileTitle = c.deriveTarget(f);
return {
placedTracking, beforeRevErr: beforeRev.errors.length > 0,
beforeTracking: beforeRev.tracking,
beforeRevErr: beforeRev.errors.length > 0,
named: named.filename, namedComplete: named.complete,
fullName: full.filename, fullComplete: full.complete,
fileTitleName: fileTitle.filename,
};
});
expect(r.placedTracking).toBe(true); // not a separate axis — a tracking placement
expect(r.beforeTracking).toBe('ACM-PRJ-EL-SPC-0001'); // full tracking number preserved while rev pending
expect(r.beforeRevErr).toBe(true); // no revision yet → incomplete
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf');
expect(r.namedComplete).toBe(false); // still needs a transmittal
expect(r.beforeRevErr).toBe(true); // a deliverable with no revision can't name a file
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf'); // tracking+title from MDL, rev from cell
expect(r.namedComplete).toBe(false); // still needs a transmittal for the output path
expect(r.fullComplete).toBe(true);
expect(r.fullName).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf');
expect(r.fileTitleName).toContain('messy scan 47'); // title toggle → the file's own title
});
test('From a list: clearing the list keeps classifications; the row drives the seltable', async ({ page }) => {
test('By MDL tab: drop a file on a deliverable row names it; 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;
@ -1328,241 +1319,20 @@ test('From a list: clearing the list keeps classifications; the row drives the s
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
c.setWorklist([
{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'], revisionCell: 'C (IFC)' },
{ id: 'm2', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', archiveRevisions: ['0 (IFC)'] },
c.setMdlList([
{ id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec' },
{ id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2' },
]);
tt.showTab('worklist');
const row = document.querySelector('#worklistTable .seltable__row[data-id="m1"]');
const latestShown = !!row && row.textContent.includes('B (IFC)'); // latest archive rev shown
tt.showTab('mdl'); tt.render();
const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]');
const hasRow = !!row;
window.app.modules.dnd.setDrag([key]);
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop on m1 (rev C set)
const named = c.deriveTarget(f).filename;
c.clearWorklist(); // list emptied — assignment must survive
return {
hasRow: !!row, latestShown,
placedAfterDrop: !!(c.getAssignment(key) || {}).trackingNodeId,
named,
listLen: c.getWorklist().length,
stillPlaced: !!(c.getAssignment(key) || {}).trackingNodeId,
stillNamed: c.deriveTarget(f).filename,
};
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, placed, named: c.deriveTarget(f).filename };
});
expect(r.hasRow).toBe(true);
expect(r.latestShown).toBe(true);
expect(r.placedAfterDrop).toBe(true);
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_C (IFC) - Spec.pdf'); // tracking + row revision + title
expect(r.listLen).toBe(0); // list cleared
expect(r.stillPlaced).toBe(true); // classification survives the clear
expect(r.stillNamed).toBe('ACM-PRJ-EL-SPC-0001_C (IFC) - Spec.pdf');
});
test('From a list: editing the tracking number (bump sequence) re-stamps placed files', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const f = { originalFilename: 'plan', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
c.setWorklist([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-DWG-0007', title: 'Plan', revisionCell: 'A (IFR)' }]);
c.assignFromRow([key], c.getWorklistRow('m1'));
const before = c.deriveTarget(f).filename;
c.setRowTracking('m1', 'ACM-PRJ-EL-DWG-0008'); // it's the next drawing
const after = c.deriveTarget(f).filename;
// the old leaf chain should be pruned (no stray 0007 folder)
const roots = c.getTrackingTree();
const hasStale0007 = JSON.stringify(roots).indexOf('0007') !== -1;
return { before, after, hasStale0007 };
});
expect(r.before).toBe('ACM-PRJ-EL-DWG-0007_A (IFR) - Plan.pdf');
expect(r.after).toBe('ACM-PRJ-EL-DWG-0008_A (IFR) - Plan.pdf'); // file moved with the bump
expect(r.hasStale0007).toBe(false); // old leaf pruned
});
test('From a list: load() migrates a legacy mdlNodeId placement into a tracking placement', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const f = { originalFilename: 'old', extension: 'pdf', folderPath: 'R' };
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
const key = c.srcKeyForFile(f);
// A pre-"From a list" serialized workspace: the file points at an mdl row.
c.load({
assignments: { [key]: { mdlNodeId: 'old1', titleFromDeliverable: true, transmittalNodeId: null, excluded: false, titleOverride: null } },
worklist: [{ id: 'old1', trackingNumber: 'ACM-PRJ-EL-SPC-0009', title: 'Legacy', revisionCell: 'B (IFC)' }],
});
const a = c.getAssignment(key) || {};
return {
noMdlNodeId: !('mdlNodeId' in a),
hasTracking: !!a.trackingNodeId,
named: c.deriveTarget(f).filename,
};
});
expect(r.noMdlNodeId).toBe(true); // dead field dropped
expect(r.hasTracking).toBe(true); // materialized into the tracking tree
expect(r.named).toBe('ACM-PRJ-EL-SPC-0009_B (IFC) - Legacy.pdf'); // classification preserved
});
test('parsePastedRows: fixed columns tracking · rev · title · current name', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const text = [
'Tracking Number\tRev\tTitle\tCurrent name', // header → skipped
'ACM-PRJ-EL-SPC-0001\tA (IFR)\tFloor plan\tIMG_4471.pdf', // full 4 columns
'ACM-PRJ-EL-SPC-0002\tB (IFC)\tSection', // 3 cols → current name blank
'\tjust a rev\t', // no tracking → skipped
].join('\n');
return c.parsePastedRows(text);
});
expect(r.rows.map(x => x.trackingNumber)).toEqual(['ACM-PRJ-EL-SPC-0001', 'ACM-PRJ-EL-SPC-0002']);
expect(r.rows[0]).toMatchObject({ revisionCell: 'A (IFR)', title: 'Floor plan', currentName: 'IMG_4471.pdf' });
expect(r.rows[1].currentName).toBe(''); // omitted trailing column
expect(r.skipped.length).toBe(1); // the no-tracking row
});
test('proposeMatches: the current-name column drives exact (auto) + token matches', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const files = [
{ originalFilename: 'IMG_4471', extension: 'pdf', folderPath: 'R' }, // exact (case+ext+sep differ)
{ originalFilename: 'site-survey-final-v2', extension: 'docx', folderPath: 'R' }, // token coverage
{ originalFilename: 'totally unrelated', extension: 'pdf', folderPath: 'R' }, // no match
];
const rows = [
{ id: 'm1', trackingNumber: 'ACM-AR-DWG-0001', currentName: 'img_4471.PDF' },
{ id: 'm2', trackingNumber: 'ACM-AR-DWG-0002', currentName: 'Site Survey final' },
];
const m = c.proposeMatches(files, rows, {});
return Object.fromEntries(m.map(p => [p.file.originalFilename, { tn: p.row.trackingNumber, conf: p.confidence, via: p.via, auto: p.auto }]));
});
expect(r['IMG_4471']).toMatchObject({ tn: 'ACM-AR-DWG-0001', conf: 1, via: 'name', auto: true }); // exact 1:1 → auto
expect(r['site-survey-final-v2'].tn).toBe('ACM-AR-DWG-0002');
expect(r['site-survey-final-v2'].via).toBe('name');
expect(r['site-survey-final-v2'].auto).toBe(false); // < exact → needs review
expect(r['totally unrelated']).toBeUndefined(); // no match dropped
});
test('proposeMatches: ambiguous duplicate current-name is not auto-assigned', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const files = [
{ originalFilename: 'scan001', extension: 'pdf', folderPath: 'Root/A' },
{ originalFilename: 'scan001', extension: 'pdf', folderPath: 'Root/B' }, // same name, different folder
];
const rows = [{ id: 'm1', trackingNumber: 'ACM-AR-DWG-0009', currentName: 'scan001.pdf' }];
return c.proposeMatches(files, rows, {}).map(p => ({ conf: p.confidence, auto: p.auto }));
});
expect(r.length).toBe(2); // both files match the one row
expect(r.every(p => p.conf === 1)).toBe(true);
expect(r.every(p => p.auto === false)).toBe(true); // a row claimed by 2 files → neither auto-assigns
});
test('proposeMatches finds a row whose tracking number is in the filename', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const files = [
{ originalFilename: 'ACM-PRJ-EL-SPC-0001 rev A', extension: 'pdf' }, // exact substring
{ originalFilename: 'random scan', extension: 'pdf' }, // no match
{ originalFilename: 'doc ACMPRJELSPC0002 final', extension: 'pdf' }, // normalized
];
const rows = [
{ trackingNumber: 'ACM-PRJ-EL-SPC-0001' },
{ trackingNumber: 'ACM-PRJ-EL-SPC-0002' },
];
const m = c.proposeMatches(files, rows, {});
return m.map(p => ({ file: p.file.originalFilename, tn: p.row.trackingNumber, conf: p.confidence }));
});
expect(r.length).toBe(2); // the no-match file is dropped
expect(r[0]).toEqual({ file: 'ACM-PRJ-EL-SPC-0001 rev A', tn: 'ACM-PRJ-EL-SPC-0001', conf: 1 });
expect(r[1].tn).toBe('ACM-PRJ-EL-SPC-0002'); // matched via normalization
expect(r[1].conf).toBeCloseTo(0.8);
});
test('From a list: 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('From a list: _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('From a list: 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']);
expect(r.placed).toBe('m1'); // drop placed the file on the deliverable
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // bulk-set revision feeds the name
});

View file

@ -1,194 +0,0 @@
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
// "Add from archive" for the tables tool's project MDL rollup. The page is
// loaded offline (file://) with an injected #table-context whose columns drive
// how a tracking number splits into deliverable fields. The walk / dedupe /
// instantiate logic is exercised against in-page mock FS-Access handles — no
// server needed.
const HTML_PATH = path.resolve('tables/dist/tables.html');
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
// originator … identity fields … title (originator is folder-pinned → omitted
// from the body; everything between originator and title is the tracking split).
const MDL_COLUMNS = [
{ field: 'originator', title: 'Orig' },
{ field: 'phase', title: 'Phase' },
{ field: 'project', title: 'Project' },
{ field: 'area', title: 'Area' },
{ field: 'discipline', title: 'Disc' },
{ field: 'type', title: 'Type' },
{ field: 'sequence', title: 'Seq' },
{ field: 'suffix', title: 'Suffix' },
{ field: 'title', title: 'Deliverable' },
];
async function loadRollup(page) {
const ctx = { title: 'MDL', columns: MDL_COLUMNS, rows: [], addable: false };
const ctxJson = JSON.stringify(ctx).replace(/<\//g, '<\\/');
const patched = HTML_RAW.replace(
/<script id="table-context" type="application\/json">[\s\S]*?<\/script>/,
`<script id="table-context" type="application/json">${ctxJson}</script>`,
);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tables-mdl-'));
const tmpPath = path.join(tmpDir, 'tables.html');
fs.writeFileSync(tmpPath, patched);
await page.goto(`file://${tmpPath}`, { waitUntil: 'load' });
await page.waitForFunction(
() => window.tablesApp && window.tablesApp.modules && window.tablesApp.modules.mdlFromArchive,
);
}
test.describe('tables/ — Add deliverables from archive', () => {
test('identityFields() = columns between originator and title', async ({ page }) => {
await loadRollup(page);
const fields = await page.evaluate(() => window.tablesApp.modules.mdlFromArchive.identityFields());
expect(fields).toEqual(['phase', 'project', 'area', 'discipline', 'type', 'sequence', 'suffix']);
});
test('deliverableFromFile splits the tracking number, omits originator, keeps title', async ({ page }) => {
await loadRollup(page);
const d = await page.evaluate(() => {
const m = window.tablesApp.modules.mdlFromArchive;
return m.deliverableFromFile(
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001-X', title: 'Foundation Plan' },
m.identityFields(),
);
});
expect(d.originator).toBe('ACME');
expect(d.tracking).toBe('ACME-DD-PRJ-A1-CIV-DWG-001-X');
expect(d.body).toEqual({
phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV',
type: 'DWG', sequence: '001', suffix: 'X', title: 'Foundation Plan',
});
// originator must NOT be in the body (server pins it from the folder).
expect(d.body.originator).toBeUndefined();
});
test('a shorter tracking number leaves trailing identity fields unset', async ({ page }) => {
await loadRollup(page);
const d = await page.evaluate(() => {
const m = window.tablesApp.modules.mdlFromArchive;
// no suffix segment
return m.deliverableFromFile({ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: '' }, m.identityFields());
});
expect(d.body.sequence).toBe('001');
expect('suffix' in d.body).toBe(false);
});
test('dedupe collapses duplicate tracking numbers, dropping unsplittable rows', async ({ page }) => {
await loadRollup(page);
const out = await page.evaluate(() => {
const m = window.tablesApp.modules.mdlFromArchive;
return m.dedupe([
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: 'a' },
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: 'a-dup' },
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-002', title: 'b' },
{ tracking: 'NOPE', title: 'too short' },
], m.identityFields());
});
expect(out.map(d => d.tracking)).toEqual([
'ACME-DD-PRJ-A1-CIV-DWG-001', 'ACME-DD-PRJ-A1-CIV-DWG-002',
]);
expect(out[0].body.title).toBe('a'); // first wins
});
test('walkArchive collects valid document files, skipping mdl/rsk/dot/underscore dirs', async ({ page }) => {
await loadRollup(page);
const files = await page.evaluate(async () => {
// Mock FS-Access directory handles.
function dir(name, entries) {
return {
name, kind: 'directory', _entries: entries,
async *values() { for (const e of entries) yield e; },
async getDirectoryHandle(n) {
const e = entries.find(x => x.name === n && x.kind === 'directory');
if (!e) throw new DOMException('not found', 'NotFoundError');
return e;
},
};
}
const file = name => ({ name, kind: 'file' });
const root = dir('archive', [
dir('Acme', [
dir('issued', [
dir('2026-05-01_ACME-DD-PRJ-A1-CIV-DWG-001 (IFR) - Plan', [
file('ACME-DD-PRJ-A1-CIV-DWG-001_B (IFR) - Foundation Plan.pdf'),
file('not-a-zddc-file.txt'),
]),
]),
dir('mdl', [ file('ACME-DD-PRJ-A1-CIV-DWG-001.yaml') ]), // skipped
dir('rsk', [ file('whatever_A (IFA) - x.pdf') ]), // skipped
]),
dir('_system', [ file('ACME-DD-PRJ-A1-CIV-DWG-999_A (IFA) - hidden.pdf') ]), // skipped
]);
const out = await window.tablesApp.modules.mdlFromArchive.walkArchive(root);
return out.map(f => ({ tracking: f.tracking, party: f.party, slot: f.slot, rev: f.revision, title: f.title }));
});
expect(files).toEqual([
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', party: 'Acme', slot: 'issued', rev: 'B', title: 'Foundation Plan' },
]);
});
test('instantiateOne writes a yaml on create, skips when it already exists', async ({ page }) => {
await loadRollup(page);
const result = await page.evaluate(async () => {
const writes = [];
function fileHandle(name, exists) {
return {
name,
async createWritable() {
return {
async write(blob) { writes.push({ name, text: await blob.text() }); },
async close() {},
};
},
_exists: exists,
};
}
function mdlDir() {
const present = {}; // tracking.yaml already there
present['ACME-DD-PRJ-A1-CIV-DWG-002.yaml'] = true;
return {
async getFileHandle(n, opts) {
if (opts && opts.create) return fileHandle(n, false);
if (present[n]) return fileHandle(n, true);
throw new DOMException('nf', 'NotFoundError');
},
};
}
function originatorDir() {
return { async getDirectoryHandle() { return mdlDir(); } };
}
const archiveRoot = { async getDirectoryHandle() { return originatorDir(); } };
const m = window.tablesApp.modules.mdlFromArchive;
const created = await m.instantiateOne(archiveRoot, {
tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', originator: 'ACME',
body: { phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV', type: 'DWG', sequence: '001', title: 'Plan' },
});
const skipped = await m.instantiateOne(archiveRoot, {
tracking: 'ACME-DD-PRJ-A1-CIV-DWG-002', originator: 'ACME',
body: { phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV', type: 'DWG', sequence: '002', title: 'Plan2' },
});
return { created, skipped, writes };
});
expect(result.created).toBe('created');
expect(result.skipped).toBe('skipped');
expect(result.writes.length).toBe(1);
expect(result.writes[0].name).toBe('ACME-DD-PRJ-A1-CIV-DWG-001.yaml');
expect(result.writes[0].text).toContain('title: Plan');
expect(result.writes[0].text).toContain('discipline: CIV');
// originator must not be serialized into the body
expect(result.writes[0].text).not.toContain('originator:');
});
test('the "From archive" button stays hidden when not on an /mdl/ rollup path', async ({ page }) => {
await loadRollup(page);
// file:// path is not /mdl/, so setup() must not reveal the button.
const hidden = await page.evaluate(() => document.getElementById('table-add-from-archive').hidden);
expect(hidden).toBe(true);
});
});

View file

@ -22,13 +22,12 @@ test.describe('shared/toast.js', () => {
expect(exposed).toBe(true);
});
test('renders a toast with the level class and ARIA role', async ({ page }) => {
test('renders a single toast with the level class and ARIA role', async ({ page }) => {
const after = await page.evaluate(() => {
window.zddc.toast('Saved.', 'success');
const el = document.querySelector('.zddc-toast');
return el && {
// The message lives in its own span (the toast also holds a × button).
text: el.querySelector('.zddc-toast__msg').textContent,
text: el.textContent,
level: [...el.classList].find(c => c.startsWith('zddc-toast--')),
role: el.getAttribute('role'),
live: el.getAttribute('aria-live'),
@ -51,24 +50,18 @@ test.describe('shared/toast.js', () => {
expect(probe).toEqual({ role: 'alert', live: 'assertive' });
});
test('toasts stack, and a "Clear all" control appears at 2+', async ({ page }) => {
const r = await page.evaluate(() => {
window.zddc.toast('one', 'error'); // sticky so it stays for the count
window.zddc.toast('two', 'error');
return {
count: document.querySelectorAll('.zddc-toast').length,
clearAll: !!document.querySelector('.zddc-toasts__clear'),
};
test('a second toast replaces the first (single-toast policy)', async ({ page }) => {
const count = await page.evaluate(() => {
window.zddc.toast('one', 'info');
window.zddc.toast('two', 'info');
return document.querySelectorAll('.zddc-toast').length;
});
expect(r.count).toBe(2); // stack, not replace
expect(r.clearAll).toBe(true); // "Clear all" surfaces when 2+ are stacked
expect(count).toBe(1);
});
test('the × button dismisses a toast; clicking the body does not', async ({ page }) => {
await page.evaluate(() => window.zddc.toast('keep me', 'error')); // sticky
await page.locator('.zddc-toast .zddc-toast__msg').click(); // selecting text ≠ dismiss
await expect(page.locator('.zddc-toast')).toHaveCount(1);
await page.locator('.zddc-toast .zddc-toast__close').click(); // × dismisses
test('clicking dismisses immediately', async ({ page }) => {
await page.evaluate(() => window.zddc.toast('click me', 'info'));
await page.locator('.zddc-toast').click();
await expect(page.locator('.zddc-toast')).toHaveCount(0);
});
});

View file

@ -162,23 +162,19 @@ test.describe('/.tokens self-service token UI', () => {
});
test('XSS guard: description with HTML special chars is escaped on render', async ({ page }) => {
page.on('dialog', d => d.accept());
const xssDesc = `<img src=x onerror="window.__xss=1">`;
await page.goto(`${server.baseURL}/.tokens`);
// Create via the apiActions modal (the inline #desc form is long gone).
await page.locator('#api-create-btn').click();
await expect(page.locator('.api-modal')).toBeVisible();
await page.locator('.api-modal input').first().fill(xssDesc);
await page.locator('.api-modal button[type="submit"]').click();
await expect(page.locator('.api-modal__secret')).toBeVisible();
await page.locator('.api-modal button:has-text("Done")').click();
await page.waitForLoadState('networkidle');
// The description renders as a row — as TEXT, not parsed HTML.
const row = page.locator('#table-root tbody tr', { hasText: 'img src' });
await expect(row).toBeVisible();
// The <img> must NOT have been parsed (its onerror never fires)…
expect(await page.evaluate(() => window.__xss === 1)).toBe(false);
// …and the literal angle brackets survive in the cell text.
expect(await row.textContent()).toContain('<img');
await page.fill('#desc', xssDesc);
await page.click('button[type="submit"]');
// Wait for the row to appear in the table.
await expect(page.locator('#tokens tbody')).toContainText('<img');
// The literal <img> tag should NOT have been parsed as HTML —
// window.__xss must remain undefined.
const xssFired = await page.evaluate(() => window.__xss === 1);
expect(xssFired).toBe(false);
// And the on-disk text content of the cell should contain the
// literal angle brackets, proving they were escaped.
const rowText = await page.locator('#tokens tbody tr', { hasText: 'img src' }).textContent();
expect(rowText).toContain('<img');
});
});

View file

@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
@ -11523,48 +11523,6 @@ window.app.modules.filtering = {
a: 'edit access rules'
};
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
@ -11578,9 +11536,8 @@ window.app.modules.filtering = {
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
var body = null;
try {
body = await resp.clone().json();
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
@ -11595,16 +11552,6 @@ window.app.modules.filtering = {
msg = prefix + 'Forbidden.';
}
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
@ -11637,7 +11584,7 @@ window.app.modules.filtering = {
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();
</script>

View file

@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
@ -7022,48 +7022,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
a: 'edit access rules'
};
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
@ -7077,9 +7035,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
var body = null;
try {
body = await resp.clone().json();
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
@ -7094,16 +7051,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
msg = prefix + 'Forbidden.';
}
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
@ -7136,7 +7083,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();
// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
@ -14444,12 +14391,8 @@ window.__ZDDC_SCHEMA__ = {
box.querySelector('#acc-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
// Close on a genuine backdrop click only — not when a drag that began
// inside the dialog (selecting text in an input) ends out here.
var pressedBackdrop = false;
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
overlay.addEventListener('click', function (e) {
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
document.addEventListener('keydown', onKeydown);
@ -14766,10 +14709,8 @@ window.__ZDDC_SCHEMA__ = {
box.querySelector('#stage-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
var pressedBackdrop = false;
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
overlay.addEventListener('click', function (e) {
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
box.querySelector('#stage-submit').addEventListener('click', function () {
var sel = box.querySelector('input[name="stage-target"]:checked');
@ -14819,10 +14760,8 @@ window.__ZDDC_SCHEMA__ = {
box.querySelector('#unstage-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
var pressedBackdrop = false;
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
overlay.addEventListener('click', function (e) {
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
box.querySelector('#unstage-submit').addEventListener('click', function () {
var target = input.value.trim();
@ -15418,12 +15357,8 @@ window.__ZDDC_SCHEMA__ = {
box.querySelector('#ct-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
// Close on a genuine backdrop click only — not when a drag that began
// inside the dialog (selecting text in an input) ends out here.
var pressedBackdrop = false;
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
overlay.addEventListener('click', function (e) {
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
document.addEventListener('keydown', onKeydown);
submit.addEventListener('click', function () {
@ -16274,13 +16209,6 @@ window.__ZDDC_SCHEMA__ = {
function openPartyPicker(opts) {
return new Promise(function (resolve) {
var kindWord = opts.kind === 'folder' ? 'folder' : 'file';
// The "+ New party" affordance is gated on create authority over ssr/
// (pre-checked in createInAggregator). When denied, disable it and say
// who can — role-first text inline, the specific people in the tooltip.
var newPartyAllowed = opts.canNewParty !== false;
var newPartyNote = newPartyAllowed ? '(registers a new party)'
: (opts.newPartyHint && opts.newPartyHint.text) || 'You cant register a new party here.';
var newPartyTitle = (!newPartyAllowed && opts.newPartyHint && opts.newPartyHint.title) || '';
var overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
var box = document.createElement('div');
@ -16299,10 +16227,10 @@ window.__ZDDC_SCHEMA__ = {
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/&lt;party&gt;/</code>.' +
'</p>' +
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
(partyList || '<em style="color:#888;">No parties yet.</em>') +
'<label title="' + escapeHtml(newPartyTitle) + '" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;' + (newPartyAllowed ? 'cursor:pointer;' : 'opacity:0.6;') + '">' +
'<input type="radio" name="pp-party" value="__new__"' + (newPartyAllowed ? '' : ' disabled') + '>' +
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</span></span></label>' +
(partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
'<input type="radio" name="pp-party" value="__new__">' +
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' +
'</div>' +
'<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' +
'<label for="pp-newparty">New party name</label><br>' +
@ -16330,11 +16258,7 @@ window.__ZDDC_SCHEMA__ = {
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
function cancel() { close(); resolve(null); }
box.querySelector('#pp-cancel').addEventListener('click', cancel);
// Close on a genuine backdrop click only — NOT when a drag that began
// inside the dialog (e.g. selecting text in an input) ends out here.
var pressedBackdrop = false;
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
overlay.addEventListener('click', function (e) { if (e.target === overlay && pressedBackdrop) cancel(); });
overlay.addEventListener('click', function (e) { if (e.target === overlay) cancel(); });
box.querySelector('#pp-submit').addEventListener('click', function () {
var sel = box.querySelector('input[name="pp-party"]:checked');
if (!sel) { statusError('Pick a party.'); return; }
@ -16363,28 +16287,8 @@ window.__ZDDC_SCHEMA__ = {
async function createInAggregator(agg, kind) {
var up = window.app.modules.upload;
if (!up) return;
var cap = window.zddc && window.zddc.cap;
var ssrPath = '/' + agg.project + '/ssr/';
var slotPath = '/' + agg.project + '/' + agg.slot + '/';
// Pre-check create authority BEFORE any data entry: registering a new
// party needs `c` on ssr/, creating under an existing party needs `c` on
// the slot. If the user can do neither, don't open the form just to deny
// them — say (subtly) who can. If they can only use existing parties,
// open the picker with the "+ New party" option disabled + explained.
var canNewParty = true, ssrView = null, slotView = null;
if (cap && cap.at) {
slotView = await cap.at(slotPath);
ssrView = await cap.at(ssrPath);
var canSlot = !slotView || cap.has({ verbs: slotView.path_verbs }, 'c');
canNewParty = !ssrView || cap.has({ verbs: ssrView.path_verbs }, 'c');
if (slotView && !canSlot && !canNewParty) {
statusError(cap.denyHint(slotView, 'c').text);
return;
}
}
var parties = await fetchParties(agg.project);
var newPartyHint = (!canNewParty && ssrView && cap.denyHint) ? cap.denyHint(ssrView, 'c') : null;
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties, canNewParty: canNewParty, newPartyHint: newPartyHint });
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties });
if (!choice) return;
// Party names are validated to a URL-safe charset, so no encoding
// needed for the party segment; makeDir/makeFile encode the leaf.
@ -16407,10 +16311,7 @@ window.__ZDDC_SCHEMA__ = {
} catch (e) {
var msg = (e && e.message) || String(e);
if (/\b403\b/.test(msg)) {
// Name who can — best-effort, for the path the denial came from.
var denied = choice.isNew ? ssrPath : ('/' + agg.project + '/' + agg.slot + '/' + choice.party + '/');
var v = (cap && cap.at) ? await cap.at(denied) : null;
statusError(v && cap.denyHint ? cap.denyHint(v, 'c').text : 'Not allowed — you dont have create access here.');
statusError('Not allowed — registering a new party requires the document-controller role.');
} else if (/\b409\b/.test(msg)) {
statusError('Unknown party — register it first (document controller).');
} else {

File diff suppressed because one or more lines are too long

View file

@ -1609,21 +1609,6 @@ body {
text-decoration: underline;
}
/* Subtle standalone-tools footer. */
.landing-apps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin: 2rem auto 1rem;
padding: 0 1rem;
max-width: 60rem;
font-size: 0.82rem;
color: var(--text-muted, #6b7280);
}
.landing-apps__label { color: var(--text-muted, #6b7280); }
.landing-apps .browse-link { margin-top: 0; }
#projectView ol {
padding-left: 1.5rem;
}
@ -1793,7 +1778,7 @@ body {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
</div>
</div>
<div class="header-right">
@ -1966,15 +1951,6 @@ body {
</div><!-- /projectView -->
</main>
<!-- Subtle links to the standalone tools (run against your own local files,
no server). Served at /_apps/<tool>.html by zddc-server. -->
<footer class="landing-apps">
<span class="landing-apps__label">Standalone tools for your own files:</span>
<a class="browse-link" href="/_apps/browse.html">Browse</a>
<a class="browse-link" href="/_apps/archive.html">Archive</a>
<a class="browse-link" href="/_apps/classifier.html">Classifier</a>
</footer>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
@ -3396,48 +3372,6 @@ body {
a: 'edit access rules'
};
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
@ -3451,9 +3385,8 @@ body {
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
var body = null;
try {
body = await resp.clone().json();
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
@ -3468,16 +3401,6 @@ body {
msg = prefix + 'Forbidden.';
}
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
@ -3510,7 +3433,7 @@ body {
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();
(function() {

View file

@ -2770,7 +2770,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
</div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action;
@ -14071,48 +14071,6 @@ X.B(E,Y);return E}return J}())
a: 'edit access rules'
};
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
@ -14126,9 +14084,8 @@ X.B(E,Y);return E}return J}())
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
var body = null;
try {
body = await resp.clone().json();
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
@ -14143,16 +14100,6 @@ X.B(E,Y);return E}return J}())
msg = prefix + 'Forbidden.';
}
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
@ -14185,7 +14132,7 @@ X.B(E,Y);return E}return J}())
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();
(function (app) {

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
transmittal=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
classifier=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
landing=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
form=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
tables=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
browse=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
archive=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
transmittal=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
classifier=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
landing=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
form=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
tables=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7
browse=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7

View file

@ -5,7 +5,6 @@ import (
"net/http"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
// writeForbidden emits a 403 JSON response naming the missing verb. Used
@ -35,25 +34,6 @@ func writeForbidden(w http.ResponseWriter, action string) {
_, _ = w.Write(body)
}
// writeForbiddenWho is writeForbidden enriched with a "who_can" Authority for
// the missing verb, computed from the deny site's policy chain. Lets the toast
// tell the user who to ask even when the action wasn't pre-checked (a race, or
// a path the client didn't gate). The pre-check path (AccessView.PathWhoCan) is
// the primary surface; this is the safety net.
func writeForbiddenWho(w http.ResponseWriter, action string, chain zddc.PolicyChain) {
verb := verbForAction(action)
body := map[string]any{"error": "Forbidden", "missing_verb": verb}
if vs, ok := zddc.ParseVerbSet(verb); ok {
if a := zddc.WhoCan(chain, vs); !a.Empty() {
body["who_can"] = a
}
}
raw, _ := json.Marshal(body)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write(raw)
}
// verbForAction maps a policy.Action constant to its single-character
// verb. Mirrors policy.actionVerb but emits the wire-format letter
// rather than the bitmask, so the JSON body carries "r"/"w"/"c"/"d"/"a"

View file

@ -152,7 +152,7 @@ func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request,
decider := DeciderFromContext(r)
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
if !allowed {
writeForbiddenWho(w, action, chain) // name who CAN, so the toast can explain
writeForbidden(w, action)
return false
}
return true

View file

@ -181,15 +181,6 @@ type AccessView struct {
// the "which roles do I hold here?" answer the browse hovercard
// surfaces. Elevation-independent (role membership, not admin).
PathRoles []string `json:"path_roles,omitempty"`
// PathWhoCan answers "if I can't, who can?" for the gated verbs the caller
// LACKS at this path (keyed by verb letter: "c"/"w"/"d"). Each entry names
// the roles + people that hold the verb, so a denied affordance can show a
// subtle "ask a document controller" hint instead of letting the user act
// and bounce off a 403. Populated only when the caller can READ the path
// (mirrors .zddc readability — no new disclosure), and only for verbs not
// already in PathVerbs. Omitted (nil) when the caller can do everything or
// can't read here.
PathWhoCan map[string]zddc.Authority `json:"path_who_can,omitempty"`
}
// enumerateAccess builds an AccessView for the given caller. Used by the
@ -265,27 +256,6 @@ func populatePathScopedAccess(ctx context.Context, decider policy.Decider, cfg c
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery)
view.PathVerbs = verbs.String()
view.PathIsAdmin = p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email)
// "If I can't, who can?" — only for verbs the caller LACKS, and only when
// they can read here (the .zddc that backs this is read-ACL-governed, so a
// reader could see it anyway). Lets a denied affordance explain itself
// up-front instead of letting the user act and bounce off a 403.
if verbs.Has(zddc.VerbR) {
who := map[string]zddc.Authority{}
for _, gv := range []struct {
letter string
v zddc.VerbSet
}{{"c", zddc.VerbC}, {"w", zddc.VerbW}, {"d", zddc.VerbD}} {
if verbs.Has(gv.v) {
continue
}
if a := zddc.WhoCan(chain, gv.v); !a.Empty() {
who[gv.letter] = a
}
}
if len(who) > 0 {
view.PathWhoCan = who
}
}
// Which cascade roles the caller holds at this path — the answer to
// "the system thinks I'm a document_controller here, right?".
view.PathRoles = zddc.RolesForPrincipalInChain(chain, p.Email)

View file

@ -1245,54 +1245,6 @@ body.is-elevated::after {
color: var(--text);
}
/* ── Shared selectable + autofilter table (seltable) + its hosting overlay ───
Used by the tables tool's "Add from archive". The classifier carries an
equivalent copy inline in its layout.css for the catalog. */
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
.seltable__table thead th {
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
.seltable__table thead tr.seltable__filters th { top: 1.55rem; padding: 0.15rem 0.35rem; }
.seltable__colfilter {
width: 100%; min-width: 2rem; box-sizing: border-box; padding: 0.15rem 0.35rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text); font-size: 0.74rem; font-weight: 400; text-transform: none; letter-spacing: 0;
}
.seltable__row { cursor: pointer; user-select: none; }
.seltable__row:hover { background: var(--bg-hover); }
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
/* ── "Add deliverables from archive" overlay (project MDL rollup) ─────────── */
.mdlarch-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center; padding: 1.5rem;
}
.mdlarch-overlay__box {
display: flex; flex-direction: column; min-height: 0;
width: min(960px, 95vw); height: min(80vh, 760px);
background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.mdlarch-overlay__head { display: flex; align-items: center; gap: 0.75rem; padding: 0.85rem 1.1rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.mdlarch-overlay__head h2 { margin: 0; font-size: 1.05rem; flex: 1; }
.mdlarch-overlay__close { border: none; background: none; color: var(--text-muted); font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.25rem; }
.mdlarch-overlay__close:hover { color: var(--text); }
.mdlarch-overlay__status { padding: 0.5rem 1.1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.mdlarch-overlay__table { flex: 1; min-height: 0; display: flex; }
.mdlarch-overlay__table .seltable { height: 100%; flex: 1; }
.mdlarch-overlay__foot { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0.75rem 1.1rem; border-top: 1px solid var(--border); flex: 0 0 auto; }
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
.table-main {
@ -1770,7 +1722,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7</span></span>
</div>
</div>
<div class="header-right">
@ -1791,7 +1743,6 @@ body.is-elevated::after {
<div class="table-toolbar__right">
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
<button type="button" id="table-add-from-archive" class="btn btn-secondary btn-sm" hidden title="Register deliverables from existing archive files (project MDL rollup)"> From archive</button>
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
</div>
</div>
@ -3592,48 +3543,6 @@ body.is-elevated::after {
a: 'edit access rules'
};
// ── "who can?" — tell a denied user who to ask, subtly ──────────────────
// The PATTERN: gate an affordance on cap.has(node, verb) / a path view's
// path_verbs; when the verb is ABSENT, don't just disable — render
// cap.denyHint(view, verb) as the disabled control's tooltip/subtitle (or
// a small inline note) so the user learns who can do it instead of acting
// and bouncing off a 403. handleForbidden() does the same on a denial that
// slips past a pre-check.
function humanizeRole(name) { return String(name || '').replace(/[_-]+/g, ' ').trim(); }
// whoCan(view, verb) → the Authority {roles, people} the server computed for
// a verb the caller lacks at view's path, or null. `view` is a /.profile/
// access?path= result (from cap.at) OR a 403 body carrying who_can.
function whoCan(view, verb) {
if (!view) return null;
var map = view.path_who_can;
if (map && map[verb]) return map[verb];
if (view.who_can && view.missing_verb === verb) return view.who_can;
return null;
}
// denyHint(view, verb) → { text, title } for a subtle "who can" line.
// Role-first: "Only the document controller can create here", with the
// specific people (admins / role members) as the tooltip detail. Falls back
// to naming people, then to "Ask an administrator". Returns null when the
// verb is actually granted (nothing to hint) and a generic hint when no
// authority is known.
function denyHint(view, verb) {
var a = whoCan(view, verb);
var doing = VERB_LABELS[verb] || verb || 'do that';
if (!a || (!(a.roles && a.roles.length) && !(a.people && a.people.length))) {
return { text: 'Ask an administrator to ' + doing + ' here.', title: '' };
}
var people = (a.people || []).slice();
var detail = people.length ? people.join(', ') : '';
if (a.roles && a.roles.length) {
return { text: 'Only the ' + humanizeRole(a.roles[0]) + ' can ' + doing + ' here.', title: detail };
}
var shown = people.slice(0, 2).join(', ') + (people.length > 2 ? ', …' : '');
return { text: 'Ask ' + shown + ' to ' + doing + ' here.', title: detail };
}
// handleForbidden(resp, opts) — render a 403 toast naming the
// missing verb. opts.path (optional) is the URL the failed request
// hit; when provided, the helper consults /.profile/access?path= to
@ -3647,9 +3556,8 @@ body.is-elevated::after {
async function handleForbidden(resp, opts) {
opts = opts || {};
var missing = '';
var body = null;
try {
body = await resp.clone().json();
var body = await resp.clone().json();
if (body && typeof body.missing_verb === 'string') {
missing = body.missing_verb;
}
@ -3664,16 +3572,6 @@ body.is-elevated::after {
msg = prefix + 'Forbidden.';
}
// Append the subtle "who can" hint: prefer who_can from the 403 body,
// else fall back to the path-scoped access view. So even a denial the
// UI didn't pre-check still tells the user who to ask.
if (missing) {
var src = (body && body.who_can) ? { who_can: body.who_can, missing_verb: missing } : null;
if (!src && opts.path) src = await at(opts.path);
var hint = src ? denyHint(src, missing) : null;
if (hint && hint.text) msg += ' ' + hint.text;
}
// Optional elevate offer: only when the caller supplied a
// path AND the path-scoped access view reports an elevation
// grant covering the missing verb. Render as a clickable
@ -3706,7 +3604,7 @@ body.is-elevated::after {
}
}
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
})();
// shared/context-menu.js — generic context-menu framework exposed on
@ -4098,185 +3996,6 @@ body.is-elevated::after {
window.zddc.menu = { open: open, close: close };
})();
/**
* ZDDC — shared selectable + autofilter table (used by the classifier catalog
* and the tables tool's "Add from archive").
*
* A flat table with PER-COLUMN autofilters (one input per column, AND-combined,
* each an AND of space-separated terms) plus an optional programmatic global
* filter, and powerful selection for building complex sets quickly:
* click replace selection + set anchor
* ctrl/cmd-click toggle one row
* shift-click range from the anchor (replaces the selection)
* ctrl-shift-click ADD the anchor→row range to the existing selection
* ctrl/cmd-Enter fire onActivate(selectedIds) — a bulk action
* Esc clear
* Ranges run over the CURRENTLY FILTERED order. Selection is keyed by a stable
* rowId so it survives filtering and re-render.
*/
(function () {
'use strict';
if (!window.app) window.app = {};
if (!window.app.modules) window.app.modules = {};
function terms(q) { return String(q == null ? '' : q).trim().toLowerCase().split(/\s+/).filter(Boolean); }
function hit(text, ts) {
var t = String(text == null ? '' : text).toLowerCase();
for (var i = 0; i < ts.length; i++) { if (t.indexOf(ts[i]) === -1) return false; }
return true;
}
function elt(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function create(opts) {
var container = opts.container;
var columns = opts.columns || [];
var rowId = opts.rowId || function (r) { return r.id; };
var getRows = (typeof opts.rows === 'function') ? opts.rows : function () { return opts.rows || []; };
var selected = Object.create(null); // id -> true
var anchorId = null;
var globalTerms = []; // programmatic global filter (tests/reveal)
var colFilters = Object.create(null); // colKey -> terms[] (the per-column autofilters)
function rows() { return getRows() || []; }
function colByKey(k) { for (var i = 0; i < columns.length; i++) { if (columns[i].key === k) return columns[i]; } return null; }
function colVal(col, row) { return col.get ? col.get(row) : (row[col.key] == null ? '' : row[col.key]); }
function rowBlob(row) { var s = ''; for (var i = 0; i < columns.length; i++) { s += colVal(columns[i], row) + ' '; } return s; }
function rowMatches(row) {
if (globalTerms.length && !hit(rowBlob(row), globalTerms)) return false;
for (var k in colFilters) {
var col = colByKey(k);
if (col && !hit(colVal(col, row), colFilters[k])) return false;
}
return true;
}
function filtered() { return rows().filter(rowMatches); }
function getSelection() { return Object.keys(selected); }
function getFilteredRows() { return filtered(); }
function fireSel() { if (opts.onSelectionChange) opts.onSelectionChange(getSelection()); }
function setFilter(q) { globalTerms = terms(q); renderBody(); }
function setColFilter(colKey, q) { var t = terms(q); if (t.length) colFilters[colKey] = t; else delete colFilters[colKey]; renderBody(); }
function selectAllFiltered() { filtered().forEach(function (r) { selected[rowId(r)] = true; }); anchorId = null; renderBody(); fireSel(); }
function clearSel() { selected = Object.create(null); anchorId = null; renderBody(); fireSel(); }
function onRowClick(e, row, fr) {
var ids = fr.map(rowId), id = rowId(row), idx = ids.indexOf(id), aIdx;
if (e.shiftKey && anchorId != null && (aIdx = ids.indexOf(anchorId)) >= 0) {
if (!(e.ctrlKey || e.metaKey)) selected = Object.create(null); // shift replaces; ctrl-shift adds
var lo = Math.min(aIdx, idx), hi = Math.max(aIdx, idx);
for (var i = lo; i <= hi; i++) selected[ids[i]] = true;
} else if (e.ctrlKey || e.metaKey) {
if (selected[id]) delete selected[id]; else selected[id] = true;
anchorId = id;
} else {
selected = Object.create(null); selected[id] = true; anchorId = id;
}
renderBody(); fireSel();
}
var bodyEl = null, countEl = null;
function render() {
container.textContent = '';
container.classList.add('seltable');
var bar = elt('div', 'seltable__bar');
var allBtn = elt('button', 'btn btn-sm btn-secondary', 'Select filtered');
allBtn.addEventListener('click', selectAllFiltered);
var clrBtn = elt('button', 'btn btn-sm btn-secondary', 'Clear');
clrBtn.addEventListener('click', clearSel);
countEl = elt('span', 'seltable__count');
bar.appendChild(allBtn); bar.appendChild(clrBtn); bar.appendChild(countEl);
container.appendChild(bar);
var scroll = elt('div', 'seltable__scroll');
var table = elt('table', 'seltable__table');
var thead = elt('thead'), htr = elt('tr');
columns.forEach(function (c) { htr.appendChild(elt('th', c.cls || null, c.title || c.key)); });
if (opts.rowExtra) htr.appendChild(elt('th', 'seltable__extrah', opts.extraTitle || ''));
thead.appendChild(htr);
// Per-column autofilter row.
var ftr = elt('tr', 'seltable__filters');
columns.forEach(function (c) {
var th = elt('th');
if (c.filterable !== false) {
var inp = elt('input', 'seltable__colfilter'); inp.type = 'search'; inp.placeholder = 'filter…'; inp.spellcheck = false;
inp.setAttribute('data-no-select', '');
inp.addEventListener('input', function () { setColFilter(c.key, this.value); });
th.appendChild(inp);
}
ftr.appendChild(th);
});
if (opts.rowExtra) ftr.appendChild(elt('th'));
thead.appendChild(ftr);
table.appendChild(thead);
bodyEl = elt('tbody'); table.appendChild(bodyEl);
scroll.appendChild(table); container.appendChild(scroll);
container.tabIndex = 0;
container.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); if (opts.onActivate) opts.onActivate(getSelection()); }
else if (e.key === 'Escape') { clearSel(); }
});
renderBody();
}
function renderBody() {
if (!bodyEl) return;
var fr = filtered();
bodyEl.textContent = '';
fr.forEach(function (row) {
var id = rowId(row);
var tr = elt('tr', 'seltable__row' + (selected[id] ? ' is-selected' : ''));
tr.dataset.id = id;
tr.addEventListener('click', function (e) {
if (e.target.closest('input,button,select,a,[data-no-select]')) return;
onRowClick(e, row, fr);
});
if (opts.onRowDrop) {
tr.addEventListener('dragover', function (e) {
if (window.app.modules.dnd && window.app.modules.dnd.active()) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; tr.classList.add('drop-hover'); }
});
tr.addEventListener('dragleave', function () { tr.classList.remove('drop-hover'); });
tr.addEventListener('drop', function (e) {
tr.classList.remove('drop-hover');
e.preventDefault();
var keys = window.app.modules.dnd ? window.app.modules.dnd.getDrag() : [];
if (window.app.modules.dnd) window.app.modules.dnd.clearDrag();
if (keys.length) opts.onRowDrop(id, keys);
});
}
columns.forEach(function (c) {
var td = elt('td', c.cls || null);
if (c.render) c.render(row, td); else td.textContent = colVal(c, row);
tr.appendChild(td);
});
if (opts.rowExtra) { var ex = elt('td', 'seltable__extra'); opts.rowExtra(row, ex); tr.appendChild(ex); }
bodyEl.appendChild(tr);
});
if (countEl) {
var nSel = getSelection().length;
countEl.textContent = fr.length + ' shown' + (nSel ? ' · ' + nSel + ' selected' : '');
}
}
return {
render: render, renderBody: renderBody,
getSelection: getSelection, getFilteredRows: getFilteredRows,
setFilter: setFilter, setColFilter: setColFilter, selectAllFiltered: selectAllFiltered, clear: clearSel,
clickRow: function (id, mods) {
var fr = filtered();
var row = fr.filter(function (r) { return String(rowId(r)) === String(id); })[0];
if (row) onRowClick(Object.assign({ shiftKey: false, ctrlKey: false, metaKey: false }, mods || {}), row, fr);
},
};
}
window.app.modules.seltable = { create: create };
})();
// mode.js — picks table-mode vs form-mode at boot time and unhides the
// matching container. Both apps (tablesApp, formApp) ship in the same
// bundle but each only paints when its container is visible.
@ -7852,191 +7571,6 @@ body.is-elevated::after {
}
})(window.tablesApp = window.tablesApp || {});
// mdl-from-archive.js — "Add from archive" for the project MDL rollup.
//
// The MDL owns the workflow of registering deliverables; this is the catch-up
// path. On the project rollup (<project>/mdl/), walk the project archive into a
// shared seltable (autofilter + ctrl-shift selection), dedupe the selection to
// one deliverable per tracking number, and PUT a deliverable .yaml into each
// originator's archive/<originator>/mdl/. The body's identity fields are split
// from the tracking number positionally per the project's own table columns
// (originator is folder-pinned, so omitted); the server composes/validates the
// filename. Server-only.
(function (app) {
'use strict';
function T(m, l, o) { if (window.zddc && window.zddc.toast) window.zddc.toast(m, l, o); }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function ctxObj() { return (app && app.context) || {}; }
// The tracking-number identity fields, in order, from the table columns:
// everything between `originator` and `title` (e.g. phase, project, area,
// discipline, type, sequence, suffix). originator is folder-pinned.
function identityFields() {
var cols = (ctxObj().columns || []).map(function (c) { return c && c.field; }).filter(Boolean);
var oi = cols.indexOf('originator'), ti = cols.indexOf('title');
return cols.slice(oi >= 0 ? oi + 1 : 0, ti >= 0 ? ti : cols.length);
}
// tracking → { tracking, originator, body{identity fields + title} }, or null
// if it can't supply the originator + at least one identity segment.
function deliverableFromFile(f, idFields) {
var segs = String(f.tracking || '').split('-');
if (segs.length < 2) return null;
var rest = segs.slice(1), body = {};
idFields.forEach(function (name, i) { if (rest[i] != null && rest[i] !== '') body[name] = rest[i]; });
if (!Object.keys(body).length) return null;
body.title = f.title || '';
return { tracking: f.tracking, originator: segs[0], body: body };
}
function dedupe(files, idFields) {
var seen = Object.create(null), out = [];
(files || []).forEach(function (f) {
if (seen[f.tracking]) return;
var d = deliverableFromFile(f, idFields);
if (d) { seen[f.tracking] = true; out.push(d); }
});
return out;
}
async function walkArchive(rootHandle) {
var out = [];
async function walk(dirH, parts) {
for await (var entry of dirH.values()) {
var nm = String(entry.name || '').replace(/\/$/, '');
if (entry.kind === 'directory') {
var c = nm.charAt(0);
if (c === '.' || c === '_' || nm === 'mdl' || nm === 'rsk') continue;
await walk(await dirH.getDirectoryHandle(nm), parts.concat(nm));
} else {
var p = window.zddc.parseFilename(nm);
if (p && p.valid && p.trackingNumber) {
out.push({
id: parts.concat(nm).join('/'),
party: parts[0] || '', slot: parts[1] || '', transmittal: parts[2] || '',
tracking: p.trackingNumber, revision: p.revision, status: p.status, title: p.title,
});
}
}
}
}
await walk(rootHandle, []);
return out;
}
async function instantiateOne(archiveRoot, d) {
var dir = await archiveRoot.getDirectoryHandle(d.originator, { create: true });
dir = await dir.getDirectoryHandle('mdl', { create: true });
var fname = d.tracking + '.yaml';
try { await dir.getFileHandle(fname); return 'skipped'; } catch (e) { /* NotFound → create */ }
var fh = await dir.getFileHandle(fname, { create: true });
var w = await fh.createWritable();
await w.write(new Blob([window.jsyaml.dump(d.body)], { type: 'application/yaml' }));
await w.close();
return 'created';
}
// ── UI ───────────────────────────────────────────────────────────────────
var overlay = null, statusEl = null, table = null, files = [], archiveRoot = null;
function close() { if (overlay) { overlay.remove(); overlay = null; table = null; } }
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
function archiveBaseUrl() {
var proj = (location.pathname || '/').replace(/\/mdl\/.*$/, '/'); // <project>/
return location.origin + proj + 'archive/';
}
async function open() {
var src = window.zddc && window.zddc.source;
if (!src || (location.protocol !== 'http:' && location.protocol !== 'https:')) {
T('Adding from the archive needs the tables page served by a zddc-server.', 'error'); return;
}
buildOverlay();
try {
archiveRoot = new src.HttpDirectoryHandle(archiveBaseUrl(), 'archive');
setStatus('Scanning archive…');
files = await walkArchive(archiveRoot);
table.renderBody();
setStatus(files.length + ' document file' + (files.length === 1 ? '' : 's') + ' found. Filter + ctrl-shift select, then “Create deliverables”.');
} catch (e) { setStatus('Archive scan failed — ' + (e.message || e)); T('Archive scan failed — ' + (e.message || e), 'error'); }
}
function buildOverlay() {
close();
overlay = el('div', 'mdlarch-overlay');
var box = el('div', 'mdlarch-overlay__box');
var head = el('div', 'mdlarch-overlay__head');
head.appendChild(el('h2', null, 'Add deliverables from archive'));
var x = el('button', 'mdlarch-overlay__close', '×'); x.title = 'Close'; x.addEventListener('click', close);
head.appendChild(x); box.appendChild(head);
statusEl = el('div', 'mdlarch-overlay__status', 'Scanning archive…'); box.appendChild(statusEl);
var host = el('div', 'mdlarch-overlay__table'); box.appendChild(host);
var foot = el('div', 'mdlarch-overlay__foot');
var create = el('button', 'btn btn-primary', 'Create deliverables');
create.addEventListener('click', function () { runCreate(create); });
var cancel = el('button', 'btn btn-secondary', 'Close'); cancel.addEventListener('click', close);
foot.appendChild(create); foot.appendChild(cancel); box.appendChild(foot);
overlay.appendChild(box); document.body.appendChild(overlay);
table = window.app.modules.seltable.create({
container: host,
extraTitle: '',
rows: function () { return files; },
rowId: function (r) { return r.id; },
columns: [
{ key: 'party', title: 'Party' },
{ key: 'slot', title: 'Slot' },
{ key: 'transmittal', title: 'Transmittal' },
{ key: 'tracking', title: 'Tracking number' },
{ key: 'revision', title: 'Rev', get: function (r) { return r.revision + (r.status ? ' (' + r.status + ')' : ''); } },
{ key: 'title', title: 'Title' },
],
});
table.render();
}
async function runCreate(btn) {
if (!table) return;
var sel = table.getSelection();
if (!sel.length) { T('Select some archive files first (filter + ctrl-shift).', 'warning'); return; }
var picked = {}; sel.forEach(function (i) { picked[i] = true; });
var deliverables = dedupe(files.filter(function (f) { return picked[f.id]; }), identityFields());
if (!deliverables.length) { T('None of the selected files split into deliverable fields.', 'warning'); return; }
if (!confirm('Create ' + deliverables.length + ' deliverable(s) in the project MDL?\n\nOne .yaml per tracking number, in archive/<originator>/mdl/. Already-present ones are skipped.')) return;
btn.disabled = true;
var s = { created: 0, skipped: 0, errors: 0 };
for (var i = 0; i < deliverables.length; i++) {
setStatus('Creating ' + (i + 1) + '/' + deliverables.length + ' — ' + deliverables[i].tracking);
try { s[await instantiateOne(archiveRoot, deliverables[i])]++; }
catch (e) { s.errors++; T('Failed to create ' + deliverables[i].tracking + ' — ' + (e.message || e), 'error'); }
}
btn.disabled = false;
setStatus(s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '.');
T('MDL: ' + s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '. Reload to see them.', s.errors ? 'warning' : 'success');
}
// Show the toolbar button only on the project MDL rollup (addable:false +
// an mdl path), over http, gated on create permission. Called from main.js
// init once the context is known.
function setup(ctx) {
var btn = document.getElementById('table-add-from-archive');
if (!btn) return;
var onHttp = location.protocol === 'http:' || location.protocol === 'https:';
var isMdlRollup = ctx && ctx.addable === false && /\/mdl\/(table\.html)?$/.test(location.pathname || '');
if (!(onHttp && isMdlRollup)) return;
btn.hidden = false;
btn.addEventListener('click', open);
if (window.zddc && window.zddc.cap) {
window.zddc.cap.at(archiveBaseUrl().replace(location.origin, '')).then(function (view) {
var verbs = (view && view.path_verbs) || '';
if (verbs.indexOf('c') === -1) { btn.classList.add('is-disabled'); btn.title = "You don't have create access in this project's archive."; }
});
}
}
app.modules.mdlFromArchive = {
setup: setup, open: open,
// test seams
identityFields: identityFields, deliverableFromFile: deliverableFromFile,
dedupe: dedupe, walkArchive: walkArchive, instantiateOne: instantiateOne,
};
})(window.tablesApp);
(function (app) {
'use strict';
@ -8191,9 +7725,7 @@ body.is-elevated::after {
if (verbs.indexOf('c') === -1) {
addRowBtn.classList.add('is-disabled');
addRowBtn.setAttribute('aria-disabled', 'true');
// Tell them who can (subtly): role-first text + people in the tooltip.
var hint = window.zddc.cap.denyHint ? window.zddc.cap.denyHint(view, 'c') : null;
addRowBtn.title = hint ? (hint.text + (hint.title ? ' (' + hint.title + ')' : '')) : "You don't have create access in this folder.";
addRowBtn.title = "You don't have create access in this folder.";
// Swallow clicks so the no-op feedback is the
// tooltip, not a 403 toast on submission.
addRowBtn.addEventListener('click', function (ev) {
@ -8208,11 +7740,6 @@ body.is-elevated::after {
}
}
// "Add from archive" — shown only on the project MDL rollup (own gating).
if (app.modules.mdlFromArchive && app.modules.mdlFromArchive.setup) {
app.modules.mdlFromArchive.setup(ctx);
}
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];

View file

@ -1,76 +0,0 @@
package zddc
import "sort"
// Authority answers "who can do this here?" for a user who was denied an action.
// It is split so the UI can lead with the ROLE ("ask a document controller") and
// keep the specific People (admins + role members + direct email grants) as
// secondary detail. Both lists are sorted + de-duplicated.
//
// Safe to surface to anyone who can read the path: the .zddc cascade it is
// derived from is already governed by directory read-ACL, so this exposes
// nothing a reader couldn't already see in the .zddc itself.
type Authority struct {
Roles []string `json:"roles,omitempty"`
People []string `json:"people,omitempty"`
}
// Empty reports whether no authority was found (no one is named — the caller
// should fall back to "ask an administrator").
func (a Authority) Empty() bool { return len(a.Roles) == 0 && len(a.People) == 0 }
// WhoCan returns the principals that hold `verb` at the chain: every
// acl.permissions grantee across the cascade whose verb set includes it, plus
// every level's admins (who bypass the ACL entirely). Role grantees are recorded
// as Roles AND expanded to their member patterns in People; direct email/glob
// grantees go to People. Pure and side-effect-free.
func WhoCan(chain PolicyChain, verb VerbSet) Authority {
roleSet := map[string]struct{}{}
peopleSet := map[string]struct{}{}
add := func(principal string, levelIdx int) {
if principal == "" {
return
}
if IsPrincipalRole(principal) {
// A bare name is a role only if the cascade actually defines it;
// otherwise it's a wildcard pattern (e.g. "*") handled as a person.
if _, defined := lookupRoleMembers(chain, levelIdx, principal); defined {
roleSet[principal] = struct{}{}
for _, m := range RoleMembers(chain, levelIdx, principal) {
if m != "" {
peopleSet[m] = struct{}{}
}
}
return
}
}
peopleSet[principal] = struct{}{}
}
for i, level := range chain.Levels {
for principal, verbStr := range level.ACL.Permissions {
if v, _ := ParseVerbSet(verbStr); v.Has(verb) {
add(principal, i)
}
}
// Admins bypass the ACL clamp, so they can always do this verb.
for _, principal := range level.Admins {
add(principal, i)
}
}
return Authority{Roles: sortedSet(roleSet), People: sortedSet(peopleSet)}
}
func sortedSet(m map[string]struct{}) []string {
if len(m) == 0 {
return nil
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}

View file

@ -1,63 +0,0 @@
package zddc
import (
"reflect"
"testing"
)
func TestWhoCan(t *testing.T) {
chain := PolicyChain{
Levels: []ZddcFile{
{
ACL: ACLRules{Permissions: map[string]string{
"document_controller": "rwcda",
"carol@example.com": "rwc",
"*@example.com": "r", // read only — must NOT show up for 'c'
}},
Roles: map[string]Role{
"document_controller": {Members: []string{"alice@example.com", "bob@example.com"}},
},
Admins: []string{"super@example.com"},
},
},
HasAnyFile: true,
}
// create: the role (rwcda) + a direct grant (carol rwc) + the admin (bypass).
// The read-only *@example.com pattern is excluded.
got := WhoCan(chain, VerbC)
if want := []string{"document_controller"}; !reflect.DeepEqual(got.Roles, want) {
t.Errorf("VerbC Roles = %v, want %v", got.Roles, want)
}
if want := []string{"alice@example.com", "bob@example.com", "carol@example.com", "super@example.com"}; !reflect.DeepEqual(got.People, want) {
t.Errorf("VerbC People = %v, want %v", got.People, want)
}
// admin verb: only the role (rwcda) + admin bypass; carol (rwc) lacks 'a'.
got = WhoCan(chain, VerbA)
if wcContains(got.People, "carol@example.com") {
t.Errorf("VerbA People = %v, should not include carol (no 'a')", got.People)
}
if !wcContains(got.People, "super@example.com") {
t.Errorf("VerbA People = %v, want the admin", got.People)
}
// read: granted to the *@example.com pattern (a People entry, not a role).
if got = WhoCan(chain, VerbR); !wcContains(got.People, "*@example.com") {
t.Errorf("VerbR People = %v, want to include *@example.com", got.People)
}
// empty chain → nobody named.
if a := WhoCan(PolicyChain{}, VerbC); !a.Empty() {
t.Errorf("empty chain: got %+v, want Empty()", a)
}
}
func wcContains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}