Compare commits
No commits in common. "main" and "tables-v0.0.26" have entirely different histories.
main
...
tables-v0.
45 changed files with 1293 additions and 4890 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 can’t 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) + '/<party>/</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 don’t 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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 || {};
|
||||
// ── 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 || '').trim(), title: r.title || '',
|
||||
trackingNumber: r.trackingNumber || '', 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;
|
||||
}
|
||||
});
|
||||
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()) {
|
||||
// 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.titleOverride) setTitleOverride(k, row.title);
|
||||
}
|
||||
if (a.mdlNodeId && !valid[a.mdlNodeId]) { a.mdlNodeId = null; cleanAssignment(k); }
|
||||
});
|
||||
rebuildIndex();
|
||||
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) {
|
||||
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
|
||||
});
|
||||
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); });
|
||||
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.6–0.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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
})();
|
||||
209
classifier/js/mdl-instantiate.js
Normal file
209
classifier/js/mdl-instantiate.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -8,17 +8,12 @@
|
|||
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 => {
|
||||
|
|
@ -38,8 +33,6 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
* 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
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 & 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" 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 one’s tracking number, revision (e.g. “A (IFR)”), and title. A file that’s 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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 : [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
@ -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 file’s 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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 can’t 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) + '/<party>/</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 don’t 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
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 : [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in a new issue