Compare commits
26 commits
tables-v0.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bfa94d0da8 | |||
| 2b32aced6d | |||
| e58347d476 | |||
| c718334d25 | |||
| 2989e6e847 | |||
| 4830cec2f8 | |||
| 8473ed3393 | |||
| 7c158be73b | |||
| 0d8125a331 | |||
| 60678e552d | |||
| 0847c7a844 | |||
| 36fe38b235 | |||
| 605f4ab3e0 | |||
| 51f5947716 | |||
| 921713d0a4 | |||
| 7c0b66590c | |||
| 3d553ce9d4 | |||
| f66b9c5d55 | |||
| cfdf0f6db9 | |||
| 8e10e5e5e6 | |||
| 93ed0d361f | |||
| 6c3c58bc70 | |||
| 95c9e42270 | |||
| d4d48cad4a | |||
| 93f1eb8d63 | |||
| fd11278417 |
45 changed files with 4891 additions and 1294 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -227,6 +227,20 @@ 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
|
- 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
|
- 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
|
### 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`.
|
**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,6 +7,7 @@ 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.
|
- **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.
|
- **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.
|
- **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
|
## Authoritative docs — read these first
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,8 +173,12 @@
|
||||||
box.querySelector('#acc-cancel').addEventListener('click', function () {
|
box.querySelector('#acc-cancel').addEventListener('click', function () {
|
||||||
close(); reject(new Error('cancelled'));
|
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) {
|
overlay.addEventListener('click', function (e) {
|
||||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,12 @@
|
||||||
box.querySelector('#ct-cancel').addEventListener('click', function () {
|
box.querySelector('#ct-cancel').addEventListener('click', function () {
|
||||||
close(); reject(new Error('cancelled'));
|
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) {
|
overlay.addEventListener('click', function (e) {
|
||||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', onKeydown);
|
||||||
submit.addEventListener('click', function () {
|
submit.addEventListener('click', function () {
|
||||||
|
|
|
||||||
|
|
@ -792,6 +792,13 @@
|
||||||
function openPartyPicker(opts) {
|
function openPartyPicker(opts) {
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
var kindWord = opts.kind === 'folder' ? 'folder' : 'file';
|
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');
|
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;';
|
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');
|
var box = document.createElement('div');
|
||||||
|
|
@ -810,10 +817,10 @@
|
||||||
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/<party>/</code>.' +
|
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/<party>/</code>.' +
|
||||||
'</p>' +
|
'</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;">' +
|
'<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 — create one below.</em>') +
|
(partyList || '<em style="color:#888;">No parties yet.</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;">' +
|
'<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__">' +
|
'<input type="radio" name="pp-party" value="__new__"' + (newPartyAllowed ? '' : ' disabled') + '>' +
|
||||||
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' +
|
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</span></span></label>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' +
|
'<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>' +
|
'<label for="pp-newparty">New party name</label><br>' +
|
||||||
|
|
@ -841,7 +848,11 @@
|
||||||
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
||||||
function cancel() { close(); resolve(null); }
|
function cancel() { close(); resolve(null); }
|
||||||
box.querySelector('#pp-cancel').addEventListener('click', cancel);
|
box.querySelector('#pp-cancel').addEventListener('click', cancel);
|
||||||
overlay.addEventListener('click', function (e) { if (e.target === overlay) 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(); });
|
||||||
box.querySelector('#pp-submit').addEventListener('click', function () {
|
box.querySelector('#pp-submit').addEventListener('click', function () {
|
||||||
var sel = box.querySelector('input[name="pp-party"]:checked');
|
var sel = box.querySelector('input[name="pp-party"]:checked');
|
||||||
if (!sel) { statusError('Pick a party.'); return; }
|
if (!sel) { statusError('Pick a party.'); return; }
|
||||||
|
|
@ -870,8 +881,28 @@
|
||||||
async function createInAggregator(agg, kind) {
|
async function createInAggregator(agg, kind) {
|
||||||
var up = window.app.modules.upload;
|
var up = window.app.modules.upload;
|
||||||
if (!up) return;
|
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 parties = await fetchParties(agg.project);
|
||||||
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties });
|
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 });
|
||||||
if (!choice) return;
|
if (!choice) return;
|
||||||
// Party names are validated to a URL-safe charset, so no encoding
|
// Party names are validated to a URL-safe charset, so no encoding
|
||||||
// needed for the party segment; makeDir/makeFile encode the leaf.
|
// needed for the party segment; makeDir/makeFile encode the leaf.
|
||||||
|
|
@ -894,7 +925,10 @@
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
var msg = (e && e.message) || String(e);
|
var msg = (e && e.message) || String(e);
|
||||||
if (/\b403\b/.test(msg)) {
|
if (/\b403\b/.test(msg)) {
|
||||||
statusError('Not allowed — registering a new party requires the document-controller role.');
|
// 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.');
|
||||||
} else if (/\b409\b/.test(msg)) {
|
} else if (/\b409\b/.test(msg)) {
|
||||||
statusError('Unknown party — register it first (document controller).');
|
statusError('Unknown party — register it first (document controller).');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -195,8 +195,10 @@
|
||||||
box.querySelector('#stage-cancel').addEventListener('click', function () {
|
box.querySelector('#stage-cancel').addEventListener('click', function () {
|
||||||
close(); reject(new Error('cancelled'));
|
close(); reject(new Error('cancelled'));
|
||||||
});
|
});
|
||||||
|
var pressedBackdrop = false;
|
||||||
|
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
|
||||||
overlay.addEventListener('click', function (e) {
|
overlay.addEventListener('click', function (e) {
|
||||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
box.querySelector('#stage-submit').addEventListener('click', function () {
|
box.querySelector('#stage-submit').addEventListener('click', function () {
|
||||||
var sel = box.querySelector('input[name="stage-target"]:checked');
|
var sel = box.querySelector('input[name="stage-target"]:checked');
|
||||||
|
|
@ -246,8 +248,10 @@
|
||||||
box.querySelector('#unstage-cancel').addEventListener('click', function () {
|
box.querySelector('#unstage-cancel').addEventListener('click', function () {
|
||||||
close(); reject(new Error('cancelled'));
|
close(); reject(new Error('cancelled'));
|
||||||
});
|
});
|
||||||
|
var pressedBackdrop = false;
|
||||||
|
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
|
||||||
overlay.addEventListener('click', function (e) {
|
overlay.addEventListener('click', function (e) {
|
||||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
box.querySelector('#unstage-submit').addEventListener('click', function () {
|
box.querySelector('#unstage-submit').addEventListener('click', function () {
|
||||||
var target = input.value.trim();
|
var target = input.value.trim();
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ concat_files \
|
||||||
"../shared/profile-menu.css" \
|
"../shared/profile-menu.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"css/base.css" \
|
"css/base.css" \
|
||||||
|
"../shared/seltable.css" \
|
||||||
"css/layout.css" \
|
"css/layout.css" \
|
||||||
"css/spreadsheet.css" \
|
"css/spreadsheet.css" \
|
||||||
> "$css_temp"
|
> "$css_temp"
|
||||||
|
|
@ -56,13 +57,13 @@ concat_files \
|
||||||
"js/classify.js" \
|
"js/classify.js" \
|
||||||
"js/workspace.js" \
|
"js/workspace.js" \
|
||||||
"js/dnd.js" \
|
"js/dnd.js" \
|
||||||
"js/seltable.js" \
|
"../shared/seltable.js" \
|
||||||
"js/validator.js" \
|
"js/validator.js" \
|
||||||
"js/scanner.js" \
|
"js/scanner.js" \
|
||||||
"js/tree.js" \
|
"js/tree.js" \
|
||||||
|
"js/dir-picker.js" \
|
||||||
"js/target-tree.js" \
|
"js/target-tree.js" \
|
||||||
"js/copy.js" \
|
"js/copy.js" \
|
||||||
"js/mdl-instantiate.js" \
|
|
||||||
"js/spreadsheet.js" \
|
"js/spreadsheet.js" \
|
||||||
"js/selection.js" \
|
"js/selection.js" \
|
||||||
"js/preview.js" \
|
"js/preview.js" \
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@
|
||||||
/* Folder Item */
|
/* Folder Item */
|
||||||
.folder-item {
|
.folder-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start; /* toggle/icon sit on the name line; count drops below */
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
|
@ -269,8 +269,15 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-name {
|
/* Name + count stacked vertically (count below the name, not right-aligned). */
|
||||||
|
.folder-namebox {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
@ -279,7 +286,6 @@
|
||||||
.folder-count {
|
.folder-count {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-children {
|
.folder-children {
|
||||||
|
|
@ -571,51 +577,70 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── By-MDL panel (seltable rows = deliverable drop targets) ─────────────── */
|
/* ── Target tabs: grouped (assign a tracking number) + separate (route) ───── */
|
||||||
#mdlPanel .seltable { height: 100%; }
|
.pane-header--target { flex-wrap: wrap; }
|
||||||
.mdl-rev__input {
|
.target-goal { flex: 1 0 100%; margin: 0 0 0.4rem; font-size: 0.78rem; color: var(--text-muted); line-height: 1.4; }
|
||||||
width: 8rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
.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);
|
||||||
border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem;
|
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; }
|
.seltable__extra { white-space: normal; }
|
||||||
.mdlfile__name { font-size: 0.78rem; }
|
.mdlfile__name { font-size: 0.78rem; }
|
||||||
#mdlPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
|
#worklistPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
|
||||||
#mdlPanel .tfile--err .mdlfile__name { color: var(--danger); }
|
#worklistPanel .tfile--err .mdlfile__name { color: var(--danger); }
|
||||||
#mdlPanel .tfile__remove { opacity: 0.6; }
|
#worklistPanel .tfile__remove { opacity: 0.6; }
|
||||||
#mdlPanel .tfile:hover .tfile__remove { opacity: 1; }
|
#worklistPanel .tfile:hover .tfile__remove { opacity: 1; }
|
||||||
|
|
||||||
/* ── MDL-from-archive overlay ───────────────────────────────────────────── */
|
/* Paste + Match dialogs (inside the .copy-choice modal shell) */
|
||||||
.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; }
|
.scratch-modal__body { margin: 0 0 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; }
|
.scratch-paste__ta {
|
||||||
.mdl-overlay__head { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); }
|
width: 100%; box-sizing: border-box; resize: vertical; font-family: var(--mono, monospace);
|
||||||
.mdl-overlay__head h2 { margin: 0; font-size: 1.1rem; }
|
font-size: 0.8rem; padding: 0.4rem 0.5rem; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
.mdl-overlay__close { background: none; border: none; font-size: 1.6rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; }
|
background: var(--bg); color: var(--text);
|
||||||
.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); }
|
.scratch-paste__preview, .scratch-match__list { max-height: 38vh; overflow: auto; margin-top: 0.6rem; }
|
||||||
.mdl-overlay__table { flex: 1; min-height: 0; }
|
.scratch-preview__table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
|
||||||
.mdl-overlay__foot { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
|
.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); }
|
||||||
|
|
||||||
/* ── Shared selectable + autofilter table (seltable) ────────────────────── */
|
/* The base seltable rules live in shared/seltable.css (bundled by build.sh and
|
||||||
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
shared with the tables tool); only the classifier-specific catalog bits
|
||||||
.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__extra, .worklist-rev__input, .worklist-*, .src-badge, #worklistTable) are
|
||||||
.seltable__filter {
|
here. */
|
||||||
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;
|
|
||||||
}
|
|
||||||
.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 destination dialog ────────────────────────────────────────────── */
|
||||||
.copy-choice__backdrop {
|
.copy-choice__backdrop {
|
||||||
|
|
@ -638,6 +663,18 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
||||||
background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.9rem;
|
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__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 ──────────────────────────────────────── */
|
/* ── By-tracking merged-cell table ──────────────────────────────────────── */
|
||||||
#trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */
|
#trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */
|
||||||
|
|
@ -670,7 +707,15 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
||||||
.trev__inner .tcell__name { color: var(--primary); }
|
.trev__inner .tcell__name { color: var(--primary); }
|
||||||
.tcell__preview { text-decoration: none; cursor: pointer; }
|
.tcell__preview { text-decoration: none; cursor: pointer; }
|
||||||
.tcell__preview:hover { text-decoration: underline; }
|
.tcell__preview:hover { text-decoration: underline; }
|
||||||
.ttable__cell:hover .tnode__actions, .ttable__rev:hover .tnode__actions { opacity: 1; }
|
/* 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 .drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
|
.ttable .drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
|
||||||
.ttable__file { padding: 0.1rem 0.4rem; }
|
.ttable__file { padding: 0.1rem 0.4rem; }
|
||||||
.ttable__drop { color: var(--text-muted); font-style: italic; font-size: 0.75rem; }
|
.ttable__drop { color: var(--text-muted); font-style: italic; font-size: 0.75rem; }
|
||||||
|
|
@ -686,3 +731,33 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
||||||
.tfile__badge { font-size: 0.78rem; flex: 0 0 auto; }
|
.tfile__badge { font-size: 0.78rem; flex: 0 0 auto; }
|
||||||
.tfile__badge--ok { color: var(--success, #16a34a); }
|
.tfile__badge--ok { color: var(--success, #16a34a); }
|
||||||
.tfile__badge--err { color: var(--danger); }
|
.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,8 +374,6 @@
|
||||||
if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); });
|
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.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(); });
|
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).
|
// Live source-tree filter (matches file path + name; reveals the hierarchy).
|
||||||
if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () {
|
if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () {
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@
|
||||||
// table columns + (later) revision-modifier menus. Editable by the user.
|
// table columns + (later) revision-modifier menus. Editable by the user.
|
||||||
var DEFAULT_FIELDS = [
|
var DEFAULT_FIELDS = [
|
||||||
{ name: 'ORIG', optional: false },
|
{ name: 'ORIG', optional: false },
|
||||||
{ name: 'PROJ', optional: false },
|
{ name: 'PHASE', optional: false },
|
||||||
|
{ name: 'PROJECT', optional: false },
|
||||||
|
{ name: 'AREA', optional: false },
|
||||||
{ name: 'DISC', optional: false },
|
{ name: 'DISC', optional: false },
|
||||||
{ name: 'TYPE', optional: false },
|
{ name: 'TYPE', optional: false },
|
||||||
{ name: 'SEQ', optional: false },
|
{ name: 'SEQ', optional: false },
|
||||||
|
|
@ -57,7 +59,8 @@
|
||||||
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
|
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
|
||||||
outputName: null, // remembered output directory display name
|
outputName: null, // remembered output directory display name
|
||||||
config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers)
|
config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers)
|
||||||
mdlList: [], // loaded MDL deliverables (drop targets): [ { id, party, trackingNumber, title, revisionCell } ]
|
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)
|
||||||
};
|
};
|
||||||
|
|
||||||
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
|
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
|
||||||
|
|
@ -123,7 +126,7 @@
|
||||||
function assignmentFor(key) {
|
function assignmentFor(key) {
|
||||||
var a = state.assignments[key];
|
var a = state.assignments[key];
|
||||||
if (!a) {
|
if (!a) {
|
||||||
a = { trackingNodeId: null, transmittalNodeId: null, mdlNodeId: null, excluded: false, titleOverride: null, titleFromDeliverable: true };
|
a = { trackingNodeId: null, transmittalNodeId: null, excluded: false, titleOverride: null };
|
||||||
state.assignments[key] = a;
|
state.assignments[key] = a;
|
||||||
}
|
}
|
||||||
return a;
|
return a;
|
||||||
|
|
@ -132,22 +135,19 @@
|
||||||
function getAssignment(key) { return state.assignments[key] || null; }
|
function getAssignment(key) { return state.assignments[key] || null; }
|
||||||
function cleanAssignment(key) {
|
function cleanAssignment(key) {
|
||||||
var a = state.assignments[key];
|
var a = state.assignments[key];
|
||||||
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.mdlNodeId && !a.excluded && !a.titleOverride) {
|
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) {
|
||||||
delete state.assignments[key];
|
delete state.assignments[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place keys onto a node along one axis ('tracking' | 'transmittal').
|
// Place keys onto a node along one axis ('tracking' | 'transmittal').
|
||||||
// nodeId null clears that axis.
|
// nodeId null clears that axis. (The "From a list" tab also produces
|
||||||
|
// 'tracking' placements — see assignFromRow.)
|
||||||
function place(keys, nodeId, axis) {
|
function place(keys, nodeId, axis) {
|
||||||
var field = axis === 'transmittal' ? 'transmittalNodeId' : axis === 'mdl' ? 'mdlNodeId' : 'trackingNodeId';
|
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
|
||||||
keys.forEach(function (k) {
|
keys.forEach(function (k) {
|
||||||
var a = assignmentFor(k);
|
var a = assignmentFor(k);
|
||||||
a[field] = nodeId || null;
|
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
|
a.excluded = false; // placing un-excludes
|
||||||
cleanAssignment(k);
|
cleanAssignment(k);
|
||||||
});
|
});
|
||||||
|
|
@ -158,7 +158,7 @@
|
||||||
keys.forEach(function (k) {
|
keys.forEach(function (k) {
|
||||||
var a = assignmentFor(k);
|
var a = assignmentFor(k);
|
||||||
a.excluded = !!excluded;
|
a.excluded = !!excluded;
|
||||||
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; a.mdlNodeId = null; }
|
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; }
|
||||||
cleanAssignment(k);
|
cleanAssignment(k);
|
||||||
});
|
});
|
||||||
clearHashConflicts();
|
clearHashConflicts();
|
||||||
|
|
@ -198,9 +198,8 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
(state.mdlList || []).forEach(function (row) {
|
// Scratch-list rows are NOT placement targets — drops materialize real
|
||||||
nodeIndex[row.id] = { node: row, kind: 'mdl', parent: null };
|
// tracking-tree nodes (assignFromRow), so the list isn't indexed here.
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; }
|
function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; }
|
||||||
function infoFor(id) { return nodeIndex[id] || null; }
|
function infoFor(id) { return nodeIndex[id] || null; }
|
||||||
|
|
@ -333,26 +332,10 @@
|
||||||
};
|
};
|
||||||
if (out.excluded) return out;
|
if (out.excluded) return out;
|
||||||
|
|
||||||
// Axis 1 — NAME. An MDL deliverable (alternative to the tracking tree)
|
// Axis 1 — NAME, always the tracking tree. The "From a list" tab drops
|
||||||
// supplies the tracking number + title; its revision comes from the
|
// also produce tracking-tree placements (assignFromRow), so there is a
|
||||||
// classifier-local revision cell. Otherwise the tracking tree.
|
// single name origin.
|
||||||
if (a.mdlNodeId) {
|
if (a.trackingNodeId) {
|
||||||
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);
|
var ti = infoFor(a.trackingNodeId);
|
||||||
if (ti && ti.kind === 'tracking') {
|
if (ti && ti.kind === 'tracking') {
|
||||||
var chain = trackingChain(ti); // [root … node]
|
var chain = trackingChain(ti); // [root … node]
|
||||||
|
|
@ -396,15 +379,6 @@
|
||||||
return out;
|
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.
|
// Per-file classification state for the left-tree markers.
|
||||||
// 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none'
|
// 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none'
|
||||||
function fileState(file) {
|
function fileState(file) {
|
||||||
|
|
@ -442,7 +416,12 @@
|
||||||
transmittalTree: state.transmittalTree,
|
transmittalTree: state.transmittalTree,
|
||||||
outputName: state.outputName,
|
outputName: state.outputName,
|
||||||
config: state.config,
|
config: state.config,
|
||||||
mdlList: state.mdlList,
|
// 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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function load(obj) {
|
function load(obj) {
|
||||||
|
|
@ -452,19 +431,91 @@
|
||||||
state.transmittalTree = obj.transmittalTree || [];
|
state.transmittalTree = obj.transmittalTree || [];
|
||||||
state.outputName = obj.outputName || null;
|
state.outputName = obj.outputName || null;
|
||||||
state.config = normalizeConfig(obj.config);
|
state.config = normalizeConfig(obj.config);
|
||||||
state.mdlList = Array.isArray(obj.mdlList) ? obj.mdlList : [];
|
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; });
|
||||||
rebuildIndex();
|
rebuildIndex();
|
||||||
|
migrateLegacyMdl(obj.worklist); // BEFORE anything can prune; materializes old mdl placements
|
||||||
notify();
|
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
|
// Reset clears the CLASSIFICATION but keeps the pattern config — it's a
|
||||||
// per-project setting, not part of the data being cleared.
|
// per-project setting, not part of the data being cleared.
|
||||||
function reset() {
|
function reset() {
|
||||||
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
|
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
|
||||||
state.outputName = null;
|
state.outputName = null;
|
||||||
|
state.trackingWorkset = Object.create(null);
|
||||||
rebuildIndex();
|
rebuildIndex();
|
||||||
notify();
|
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 ───────────────────────────────────────────────────────
|
// ── pattern config ───────────────────────────────────────────────────────
|
||||||
function normalizeConfig(c) {
|
function normalizeConfig(c) {
|
||||||
var d = defaultConfig();
|
var d = defaultConfig();
|
||||||
|
|
@ -482,39 +533,236 @@
|
||||||
function getTrackingFields() { return state.config.trackingFields; }
|
function getTrackingFields() { return state.config.trackingFields; }
|
||||||
function setConfig(c) { state.config = normalizeConfig(c); notify(); }
|
function setConfig(c) { state.config = normalizeConfig(c); notify(); }
|
||||||
|
|
||||||
// ── MDL deliverables (the "By MDL" drop-target axis) ─────────────────────
|
// ── "From a list" scratch worklist ───────────────────────────────────────
|
||||||
function setMdlList(rows) {
|
// A temporary list of known/typed tracking numbers (from the archive/MDL, a
|
||||||
state.mdlList = (rows || []).map(function (r) {
|
// paste, or a name-match). Dropping a file on a row MATERIALIZES a real
|
||||||
return {
|
// tracking-tree placement (assignFromRow) — the list is pure input, so it can
|
||||||
id: r.id || uid(), party: r.party || '',
|
// be cleared without losing any classification. `placed` is a transient
|
||||||
trackingNumber: r.trackingNumber || '', title: r.title || '',
|
// row→keys hint (not the source of truth, not serialized) used to re-stamp a
|
||||||
revisionCell: r.revisionCell || '',
|
// row's files when its tracking number / revision is edited.
|
||||||
};
|
function rowSource(r) {
|
||||||
|
if (r && r.source) return { mdl: !!r.source.mdl, archive: !!r.source.archive, pasted: !!r.source.pasted };
|
||||||
|
return { mdl: !!(r && r.inMdl), archive: !!(r && Array.isArray(r.archiveRevisions) && r.archiveRevisions.length), pasted: false };
|
||||||
|
}
|
||||||
|
function normalizeRow(r) {
|
||||||
|
r = r || {};
|
||||||
|
return {
|
||||||
|
id: r.id || uid(), party: r.party || '',
|
||||||
|
trackingNumber: (r.trackingNumber || '').trim(), title: r.title || '',
|
||||||
|
revisionCell: r.revisionCell || '',
|
||||||
|
// The file's existing name (pasted col 4) — a join key for name-match.
|
||||||
|
currentName: (r.currentName || '').trim(),
|
||||||
|
source: rowSource(r),
|
||||||
|
archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [],
|
||||||
|
placed: Object.create(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function setWorklist(rows) { state.worklist = (rows || []).map(normalizeRow); notify(); }
|
||||||
|
function appendWorklist(rows) {
|
||||||
|
var byTn = Object.create(null);
|
||||||
|
state.worklist.forEach(function (r) { if (r.trackingNumber) byTn[r.trackingNumber] = r; });
|
||||||
|
(rows || []).forEach(function (raw) {
|
||||||
|
var r = normalizeRow(raw), ex = r.trackingNumber ? byTn[r.trackingNumber] : null;
|
||||||
|
if (ex) {
|
||||||
|
if (!ex.revisionCell && r.revisionCell) ex.revisionCell = r.revisionCell;
|
||||||
|
if (!ex.title && r.title) ex.title = r.title;
|
||||||
|
ex.source.mdl = ex.source.mdl || r.source.mdl;
|
||||||
|
ex.source.archive = ex.source.archive || r.source.archive;
|
||||||
|
ex.source.pasted = ex.source.pasted || r.source.pasted;
|
||||||
|
if (r.archiveRevisions.length && !ex.archiveRevisions.length) ex.archiveRevisions = r.archiveRevisions;
|
||||||
|
} else {
|
||||||
|
state.worklist.push(r);
|
||||||
|
if (r.trackingNumber) byTn[r.trackingNumber] = r;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// 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.mdlNodeId && !valid[a.mdlNodeId]) { a.mdlNodeId = null; cleanAssignment(k); }
|
|
||||||
});
|
|
||||||
rebuildIndex();
|
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
function getMdlList() { return state.mdlList; }
|
function clearWorklist() { state.worklist = []; notify(); } // rows only — assignments survive
|
||||||
function getMdlRow(id) { var i = infoFor(id); return (i && i.kind === 'mdl') ? i.node : null; }
|
function getWorklist() { return state.worklist; }
|
||||||
|
function getWorklistRow(id) { return state.worklist.filter(function (r) { return r.id === id; })[0] || null; }
|
||||||
|
|
||||||
|
// Build (creating folders as needed) the tracking-tree leaf a row points at:
|
||||||
|
// "<tracking>_<rev (status)>". A tracking number's last segment is an
|
||||||
|
// ancestor and the revision is ALWAYS the leaf, so a blank revision gets a
|
||||||
|
// PENDING_REV placeholder leaf (the file shows incomplete until set; the
|
||||||
|
// placeholder is pruned when the real revision lands).
|
||||||
|
var PENDING_REV = 'pending';
|
||||||
|
function leafForRow(row) {
|
||||||
|
var tn = (row.trackingNumber || '').trim();
|
||||||
|
if (!tn) return null;
|
||||||
|
var rev = (row.revisionCell || '').trim() || PENDING_REV;
|
||||||
|
return addTrackingPath(null, parseFolderLevels(tn + '_' + rev));
|
||||||
|
}
|
||||||
|
function assignFromRow(keys, row) {
|
||||||
|
var leaf = leafForRow(row);
|
||||||
|
if (!leaf || !keys || !keys.length) return;
|
||||||
|
place(keys, leaf, 'tracking');
|
||||||
|
keys.forEach(function (k) {
|
||||||
|
row.placed[k] = true;
|
||||||
|
if (row.title && row.title.trim()) {
|
||||||
|
var a = state.assignments[k];
|
||||||
|
if (a && !a.titleOverride) setTitleOverride(k, row.title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
function nodeHasFiles(nodeId) {
|
||||||
|
for (var k in state.assignments) { if (state.assignments[k].trackingNodeId === nodeId) return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Delete a now-empty tracking leaf and any ancestors it leaves empty (so a
|
||||||
|
// re-stamp doesn't litter the By-tracking tree with stale folders).
|
||||||
|
function pruneEmptyTrackingChain(nodeId) {
|
||||||
|
var info = infoFor(nodeId);
|
||||||
|
while (info && info.kind === 'tracking') {
|
||||||
|
if ((info.node.children || []).length || nodeHasFiles(info.node.id)) break;
|
||||||
|
var parent = info.parent; // parent NODE (or null)
|
||||||
|
deleteNode(info.node.id); // rebuilds the index
|
||||||
|
info = parent ? infoFor(parent.id) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-point a row's already-dropped files at the row's current leaf (after its
|
||||||
|
// tracking number or revision was edited). Skips keys the user has since
|
||||||
|
// un-placed elsewhere; prunes the leaves it empties.
|
||||||
|
function restampRow(row) {
|
||||||
|
var keys = Object.keys(row.placed || {});
|
||||||
|
if (!keys.length) return;
|
||||||
|
var leaf = leafForRow(row);
|
||||||
|
if (!leaf) return;
|
||||||
|
var old = Object.create(null);
|
||||||
|
keys.forEach(function (k) {
|
||||||
|
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 setRevisionCell(rowId, value) { setRevisionCells([rowId], value); }
|
function setRevisionCell(rowId, value) { setRevisionCells([rowId], value); }
|
||||||
function setRevisionCells(rowIds, value) {
|
function setRevisionCells(rowIds, value) {
|
||||||
var set = Object.create(null); (rowIds || []).forEach(function (i) { set[i] = true; });
|
var set = Object.create(null); (rowIds || []).forEach(function (i) { set[i] = true; });
|
||||||
var changed = false;
|
var changed = false;
|
||||||
state.mdlList.forEach(function (r) { if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); changed = true; } });
|
state.worklist.forEach(function (r) {
|
||||||
|
if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); restampRow(r); changed = true; }
|
||||||
|
});
|
||||||
if (changed) notify();
|
if (changed) notify();
|
||||||
}
|
}
|
||||||
function setTitleFromDeliverable(key, fromDeliverable) {
|
|
||||||
var a = assignmentFor(key);
|
// ── paste parsing + name matching (pure helpers, unit-tested) ─────────────
|
||||||
a.titleFromDeliverable = !!fromDeliverable;
|
// Parse Excel/TSV text into scratch rows. Columns: Tracking ⇥ Rev(Status) ⇥
|
||||||
cleanAssignment(key);
|
// Title; a 4th bare-status column merges into the revision; a lone cell that
|
||||||
notify();
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── add-folder pattern expansion ─────────────────────────────────────────
|
// ── add-folder pattern expansion ─────────────────────────────────────────
|
||||||
|
|
@ -691,13 +939,19 @@
|
||||||
transmittalRecord: transmittalRecord,
|
transmittalRecord: transmittalRecord,
|
||||||
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
|
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
|
||||||
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
|
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
|
||||||
setMdlList: setMdlList, getMdlList: getMdlList, getMdlRow: getMdlRow,
|
// 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,
|
||||||
setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells,
|
setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells,
|
||||||
setTitleFromDeliverable: setTitleFromDeliverable,
|
parsePastedRows: parsePastedRows, proposeMatches: proposeMatches,
|
||||||
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
|
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
|
||||||
getTransmittalTree: function () { return state.transmittalTree; },
|
getTransmittalTree: function () { return state.transmittalTree; },
|
||||||
// derive + reverse
|
// derive + reverse
|
||||||
deriveTarget: deriveTarget, filesInNode: filesInNode,
|
deriveTarget: deriveTarget,
|
||||||
fileState: fileState, stats: stats,
|
fileState: fileState, stats: stats,
|
||||||
// persistence
|
// persistence
|
||||||
serialize: serialize, load: load, reset: reset,
|
serialize: serialize, load: load, reset: reset,
|
||||||
|
|
|
||||||
|
|
@ -326,7 +326,9 @@
|
||||||
row.appendChild(go); row.appendChild(cancel);
|
row.appendChild(go); row.appendChild(cancel);
|
||||||
box.appendChild(h); box.appendChild(p); box.appendChild(sel); box.appendChild(row);
|
box.appendChild(h); box.appendChild(p); box.appendChild(sel); box.appendChild(row);
|
||||||
back.appendChild(box);
|
back.appendChild(box);
|
||||||
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
|
var pressedBackdrop = false;
|
||||||
|
back.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === back); });
|
||||||
|
back.addEventListener('click', function (e) { if (e.target === back && pressedBackdrop) finish(null); });
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
document.body.appendChild(back);
|
document.body.appendChild(back);
|
||||||
});
|
});
|
||||||
|
|
@ -359,7 +361,9 @@
|
||||||
row.appendChild(btn('Cancel', 'btn-secondary', null));
|
row.appendChild(btn('Cancel', 'btn-secondary', null));
|
||||||
box.appendChild(h); box.appendChild(p); box.appendChild(row);
|
box.appendChild(h); box.appendChild(p); box.appendChild(row);
|
||||||
back.appendChild(box);
|
back.appendChild(box);
|
||||||
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
|
var pressedBackdrop = false;
|
||||||
|
back.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === back); });
|
||||||
|
back.addEventListener('click', function (e) { if (e.target === back && pressedBackdrop) finish(null); });
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
document.body.appendChild(back);
|
document.body.appendChild(back);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
137
classifier/js/dir-picker.js
Normal file
137
classifier/js/dir-picker.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,32 +8,39 @@
|
||||||
let resizingColumn = null;
|
let resizingColumn = null;
|
||||||
let startX = 0;
|
let startX = 0;
|
||||||
let startWidth = 0;
|
let startWidth = 0;
|
||||||
|
let activeTable = null;
|
||||||
|
let activeOnResize = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize column resizing
|
* 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.
|
||||||
*/
|
*/
|
||||||
function init() {
|
function init(table, onResize) {
|
||||||
const table = window.app.dom.spreadsheet;
|
table = table || (window.app.dom && window.app.dom.spreadsheet);
|
||||||
|
if (!table) return;
|
||||||
const headers = table.querySelectorAll('thead th');
|
const headers = table.querySelectorAll('thead th');
|
||||||
|
|
||||||
headers.forEach(th => {
|
headers.forEach(th => {
|
||||||
// Skip if resize handle already exists
|
// Skip if resize handle already exists
|
||||||
if (th.querySelector('.column-resizer')) return;
|
if (th.querySelector('.column-resizer')) return;
|
||||||
|
|
||||||
// Add resize handle
|
// Add resize handle
|
||||||
const resizer = document.createElement('div');
|
const resizer = document.createElement('div');
|
||||||
resizer.className = 'column-resizer';
|
resizer.className = 'column-resizer';
|
||||||
th.appendChild(resizer);
|
th.appendChild(resizer);
|
||||||
|
|
||||||
// Mouse down on resizer
|
// Mouse down on resizer
|
||||||
resizer.addEventListener('mousedown', (e) => {
|
resizer.addEventListener('mousedown', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
resizingColumn = th;
|
resizingColumn = th;
|
||||||
startX = e.pageX;
|
startX = e.pageX;
|
||||||
startWidth = th.offsetWidth;
|
startWidth = th.offsetWidth;
|
||||||
|
activeTable = table;
|
||||||
|
activeOnResize = onResize || null;
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
});
|
});
|
||||||
|
|
@ -61,6 +68,8 @@
|
||||||
resizingColumn = null;
|
resizingColumn = null;
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
if (activeOnResize && activeTable) { try { activeOnResize(activeTable); } catch (_) { /* ignore */ } }
|
||||||
|
activeTable = null; activeOnResize = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export module
|
// Export module
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,17 @@
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'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 ────────────────────────────────────────────
|
// ── Classify & Copy helpers ────────────────────────────────────────────
|
||||||
function classifyOn() {
|
function classifyOn() {
|
||||||
var c = window.app.modules.classify;
|
var c = window.app.modules.classify;
|
||||||
|
|
@ -58,8 +69,8 @@
|
||||||
var tt = window.app.modules.targetTree;
|
var tt = window.app.modules.targetTree;
|
||||||
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
|
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
|
||||||
}
|
}
|
||||||
function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : ax === 'mdl' ? 'mdlNodeId' : 'trackingNodeId'; }
|
function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId'; }
|
||||||
// Bucket a file relative to the active axis (tracking | transmittal | mdl):
|
// Bucket a file relative to the active axis (tracking | transmittal):
|
||||||
// 'excluded' | 'assigned' (on this axis) | 'partial' (assigned on a DIFFERENT
|
// 'excluded' | 'assigned' (on this axis) | 'partial' (assigned on a DIFFERENT
|
||||||
// axis only — the to-do for this tab) | 'unassigned' (no axis).
|
// axis only — the to-do for this tab) | 'unassigned' (no axis).
|
||||||
function fileCategory(file) {
|
function fileCategory(file) {
|
||||||
|
|
@ -68,7 +79,7 @@
|
||||||
if (a && a.excluded) return 'excluded';
|
if (a && a.excluded) return 'excluded';
|
||||||
var ax = activeAxis();
|
var ax = activeAxis();
|
||||||
if (a && a[axisField(ax)]) return 'assigned';
|
if (a && a[axisField(ax)]) return 'assigned';
|
||||||
var others = ['tracking', 'transmittal', 'mdl'].filter(function (x) { return x !== ax; });
|
var others = ['tracking', 'transmittal'].filter(function (x) { return x !== ax; });
|
||||||
var any = a && others.some(function (x) { return a[axisField(x)]; });
|
var any = a && others.some(function (x) { return a[axisField(x)]; });
|
||||||
return any ? 'partial' : 'unassigned';
|
return any ? 'partial' : 'unassigned';
|
||||||
}
|
}
|
||||||
|
|
@ -100,42 +111,40 @@
|
||||||
var visible = null; // { folders, files } while filtering, else null
|
var visible = null; // { folders, files } while filtering, else null
|
||||||
function computeVisible() {
|
function computeVisible() {
|
||||||
var c = window.app.modules.classify;
|
var c = window.app.modules.classify;
|
||||||
var folders = Object.create(null), files = Object.create(null), open = Object.create(null);
|
var folders = Object.create(null), files = Object.create(null), counts = Object.create(null);
|
||||||
var nf = filterActive();
|
var nf = filterActive();
|
||||||
function walk(folder, ancMatched) {
|
function walk(folder, ancMatched) {
|
||||||
var selfMatch = nf && nameHit(folder.path || folder.name);
|
var selfMatch = nf && nameHit(folder.path || folder.name);
|
||||||
var matched = ancMatched || selfMatch;
|
var matched = ancMatched || selfMatch;
|
||||||
var show = false, hasFile = false, descMatch = false;
|
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) {
|
(folder.children || []).forEach(function (ch) {
|
||||||
var r = walk(ch, matched);
|
var r = walk(ch, matched);
|
||||||
if (r.show) show = true;
|
if (r.show) { show = true; dDir++; tDir += 1 + r.tDir; }
|
||||||
if (r.hasFile) hasFile = true;
|
if (r.hasFile) hasFile = true;
|
||||||
if (r.subtreeMatch) descMatch = true; // a child leads to a match
|
if (r.subtreeMatch) descMatch = true; // a child leads to a match
|
||||||
|
tFile += r.tFile;
|
||||||
});
|
});
|
||||||
(folder.files || []).forEach(function (f) {
|
(folder.files || []).forEach(function (f) {
|
||||||
hasFile = true;
|
hasFile = true;
|
||||||
if (!classifyAllows(f)) return;
|
if (!classifyAllows(f)) return;
|
||||||
var fileMatch = nf && nameHit(c.srcKeyForFile(f));
|
var fileMatch = nf && nameHit(c.srcKeyForFile(f));
|
||||||
if (!nf || matched || fileMatch) { files[c.srcKeyForFile(f)] = true; show = true; }
|
if (!nf || matched || fileMatch) { files[c.srcKeyForFile(f)] = true; show = true; dFile++; }
|
||||||
if (fileMatch) descMatch = true; // a match sits directly in this folder
|
if (fileMatch) descMatch = true; // a match sits directly in this folder
|
||||||
});
|
});
|
||||||
|
tFile += dFile;
|
||||||
if (matched) show = true;
|
if (matched) show = true;
|
||||||
// "Show Empty" off → hide folders whose whole subtree holds no files.
|
// "Show Empty" off → hide folders whose whole subtree holds no files.
|
||||||
if (!hasFile && !showEmpty && !matched) show = false;
|
if (!hasFile && !showEmpty && !matched) show = false;
|
||||||
if (show) folders[folder.path] = true;
|
if (show) folders[folder.path] = true;
|
||||||
// Auto-open ONLY the connector folders on the path down to a match —
|
counts[folder.path] = { dDir: dDir, tDir: tDir, dFile: dFile, tFile: tFile };
|
||||||
// never the matched node itself. Terminal matches and everything
|
return { show: show, hasFile: hasFile, subtreeMatch: descMatch || selfMatch, tDir: tDir, tFile: tFile };
|
||||||
// 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); });
|
(window.app.folderTree || []).forEach(function (root) { walk(root, false); });
|
||||||
return { folders: folders, files: files, open: open };
|
return { folders: folders, files: files, counts: counts };
|
||||||
}
|
}
|
||||||
// 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 folderShown(folder) { return !visible || !!visible.folders[folder.path]; }
|
||||||
function fileShown(file) {
|
function fileShown(file) {
|
||||||
if (!classifyAllows(file)) return false;
|
if (!classifyAllows(file)) return false;
|
||||||
|
|
@ -175,7 +184,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.app.folderTree.forEach(folder => {
|
sortedFolders(window.app.folderTree).forEach(folder => {
|
||||||
if (!folderShown(folder)) return;
|
if (!folderShown(folder)) return;
|
||||||
const element = createFolderElement(folder);
|
const element = createFolderElement(folder);
|
||||||
container.appendChild(element);
|
container.appendChild(element);
|
||||||
|
|
@ -208,8 +217,13 @@
|
||||||
const done = st === 'done';
|
const done = st === 'done';
|
||||||
// When fully scanned both numbers are blue; .done turns the labels blue too.
|
// When fully scanned both numbers are blue; .done turns the labels blue too.
|
||||||
if (done) el.classList.add('done');
|
if (done) el.classList.add('done');
|
||||||
const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
|
// While a filter (autofilter or a Show checkbox) is narrowing the tree,
|
||||||
const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
|
// 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 frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
frag.appendChild(document.createTextNode('('));
|
frag.appendChild(document.createTextNode('('));
|
||||||
|
|
@ -291,7 +305,7 @@
|
||||||
// expandable so its files can be revealed and dragged.
|
// expandable so its files can be revealed and dragged.
|
||||||
|| (classifyOn() && folder.files && folder.files.length > 0);
|
|| (classifyOn() && folder.files && folder.files.length > 0);
|
||||||
if (mightHaveChildren) {
|
if (mightHaveChildren) {
|
||||||
toggle.textContent = (folder.expanded || autoOpen(folder)) ? '▼' : '▶';
|
toggle.textContent = folder.expanded ? '▼' : '▶';
|
||||||
toggle.addEventListener('click', (e) => {
|
toggle.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const recursive = e.ctrlKey || e.metaKey;
|
const recursive = e.ctrlKey || e.metaKey;
|
||||||
|
|
@ -322,18 +336,24 @@
|
||||||
if (agg === 'excluded') item.classList.add('excluded');
|
if (agg === 'excluded') item.classList.add('excluded');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder name
|
// 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';
|
||||||
|
|
||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
name.className = 'folder-name';
|
name.className = 'folder-name';
|
||||||
name.textContent = folder.name;
|
name.textContent = folder.name;
|
||||||
item.appendChild(name);
|
namebox.appendChild(name);
|
||||||
|
|
||||||
// Subfolder / file counts (immediate). Greyed via the row's .scanning
|
// Subfolder / file counts (immediate). Greyed via the row's .scanning
|
||||||
// class until the subtree is fully scanned.
|
// class until the subtree is fully scanned.
|
||||||
const count = document.createElement('span');
|
const count = document.createElement('span');
|
||||||
count.className = 'folder-count';
|
count.className = 'folder-count';
|
||||||
populateCount(count, folder);
|
populateCount(count, folder);
|
||||||
item.appendChild(count);
|
namebox.appendChild(count);
|
||||||
|
|
||||||
|
item.appendChild(namebox);
|
||||||
|
|
||||||
// Extract button for ZIP roots
|
// Extract button for ZIP roots
|
||||||
if (folder.isZipRoot) {
|
if (folder.isZipRoot) {
|
||||||
|
|
@ -355,12 +375,15 @@
|
||||||
|
|
||||||
div.appendChild(item);
|
div.appendChild(item);
|
||||||
|
|
||||||
// Children — when expanded, or opened on the path to a search hit below.
|
// Children render ONLY when the user has expanded this folder. The
|
||||||
// The Show toggles never force-expand; search opens only connector folders.
|
// autofilter and Show toggles never change expand/collapse state — they
|
||||||
if ((folder.expanded || autoOpen(folder)) && folder.children && folder.children.length > 0) {
|
// 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) {
|
||||||
const childrenDiv = document.createElement('div');
|
const childrenDiv = document.createElement('div');
|
||||||
childrenDiv.className = 'folder-children';
|
childrenDiv.className = 'folder-children';
|
||||||
folder.children.forEach(child => {
|
sortedFolders(folder.children).forEach(child => {
|
||||||
if (!folderShown(child)) return;
|
if (!folderShown(child)) return;
|
||||||
const childElement = createFolderElement(child, level + 1);
|
const childElement = createFolderElement(child, level + 1);
|
||||||
childrenDiv.appendChild(childElement);
|
childrenDiv.appendChild(childElement);
|
||||||
|
|
@ -368,12 +391,12 @@
|
||||||
div.appendChild(childrenDiv);
|
div.appendChild(childrenDiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify mode: list this folder's own files (draggable leaves) when
|
// Classify mode: list this folder's own files (draggable leaves) only
|
||||||
// expanded (or opened to reveal a search hit), so they can be dropped.
|
// when the user has expanded it (the filter never force-expands).
|
||||||
if (classifyOn() && (folder.expanded || autoOpen(folder)) && folder.files && folder.files.length > 0) {
|
if (classifyOn() && folder.expanded && folder.files && folder.files.length > 0) {
|
||||||
const filesDiv = document.createElement('div');
|
const filesDiv = document.createElement('div');
|
||||||
filesDiv.className = 'folder-children folder-files';
|
filesDiv.className = 'folder-children folder-files';
|
||||||
folder.files.forEach(function (file) {
|
sortedFiles(folder.files).forEach(function (file) {
|
||||||
if (!fileShown(file)) return;
|
if (!fileShown(file)) return;
|
||||||
filesDiv.appendChild(createFileElement(file, level + 1));
|
filesDiv.appendChild(createFileElement(file, level + 1));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@
|
||||||
<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="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>
|
<button id="modeRenameBtn" class="mode-btn" title="Edit a spreadsheet and rename the files in place (edits the source)">Rename in place</button>
|
||||||
</div>
|
</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="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>
|
<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>
|
</div>
|
||||||
|
|
@ -159,11 +158,17 @@
|
||||||
|
|
||||||
<!-- Target Trees (Classify & Copy mode) — default view -->
|
<!-- Target Trees (Classify & Copy mode) — default view -->
|
||||||
<main class="target-pane" id="targetPane">
|
<main class="target-pane" id="targetPane">
|
||||||
<div class="pane-header">
|
<div class="pane-header pane-header--target">
|
||||||
|
<p class="target-goal">Each file needs a <strong>tracking number</strong> (revision + status + title) and a <strong>transmittal folder</strong>. Name it — build one under <em>By tracking number</em>, or 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="target-tabs" role="tablist">
|
<div class="target-tabs" role="tablist">
|
||||||
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
|
<div class="target-tabs__group">
|
||||||
<button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
|
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
|
||||||
<button class="target-tab" id="mdlTab" role="tab">By MDL</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>
|
||||||
</div>
|
</div>
|
||||||
<div class="pane-header-right">
|
<div class="pane-header-right">
|
||||||
<span id="classifyStats" class="file-stats"></span>
|
<span id="classifyStats" class="file-stats"></span>
|
||||||
|
|
@ -179,11 +184,11 @@
|
||||||
<div class="target-body">
|
<div class="target-body">
|
||||||
<section id="trackingPanel" class="target-panel">
|
<section id="trackingPanel" class="target-panel">
|
||||||
<div class="target-panel__toolbar">
|
<div class="target-panel__toolbar">
|
||||||
<button id="addTrackingRootBtn" class="btn btn-sm btn-secondary">+ Root folder</button>
|
<button id="trackingColsBtn" class="btn btn-sm btn-secondary" title="Show or hide columns">Columns ▾</button>
|
||||||
<span class="target-hint">Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”.</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<input type="search" id="trackingFilterInput" class="tree-filter target-filter" spellcheck="false"
|
<input type="search" id="trackingFilterInput" class="tree-filter target-filter" spellcheck="false"
|
||||||
placeholder="Filter the tracking tree…" aria-label="Filter tracking tree">
|
placeholder="Filter the grid…" aria-label="Filter the tracking grid">
|
||||||
<div id="trackingTree" class="target-tree"></div>
|
<div id="trackingTree" class="target-tree"></div>
|
||||||
</section>
|
</section>
|
||||||
<section id="transmittalPanel" class="target-panel" hidden>
|
<section id="transmittalPanel" class="target-panel" hidden>
|
||||||
|
|
@ -195,12 +200,21 @@
|
||||||
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
|
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
|
||||||
<div id="transmittalTree" class="target-tree"></div>
|
<div id="transmittalTree" class="target-tree"></div>
|
||||||
</section>
|
</section>
|
||||||
<section id="mdlPanel" class="target-panel" hidden>
|
<!-- "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>
|
||||||
<div class="target-panel__toolbar">
|
<div class="target-panel__toolbar">
|
||||||
<button id="loadMdlBtn" class="btn btn-sm btn-secondary">⊞ Load MDL…</button>
|
<button id="loadWorklistBtn" class="btn btn-sm btn-secondary" title="Add tracking numbers from the project archive/MDL (pick directories to scan).">⊞ Load…</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div id="mdlTree" class="target-tree"></div>
|
<div id="worklistTable" class="target-tree"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,21 @@ body {
|
||||||
text-decoration: underline;
|
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 {
|
#projectView ol {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,15 @@
|
||||||
</div><!-- /projectView -->
|
</div><!-- /projectView -->
|
||||||
</main>
|
</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 -->
|
<!-- Help Panel -->
|
||||||
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
||||||
<div class="help-panel__header">
|
<div class="help-panel__header">
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,14 @@ export default defineConfig({
|
||||||
name: 'tables',
|
name: 'tables',
|
||||||
testMatch: 'tables.spec.js',
|
testMatch: 'tables.spec.js',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'cap',
|
||||||
|
testMatch: 'cap.spec.js',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tables-mdl',
|
||||||
|
testMatch: 'tables-mdl.spec.js',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'zddc-filter',
|
name: 'zddc-filter',
|
||||||
testMatch: 'zddc-filter.spec.js',
|
testMatch: 'zddc-filter.spec.js',
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,48 @@
|
||||||
a: 'edit access rules'
|
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
|
// handleForbidden(resp, opts) — render a 403 toast naming the
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
// missing verb. opts.path (optional) is the URL the failed request
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
// hit; when provided, the helper consults /.profile/access?path= to
|
||||||
|
|
@ -111,8 +153,9 @@
|
||||||
async function handleForbidden(resp, opts) {
|
async function handleForbidden(resp, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var missing = '';
|
var missing = '';
|
||||||
|
var body = null;
|
||||||
try {
|
try {
|
||||||
var body = await resp.clone().json();
|
body = await resp.clone().json();
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
if (body && typeof body.missing_verb === 'string') {
|
||||||
missing = body.missing_verb;
|
missing = body.missing_verb;
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +170,16 @@
|
||||||
msg = prefix + 'Forbidden.';
|
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
|
// Optional elevate offer: only when the caller supplied a
|
||||||
// path AND the path-scoped access view reports an elevation
|
// path AND the path-scoped access view reports an elevation
|
||||||
// grant covering the missing verb. Render as a clickable
|
// grant covering the missing verb. Render as a clickable
|
||||||
|
|
@ -159,5 +212,5 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
47
shared/seltable.css
Normal file
47
shared/seltable.css
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
/* ── 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; }
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
/**
|
/**
|
||||||
* ZDDC Classifier — shared selectable + autofilter table.
|
* ZDDC — shared selectable + autofilter table (used by the classifier catalog
|
||||||
|
* and the tables tool's "Add from archive").
|
||||||
*
|
*
|
||||||
* A flat table with one global autofilter (AND of space-separated terms over
|
* A flat table with PER-COLUMN autofilters (one input per column, AND-combined,
|
||||||
* every column) and powerful selection for building complex sets quickly:
|
* 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
|
* click replace selection + set anchor
|
||||||
* ctrl/cmd-click toggle one row
|
* ctrl/cmd-click toggle one row
|
||||||
* shift-click range from the anchor (replaces the selection)
|
* shift-click range from the anchor (replaces the selection)
|
||||||
* ctrl-shift-click ADD the anchor→row range to the existing selection
|
* ctrl-shift-click ADD the anchor→row range to the existing selection
|
||||||
* ctrl/cmd-Enter fire onActivate(selectedIds) — a bulk action
|
* ctrl/cmd-Enter fire onActivate(selectedIds) — a bulk action
|
||||||
* Esc clear
|
* Esc clear
|
||||||
* Ranges run over the CURRENTLY FILTERED order, so "filter to a transmittal,
|
* Ranges run over the CURRENTLY FILTERED order. Selection is keyed by a stable
|
||||||
* then shift-select the visible block" works. Selection is keyed by a stable
|
|
||||||
* rowId so it survives filtering and re-render.
|
* 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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
@ -41,17 +39,28 @@
|
||||||
var getRows = (typeof opts.rows === 'function') ? opts.rows : function () { return opts.rows || []; };
|
var getRows = (typeof opts.rows === 'function') ? opts.rows : function () { return opts.rows || []; };
|
||||||
var selected = Object.create(null); // id -> true
|
var selected = Object.create(null); // id -> true
|
||||||
var anchorId = null;
|
var anchorId = null;
|
||||||
var ft = []; // global filter terms
|
var globalTerms = []; // programmatic global filter (tests/reveal)
|
||||||
|
var colFilters = Object.create(null); // colKey -> terms[] (the per-column autofilters)
|
||||||
|
|
||||||
function rows() { return getRows() || []; }
|
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 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 rowBlob(row) { var s = ''; for (var i = 0; i < columns.length; i++) { s += colVal(columns[i], row) + ' '; } return s; }
|
||||||
function filtered() { return ft.length ? rows().filter(function (r) { return hit(rowBlob(r), ft); }) : rows().slice(); }
|
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 getSelection() { return Object.keys(selected); }
|
||||||
function getFilteredRows() { return filtered(); }
|
function getFilteredRows() { return filtered(); }
|
||||||
function fireSel() { if (opts.onSelectionChange) opts.onSelectionChange(getSelection()); }
|
function fireSel() { if (opts.onSelectionChange) opts.onSelectionChange(getSelection()); }
|
||||||
function setFilter(q) { ft = terms(q); renderBody(); }
|
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 selectAllFiltered() { filtered().forEach(function (r) { selected[rowId(r)] = true; }); anchorId = null; renderBody(); fireSel(); }
|
||||||
function clearSel() { selected = Object.create(null); anchorId = null; renderBody(); fireSel(); }
|
function clearSel() { selected = Object.create(null); anchorId = null; renderBody(); fireSel(); }
|
||||||
|
|
||||||
|
|
@ -75,15 +84,12 @@
|
||||||
container.textContent = '';
|
container.textContent = '';
|
||||||
container.classList.add('seltable');
|
container.classList.add('seltable');
|
||||||
var bar = elt('div', 'seltable__bar');
|
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');
|
var allBtn = elt('button', 'btn btn-sm btn-secondary', 'Select filtered');
|
||||||
allBtn.addEventListener('click', selectAllFiltered);
|
allBtn.addEventListener('click', selectAllFiltered);
|
||||||
var clrBtn = elt('button', 'btn btn-sm btn-secondary', 'Clear');
|
var clrBtn = elt('button', 'btn btn-sm btn-secondary', 'Clear');
|
||||||
clrBtn.addEventListener('click', clearSel);
|
clrBtn.addEventListener('click', clearSel);
|
||||||
countEl = elt('span', 'seltable__count');
|
countEl = elt('span', 'seltable__count');
|
||||||
bar.appendChild(filterEl); bar.appendChild(allBtn); bar.appendChild(clrBtn); bar.appendChild(countEl);
|
bar.appendChild(allBtn); bar.appendChild(clrBtn); bar.appendChild(countEl);
|
||||||
container.appendChild(bar);
|
container.appendChild(bar);
|
||||||
|
|
||||||
var scroll = elt('div', 'seltable__scroll');
|
var scroll = elt('div', 'seltable__scroll');
|
||||||
|
|
@ -91,7 +97,22 @@
|
||||||
var thead = elt('thead'), htr = elt('tr');
|
var thead = elt('thead'), htr = elt('tr');
|
||||||
columns.forEach(function (c) { htr.appendChild(elt('th', c.cls || null, c.title || c.key)); });
|
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 || ''));
|
if (opts.rowExtra) htr.appendChild(elt('th', 'seltable__extrah', opts.extraTitle || ''));
|
||||||
thead.appendChild(htr); table.appendChild(thead);
|
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);
|
bodyEl = elt('tbody'); table.appendChild(bodyEl);
|
||||||
scroll.appendChild(table); container.appendChild(scroll);
|
scroll.appendChild(table); container.appendChild(scroll);
|
||||||
|
|
||||||
|
|
@ -111,7 +132,7 @@
|
||||||
var tr = elt('tr', 'seltable__row' + (selected[id] ? ' is-selected' : ''));
|
var tr = elt('tr', 'seltable__row' + (selected[id] ? ' is-selected' : ''));
|
||||||
tr.dataset.id = id;
|
tr.dataset.id = id;
|
||||||
tr.addEventListener('click', function (e) {
|
tr.addEventListener('click', function (e) {
|
||||||
if (e.target.closest('input,button,select,a,[data-no-select]')) return; // let controls work
|
if (e.target.closest('input,button,select,a,[data-no-select]')) return;
|
||||||
onRowClick(e, row, fr);
|
onRowClick(e, row, fr);
|
||||||
});
|
});
|
||||||
if (opts.onRowDrop) {
|
if (opts.onRowDrop) {
|
||||||
|
|
@ -137,15 +158,14 @@
|
||||||
});
|
});
|
||||||
if (countEl) {
|
if (countEl) {
|
||||||
var nSel = getSelection().length;
|
var nSel = getSelection().length;
|
||||||
countEl.textContent = fr.length + ' shown' + (nSel ? (' · ' + nSel + ' selected') : '');
|
countEl.textContent = fr.length + ' shown' + (nSel ? ' · ' + nSel + ' selected' : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
render: render, renderBody: renderBody,
|
render: render, renderBody: renderBody,
|
||||||
getSelection: getSelection, getFilteredRows: getFilteredRows,
|
getSelection: getSelection, getFilteredRows: getFilteredRows,
|
||||||
setFilter: setFilter, selectAllFiltered: selectAllFiltered, clear: clearSel,
|
setFilter: setFilter, setColFilter: setColFilter, selectAllFiltered: selectAllFiltered, clear: clearSel,
|
||||||
// test seam: simulate a row click with modifier keys.
|
|
||||||
clickRow: function (id, mods) {
|
clickRow: function (id, mods) {
|
||||||
var fr = filtered();
|
var fr = filtered();
|
||||||
var row = fr.filter(function (r) { return String(rowId(r)) === String(id); })[0];
|
var row = fr.filter(function (r) { return String(rowId(r)) === String(id); })[0];
|
||||||
|
|
@ -25,6 +25,7 @@ concat_files \
|
||||||
"../shared/profile-menu.css" \
|
"../shared/profile-menu.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"../shared/context-menu.css" \
|
"../shared/context-menu.css" \
|
||||||
|
"../shared/seltable.css" \
|
||||||
"css/table.css" \
|
"css/table.css" \
|
||||||
"../form/css/form.css" \
|
"../form/css/form.css" \
|
||||||
> "$css_temp"
|
> "$css_temp"
|
||||||
|
|
@ -46,6 +47,7 @@ concat_files \
|
||||||
"../shared/profile-menu.js" \
|
"../shared/profile-menu.js" \
|
||||||
"../shared/cap.js" \
|
"../shared/cap.js" \
|
||||||
"../shared/context-menu.js" \
|
"../shared/context-menu.js" \
|
||||||
|
"../shared/seltable.js" \
|
||||||
"js/mode.js" \
|
"js/mode.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/context.js" \
|
"js/context.js" \
|
||||||
|
|
@ -61,6 +63,7 @@ concat_files \
|
||||||
"js/export.js" \
|
"js/export.js" \
|
||||||
"js/render.js" \
|
"js/render.js" \
|
||||||
"js/api-actions.js" \
|
"js/api-actions.js" \
|
||||||
|
"js/mdl-from-archive.js" \
|
||||||
"js/main.js" \
|
"js/main.js" \
|
||||||
"../form/js/app.js" \
|
"../form/js/app.js" \
|
||||||
"../form/js/context.js" \
|
"../form/js/context.js" \
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,9 @@
|
||||||
if (verbs.indexOf('c') === -1) {
|
if (verbs.indexOf('c') === -1) {
|
||||||
addRowBtn.classList.add('is-disabled');
|
addRowBtn.classList.add('is-disabled');
|
||||||
addRowBtn.setAttribute('aria-disabled', 'true');
|
addRowBtn.setAttribute('aria-disabled', 'true');
|
||||||
addRowBtn.title = "You don't have create access in this folder.";
|
// 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.";
|
||||||
// Swallow clicks so the no-op feedback is the
|
// Swallow clicks so the no-op feedback is the
|
||||||
// tooltip, not a 403 toast on submission.
|
// tooltip, not a 403 toast on submission.
|
||||||
addRowBtn.addEventListener('click', function (ev) {
|
addRowBtn.addEventListener('click', function (ev) {
|
||||||
|
|
@ -167,6 +169,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "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 columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||||
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
||||||
|
|
||||||
|
|
|
||||||
184
tables/js/mdl-from-archive.js
Normal file
184
tables/js/mdl-from-archive.js
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
// 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,6 +43,7 @@
|
||||||
<div class="table-toolbar__right">
|
<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-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-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>
|
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -271,11 +271,10 @@ test.describe('Browse menu — context & tiers', () => {
|
||||||
expect(res.rwd).toContain('Delete…');
|
expect(res.rwd).toContain('Delete…');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('toolbar Sort and Show-hidden drive state; New buttons present', async ({ page }) => {
|
// 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 }) => {
|
||||||
await openWithTree(page);
|
await openWithTree(page);
|
||||||
await expect(page.locator('#newFolderBtn')).toBeVisible();
|
|
||||||
await expect(page.locator('#newFileBtn')).toBeVisible();
|
|
||||||
|
|
||||||
await page.locator('#sortSelect').selectOption('date:-1');
|
await page.locator('#sortSelect').selectOption('date:-1');
|
||||||
expect(await page.evaluate(() => window.app.state.sort)).toEqual({ key: 'date', dir: -1 });
|
expect(await page.evaluate(() => window.app.state.sort)).toEqual({ key: 'date', dir: -1 });
|
||||||
|
|
||||||
|
|
|
||||||
66
tests/cap.spec.js
Normal file
66
tests/cap.spec.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
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,49 +152,51 @@ test('mode switch swaps the spreadsheet pane for the target pane', async ({ page
|
||||||
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false);
|
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('target tree renders structure and tabs switch', async ({ page }) => {
|
test('target tree renders the By-tracking grid and tabs switch', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const c = window.app.modules.classify;
|
const c = window.app.modules.classify;
|
||||||
const acme = c.addTrackingNode(null, 'ACME-PROJ');
|
c.reset();
|
||||||
c.addTrackingNode(acme, 'A (IFR)');
|
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 party = c.addParty('ClientCorp');
|
const party = c.addParty('ClientCorp');
|
||||||
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
window.app.modules.targetTree.render();
|
||||||
});
|
});
|
||||||
// Tracking panel visible by default with the table rendered.
|
// The grid shows the file's tracking number in an editable cell.
|
||||||
await expect(page.locator('#trackingTree .ttable__cell .tcell__name', { hasText: 'ACME-PROJ' })).toBeVisible();
|
await expect(page.locator('#trackingTree .ttable--grid')).toBeVisible();
|
||||||
await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: 'A (IFR)' })).toBeVisible();
|
await expect(page.locator('#trackingTree .tg-tn .tg-input')).toHaveValue('ACME-PROJ-EL-DWG-0001');
|
||||||
// Switch to transmittal tab.
|
// Switch to transmittal tab.
|
||||||
await page.click('#transmittalTab');
|
await page.click('#transmittalTab');
|
||||||
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
|
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
|
||||||
await expect(page.locator('#transmittalTree .tnode--bin .tnode__name', { hasText: 'ClientCorp-TRN-0007' })).toBeVisible();
|
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) ───────────────────────
|
// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
|
||||||
|
|
||||||
test('dropping a file onto a tracking leaf assigns it', async ({ page }) => {
|
test('dropping files onto the By-tracking grid adds rows and auto-fills ZDDC names', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
const c = window.app.modules.classify;
|
const c = window.app.modules.classify; c.reset();
|
||||||
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
const plain = { originalFilename: 'messy scan', extension: 'pdf', folderPath: 'R' };
|
||||||
window.app.modules.targetTree.render();
|
const named = { originalFilename: 'ACME-MECH-0001_A (IFR) - Pump', extension: 'pdf', folderPath: 'R' };
|
||||||
const row = document.querySelector('#trackingTree .ttable__rev[data-id]');
|
window.app.folderTree = [{ name: 'R', path: 'R', files: [plain, named], children: [] }];
|
||||||
const key = 'Sub/foundation.pdf';
|
const tt = window.app.modules.targetTree; tt.render();
|
||||||
window.app.modules.dnd.setDrag([key]);
|
const grid = document.querySelector('#trackingTree');
|
||||||
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
function drop(key) { window.app.modules.dnd.setDrag([key]); grid.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); }
|
||||||
return { assigned: c.assignmentFor(key).trackingNodeId, leaf };
|
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,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
expect(r.assigned).toBe(r.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
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => {
|
test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => {
|
||||||
|
|
@ -247,6 +249,33 @@ 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();
|
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 }) => {
|
test('classify: single-click a source file triggers preview', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const previewed = await page.evaluate(() => {
|
const previewed = await page.evaluate(() => {
|
||||||
|
|
@ -636,24 +665,26 @@ test('trackingNodeComplete: true only for a leaf with a valid status', async ({
|
||||||
expect(r).toEqual({ root: false, num: false, leaf: true, bare: false });
|
expect(r).toEqual({ root: false, num: false, leaf: true, bare: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('editing a placed file’s filename re-files it onto the parsed tracking path', async ({ page }) => {
|
test('editing grid cells re-files the file onto the new tracking path', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
const c = window.app.modules.classify;
|
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||||
c.reset();
|
c.reset();
|
||||||
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'OLD'), 'A (IFR)');
|
|
||||||
const file = { folderPath: 'Root/Sub', originalFilename: 'doc', extension: 'pdf' };
|
const file = { folderPath: 'Root/Sub', originalFilename: 'doc', extension: 'pdf' };
|
||||||
const key = c.srcKeyForFile(file);
|
const key = c.srcKeyForFile(file);
|
||||||
|
const leaf = c.addTrackingPath(null, c.parseFolderLevels('OLD-0001_A (IFR)'));
|
||||||
c.place([key], leaf, 'tracking');
|
c.place([key], leaf, 'tracking');
|
||||||
window.app.folderTree = [{
|
window.app.folderTree = [{ name: 'Sub', path: 'Root/Sub', expanded: true, scanState: 'done', children: [], files: [file] }];
|
||||||
name: 'Sub', path: 'Sub', expanded: true, scanState: 'done', children: [], files: [file],
|
function editCell(cls, val) {
|
||||||
}];
|
tt.render(); // re-render so we edit the live input each time
|
||||||
window.app.modules.targetTree.render();
|
const inp = document.querySelector('#trackingTree .' + cls + ' .tg-input');
|
||||||
const input = document.querySelector('#trackingTree .tfile__name');
|
inp.value = val; inp.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
input.value = 'CPO-0002_0 (IFU) - New Title.pdf';
|
}
|
||||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
editCell('tg-tn', 'CPO-0002');
|
||||||
|
editCell('tg-rev', '0 (IFU)');
|
||||||
|
editCell('tg-title', 'New Title');
|
||||||
const d = c.deriveTarget(file);
|
const d = c.deriveTarget(file);
|
||||||
return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title, complete: d.complete };
|
return { tracking: d.tracking, revision: d.revision, status: d.status, title: d.title };
|
||||||
});
|
});
|
||||||
expect(r.tracking).toBe('CPO-0002');
|
expect(r.tracking).toBe('CPO-0002');
|
||||||
expect(r.revision).toBe('0');
|
expect(r.revision).toBe('0');
|
||||||
|
|
@ -702,45 +733,53 @@ test('dataset (filename-based): import reconstruction rebuilds tracking + shared
|
||||||
expect(r.excluded).toBe(true);
|
expect(r.excluded).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('source-tree filter reveals matches with their folder hierarchy', async ({ page }) => {
|
test('source-tree filter hides non-matches in place; never changes expand state', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
window.app.folderTree = [{
|
window.app.folderTree = [{
|
||||||
name: 'Project', path: 'Project', expanded: false, scanState: 'done', files: [], children: [
|
name: 'Project', path: 'Project', expanded: true, scanState: 'done', files: [], children: [
|
||||||
{ name: 'Electrical', path: 'Project/Electrical', expanded: false, scanState: 'done', children: [], files: [
|
// EXPANDED: its match shows in place, the non-match is hidden.
|
||||||
|
{ name: 'Electrical', path: 'Project/Electrical', expanded: true, scanState: 'done', children: [], files: [
|
||||||
{ originalFilename: 'Master Deliverables List', extension: 'xlsx', folderPath: 'Project/Electrical' },
|
{ originalFilename: 'Master Deliverables List', extension: 'xlsx', folderPath: 'Project/Electrical' },
|
||||||
{ originalFilename: 'Switchgear Spec', extension: 'pdf', 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: [
|
{ name: 'Civil', path: 'Project/Civil', expanded: false, scanState: 'done', children: [], files: [
|
||||||
{ originalFilename: 'Site Plan', extension: 'pdf', folderPath: 'Project/Civil' },
|
{ originalFilename: 'master deliverables draft', extension: 'pdf', folderPath: 'Project/Civil' },
|
||||||
] },
|
] },
|
||||||
],
|
],
|
||||||
}];
|
}];
|
||||||
window.app.modules.tree.render();
|
window.app.modules.tree.render();
|
||||||
window.app.modules.tree.setNameFilter('master deliverables');
|
window.app.modules.tree.setNameFilter('master deliverables');
|
||||||
|
const civil = window.app.folderTree[0].children[1];
|
||||||
return {
|
return {
|
||||||
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent),
|
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),
|
folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path).sort(),
|
||||||
|
civilStillCollapsed: civil.expanded === false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(r.files).toEqual(['Master Deliverables List.xlsx']); // only the match shown
|
expect(r.files).toEqual(['Master Deliverables List.xlsx']); // expanded folder: match in place, Switchgear hidden
|
||||||
expect(r.folders).toEqual(['Project', 'Project/Electrical']); // path revealed; Civil 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
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tracking-tree filter reveals matching nodes and hides the rest', async ({ page }) => {
|
test('the By-tracking grid filter narrows rows by name/tracking', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const names = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
const c = window.app.modules.classify;
|
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||||
c.reset();
|
c.reset();
|
||||||
c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)'));
|
const a = { originalFilename: 'pump', extension: 'pdf', folderPath: 'R' };
|
||||||
c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)'));
|
const b = { originalFilename: 'valve', extension: 'pdf', folderPath: 'R' };
|
||||||
window.app.modules.targetTree.render();
|
window.app.folderTree = [{ name: 'R', path: 'R', files: [a, b], children: [] }];
|
||||||
window.app.modules.targetTree.setNameFilter('CPO');
|
c.setFileIdentity(c.srcKeyForFile(a), { tracking: 'CPO-0001', rev: 'A (IFR)', title: 'Pump' });
|
||||||
return Array.from(document.querySelectorAll('#trackingTree .tcell__name')).map((e) => e.textContent);
|
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);
|
||||||
});
|
});
|
||||||
expect(names).toContain('CPO');
|
expect(r).toContain('CPO-0001');
|
||||||
expect(names).toContain('0001');
|
expect(r).not.toContain('XYZ-0009');
|
||||||
expect(names).not.toContain('XYZ');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Show Empty off hides folders that contain no files', async ({ page }) => {
|
test('Show Empty off hides folders that contain no files', async ({ page }) => {
|
||||||
|
|
@ -776,18 +815,19 @@ test('toggling a Show filter preserves collapse state (no force-expand)', async
|
||||||
// A Show toggle must not expand the collapsed parent…
|
// A Show toggle must not expand the collapsed parent…
|
||||||
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false });
|
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: false });
|
||||||
const afterToggle = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
|
const afterToggle = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
|
||||||
// …whereas a name search still reveals the match by auto-expanding.
|
// …and neither does the name filter — it hides/shows in place, never expands.
|
||||||
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: true });
|
tree.setShowFilters({ unassigned: true, assigned: true, excluded: true, empty: true });
|
||||||
tree.setNameFilter('a.pdf');
|
tree.setNameFilter('a.pdf');
|
||||||
const afterSearch = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
|
const afterSearch = Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path);
|
||||||
return { collapsed, afterToggle, afterSearch };
|
return { collapsed, afterToggle, afterSearch, parentCollapsed: window.app.folderTree[0].expanded === false };
|
||||||
});
|
});
|
||||||
expect(r.collapsed).toEqual(['Project']); // child hidden — parent collapsed
|
expect(r.collapsed).toEqual(['Project']); // child hidden — parent collapsed
|
||||||
expect(r.afterToggle).toEqual(['Project']); // Show toggle leaves it collapsed
|
expect(r.afterToggle).toEqual(['Project']); // Show toggle leaves it collapsed
|
||||||
expect(r.afterSearch).toEqual(['Project', 'Project/Sub']); // name search auto-expands to the match
|
expect(r.afterSearch).toEqual(['Project']); // name filter leaves it collapsed (no force-expand)
|
||||||
|
expect(r.parentCollapsed).toBe(true); // expand state untouched
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search opens only the branch with a hit, leaving siblings collapsed', async ({ page }) => {
|
test('filter does not open collapsed branches; non-matching siblings hide', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
window.app.folderTree = [{
|
window.app.folderTree = [{
|
||||||
|
|
@ -802,15 +842,46 @@ test('search opens only the branch with a hit, leaving siblings collapsed', asyn
|
||||||
}];
|
}];
|
||||||
const tree = window.app.modules.tree;
|
const tree = window.app.modules.tree;
|
||||||
tree.render();
|
tree.render();
|
||||||
tree.setNameFilter('switchgear'); // a file deep in the Electrical branch
|
tree.setNameFilter('switchgear'); // a file deep in the (collapsed) Electrical branch
|
||||||
return {
|
return {
|
||||||
folders: Array.from(document.querySelectorAll('#folderTree .folder-item')).map((e) => e.dataset.path),
|
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),
|
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map((e) => e.textContent),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// Path to the hit opens; the unrelated Civil sibling is not force-opened (stays out).
|
// Project contains a match so it's shown — but stays COLLAPSED, so Electrical
|
||||||
expect(r.folders).toEqual(['Project', 'Project/Electrical']);
|
// isn't rendered and the hit isn't revealed (the user expands to reach it).
|
||||||
expect(r.files).toEqual(['Switchgear Spec.pdf']);
|
// 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
|
||||||
});
|
});
|
||||||
|
|
||||||
test('snapshot: a scanned zip subtree round-trips with its virtual members', async ({ page }) => {
|
test('snapshot: a scanned zip subtree round-trips with its virtual members', async ({ page }) => {
|
||||||
|
|
@ -958,51 +1029,47 @@ 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
|
expect(r.after).toBe(true); // struck through once the whole subtree is excluded
|
||||||
});
|
});
|
||||||
|
|
||||||
test('By-tracking table merges shared ancestors and aligns revisions', async ({ page }) => {
|
test('grid: hiding a column drops its cells; a status badge reflects completeness', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||||
c.reset();
|
c.reset();
|
||||||
c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_2025-11-17 (IFI)'));
|
try { localStorage.removeItem('zddc.classifier.trackingCols'); } catch (_) {}
|
||||||
c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_A (IFR)'));
|
const f = { originalFilename: 'x', extension: 'pdf', folderPath: 'R' };
|
||||||
c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)'));
|
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||||
|
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-MECH-0001', rev: 'A (IFR)', title: 'X' });
|
||||||
tt.render();
|
tt.render();
|
||||||
const cellByName = (n) => Array.from(document.querySelectorAll('#trackingTree .ttable__cell .tcell__name'))
|
const titleBefore = !!document.querySelector('#trackingTree .tg-title');
|
||||||
.filter((e) => e.textContent === n).map((e) => e.closest('td'))[0];
|
const badge = document.querySelector('#trackingTree .tg-status .tfile__badge');
|
||||||
const lku = cellByName('LKU'), cpo = cellByName('CPO');
|
// Hide the Title column via the persisted prefs, then re-render.
|
||||||
return {
|
localStorage.setItem('zddc.classifier.trackingCols', JSON.stringify({ hidden: { title: true } }));
|
||||||
lkuSpan: lku ? lku.rowSpan : 0,
|
tt.render();
|
||||||
cpoSpan: cpo ? cpo.rowSpan : 0,
|
const titleAfter = !!document.querySelector('#trackingTree .tg-title');
|
||||||
revs: Array.from(document.querySelectorAll('#trackingTree .ttable__rev .tcell__name')).map((e) => e.textContent),
|
try { localStorage.removeItem('zddc.classifier.trackingCols'); } catch (_) {}
|
||||||
};
|
return { titleBefore, titleAfter, badge: badge && badge.textContent };
|
||||||
});
|
});
|
||||||
expect(r.lkuSpan).toBe(2); // the LKU ancestor cell spans its two revisions (merged)
|
expect(r.titleBefore).toBe(true);
|
||||||
expect(r.cpoSpan).toBe(1);
|
expect(r.titleAfter).toBe(false); // Title column hidden + persists across re-render
|
||||||
// The revisions live in one aligned column; the date revision stays intact.
|
expect(r.badge).toBe('✓'); // complete (tracking + rev + status all set)
|
||||||
expect(r.revs).toEqual(['2025-11-17 (IFI)', 'A (IFR)', '0 (IFU)']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('revision cell links to preview its file and shows no count bubble', async ({ page }) => {
|
test('grid: the original-name cell is a preview link', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||||
c.reset();
|
c.reset();
|
||||||
const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root' };
|
const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root' };
|
||||||
window.app.folderTree = [{ name: 'Root', path: 'Root', files: [f], children: [] }];
|
window.app.folderTree = [{ name: 'Root', path: 'Root', files: [f], children: [] }];
|
||||||
const leaf = c.addTrackingPath(null, c.parseFolderLevels('ACME-MECH-0001_A (IFR)'));
|
let previewed = null;
|
||||||
c.place([c.srcKeyForFile(f)], leaf, 'tracking');
|
window.app.modules.preview.previewFile = (file) => { previewed = file.originalFilename; };
|
||||||
|
c.setFileIdentity(c.srcKeyForFile(f), { tracking: 'ACME-MECH-0001', rev: 'A (IFR)', title: 'Foundation' });
|
||||||
tt.render();
|
tt.render();
|
||||||
const rev = document.querySelector('#trackingTree .ttable__rev');
|
const link = document.querySelector('#trackingTree .tg-orig .tg-orig__link');
|
||||||
const link = rev.querySelector('.tcell__preview[data-preview-key]');
|
if (link) link.click();
|
||||||
return {
|
return { text: link && link.textContent, previewed };
|
||||||
hasPreview: !!link,
|
|
||||||
previewKey: link && link.dataset.previewKey,
|
|
||||||
hasBadge: !!rev.querySelector('.tnode__badge'),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
expect(r.hasPreview).toBe(true); // revision name is a preview link
|
expect(r.text).toBe('foundation.pdf');
|
||||||
expect(r.previewKey).toBe('foundation.pdf');
|
expect(r.previewed).toBe('foundation'); // clicking the name previews the file
|
||||||
expect(r.hasBadge).toBe(false); // no count bubble
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Show Partial surfaces files assigned in the other tab only', async ({ page }) => {
|
test('Show Partial surfaces files assigned in the other tab only', async ({ page }) => {
|
||||||
|
|
@ -1216,69 +1283,7 @@ test('seltable: autofilter + ctrl-shift selection builds complex sets', async ({
|
||||||
expect(r.ctrlShiftRange).toBe('c,d'); // ctrl-shift range runs over the FILTERED order
|
expect(r.ctrlShiftRange).toBe('c,d'); // ctrl-shift range runs over the FILTERED order
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mdl-instantiate: walks the archive subtree and dedupes to one deliverable per tracking number', async ({ page }) => {
|
test('From a list: a drop materializes a real tracking placement; row revision + transmittal complete it', 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');
|
await page.click('#modeClassifyBtn');
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
const c = window.app.modules.classify;
|
const c = window.app.modules.classify;
|
||||||
|
|
@ -1286,32 +1291,36 @@ test('classify: an MDL placement names a file; revision from the cell, transmitt
|
||||||
const f = { originalFilename: 'messy scan 47', extension: 'pdf', folderPath: 'R' };
|
const f = { originalFilename: 'messy scan 47', extension: 'pdf', folderPath: 'R' };
|
||||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||||
const key = c.srcKeyForFile(f);
|
const key = c.srcKeyForFile(f);
|
||||||
c.setMdlList([{ id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Switchgear Spec' }]);
|
c.setWorklist([{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Switchgear Spec' }]);
|
||||||
c.place([key], 'm1', 'mdl');
|
c.assignFromRow([key], c.getWorklistRow('m1')); // blank revision → partial
|
||||||
const beforeRev = c.deriveTarget(f); // no revision yet
|
const placedTracking = !!(c.getAssignment(key) || {}).trackingNodeId; // a REAL tracking placement
|
||||||
c.setRevisionCell('m1', 'A (IFR)');
|
const beforeRev = c.deriveTarget(f);
|
||||||
const named = c.deriveTarget(f); // named, but no transmittal → not complete
|
c.setRevisionCell('m1', 'A (IFR)'); // re-stamps onto the leaf
|
||||||
|
const named = c.deriveTarget(f);
|
||||||
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
c.place([key], bin, 'transmittal');
|
c.place([key], bin, 'transmittal');
|
||||||
const full = c.deriveTarget(f);
|
const full = c.deriveTarget(f);
|
||||||
c.setTitleFromDeliverable(key, false); // use the file's own title instead
|
c.setTitleOverride(key, ''); // use the file's own title instead
|
||||||
const fileTitle = c.deriveTarget(f);
|
const fileTitle = c.deriveTarget(f);
|
||||||
return {
|
return {
|
||||||
beforeRevErr: beforeRev.errors.length > 0,
|
placedTracking, beforeRevErr: beforeRev.errors.length > 0,
|
||||||
|
beforeTracking: beforeRev.tracking,
|
||||||
named: named.filename, namedComplete: named.complete,
|
named: named.filename, namedComplete: named.complete,
|
||||||
fullName: full.filename, fullComplete: full.complete,
|
fullName: full.filename, fullComplete: full.complete,
|
||||||
fileTitleName: fileTitle.filename,
|
fileTitleName: fileTitle.filename,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
expect(r.beforeRevErr).toBe(true); // a deliverable with no revision can't name a file
|
expect(r.placedTracking).toBe(true); // not a separate axis — a tracking placement
|
||||||
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf'); // tracking+title from MDL, rev from cell
|
expect(r.beforeTracking).toBe('ACM-PRJ-EL-SPC-0001'); // full tracking number preserved while rev pending
|
||||||
expect(r.namedComplete).toBe(false); // still needs a transmittal for the output path
|
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.fullComplete).toBe(true);
|
expect(r.fullComplete).toBe(true);
|
||||||
expect(r.fullName).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Switchgear Spec.pdf');
|
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
|
expect(r.fileTitleName).toContain('messy scan 47'); // title toggle → the file's own title
|
||||||
});
|
});
|
||||||
|
|
||||||
test('By MDL tab: drop a file on a deliverable row names it; bulk revision applies', async ({ page }) => {
|
test('From a list: clearing the list keeps classifications; the row drives the seltable', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||||
|
|
@ -1319,20 +1328,241 @@ test('By MDL tab: drop a file on a deliverable row names it; bulk revision appli
|
||||||
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
|
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
|
||||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||||
const key = c.srcKeyForFile(f);
|
const key = c.srcKeyForFile(f);
|
||||||
c.setMdlList([
|
c.setWorklist([
|
||||||
{ id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec' },
|
{ id: 'm1', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'], revisionCell: 'C (IFC)' },
|
||||||
{ id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2' },
|
{ id: 'm2', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', archiveRevisions: ['0 (IFC)'] },
|
||||||
]);
|
]);
|
||||||
tt.showTab('mdl'); tt.render();
|
tt.showTab('worklist');
|
||||||
const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]');
|
const row = document.querySelector('#worklistTable .seltable__row[data-id="m1"]');
|
||||||
const hasRow = !!row;
|
const latestShown = !!row && row.textContent.includes('B (IFC)'); // latest archive rev shown
|
||||||
window.app.modules.dnd.setDrag([key]);
|
window.app.modules.dnd.setDrag([key]);
|
||||||
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop the file on m1
|
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop on m1 (rev C set)
|
||||||
const placed = (c.getAssignment(key) || {}).mdlNodeId;
|
const named = c.deriveTarget(f).filename;
|
||||||
c.setRevisionCells(['m1', 'm2'], 'A (IFR)'); // ctrl-enter bulk path
|
c.clearWorklist(); // list emptied — assignment must survive
|
||||||
return { hasRow, placed, named: c.deriveTarget(f).filename };
|
return {
|
||||||
|
hasRow: !!row, latestShown,
|
||||||
|
placedAfterDrop: !!(c.getAssignment(key) || {}).trackingNodeId,
|
||||||
|
named,
|
||||||
|
listLen: c.getWorklist().length,
|
||||||
|
stillPlaced: !!(c.getAssignment(key) || {}).trackingNodeId,
|
||||||
|
stillNamed: c.deriveTarget(f).filename,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
expect(r.hasRow).toBe(true);
|
expect(r.hasRow).toBe(true);
|
||||||
expect(r.placed).toBe('m1'); // drop placed the file on the deliverable
|
expect(r.latestShown).toBe(true);
|
||||||
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // bulk-set revision feeds the name
|
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']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
194
tests/tables-mdl.spec.js
Normal file
194
tests/tables-mdl.spec.js
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
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,12 +22,13 @@ test.describe('shared/toast.js', () => {
|
||||||
expect(exposed).toBe(true);
|
expect(exposed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders a single toast with the level class and ARIA role', async ({ page }) => {
|
test('renders a toast with the level class and ARIA role', async ({ page }) => {
|
||||||
const after = await page.evaluate(() => {
|
const after = await page.evaluate(() => {
|
||||||
window.zddc.toast('Saved.', 'success');
|
window.zddc.toast('Saved.', 'success');
|
||||||
const el = document.querySelector('.zddc-toast');
|
const el = document.querySelector('.zddc-toast');
|
||||||
return el && {
|
return el && {
|
||||||
text: el.textContent,
|
// The message lives in its own span (the toast also holds a × button).
|
||||||
|
text: el.querySelector('.zddc-toast__msg').textContent,
|
||||||
level: [...el.classList].find(c => c.startsWith('zddc-toast--')),
|
level: [...el.classList].find(c => c.startsWith('zddc-toast--')),
|
||||||
role: el.getAttribute('role'),
|
role: el.getAttribute('role'),
|
||||||
live: el.getAttribute('aria-live'),
|
live: el.getAttribute('aria-live'),
|
||||||
|
|
@ -50,18 +51,24 @@ test.describe('shared/toast.js', () => {
|
||||||
expect(probe).toEqual({ role: 'alert', live: 'assertive' });
|
expect(probe).toEqual({ role: 'alert', live: 'assertive' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a second toast replaces the first (single-toast policy)', async ({ page }) => {
|
test('toasts stack, and a "Clear all" control appears at 2+', async ({ page }) => {
|
||||||
const count = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
window.zddc.toast('one', 'info');
|
window.zddc.toast('one', 'error'); // sticky so it stays for the count
|
||||||
window.zddc.toast('two', 'info');
|
window.zddc.toast('two', 'error');
|
||||||
return document.querySelectorAll('.zddc-toast').length;
|
return {
|
||||||
|
count: document.querySelectorAll('.zddc-toast').length,
|
||||||
|
clearAll: !!document.querySelector('.zddc-toasts__clear'),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
expect(count).toBe(1);
|
expect(r.count).toBe(2); // stack, not replace
|
||||||
|
expect(r.clearAll).toBe(true); // "Clear all" surfaces when 2+ are stacked
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking dismisses immediately', async ({ page }) => {
|
test('the × button dismisses a toast; clicking the body does not', async ({ page }) => {
|
||||||
await page.evaluate(() => window.zddc.toast('click me', 'info'));
|
await page.evaluate(() => window.zddc.toast('keep me', 'error')); // sticky
|
||||||
await page.locator('.zddc-toast').click();
|
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
|
||||||
await expect(page.locator('.zddc-toast')).toHaveCount(0);
|
await expect(page.locator('.zddc-toast')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -162,19 +162,23 @@ test.describe('/.tokens self-service token UI', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('XSS guard: description with HTML special chars is escaped on render', async ({ page }) => {
|
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">`;
|
const xssDesc = `<img src=x onerror="window.__xss=1">`;
|
||||||
await page.goto(`${server.baseURL}/.tokens`);
|
await page.goto(`${server.baseURL}/.tokens`);
|
||||||
await page.fill('#desc', xssDesc);
|
// Create via the apiActions modal (the inline #desc form is long gone).
|
||||||
await page.click('button[type="submit"]');
|
await page.locator('#api-create-btn').click();
|
||||||
// Wait for the row to appear in the table.
|
await expect(page.locator('.api-modal')).toBeVisible();
|
||||||
await expect(page.locator('#tokens tbody')).toContainText('<img');
|
await page.locator('.api-modal input').first().fill(xssDesc);
|
||||||
// The literal <img> tag should NOT have been parsed as HTML —
|
await page.locator('.api-modal button[type="submit"]').click();
|
||||||
// window.__xss must remain undefined.
|
await expect(page.locator('.api-modal__secret')).toBeVisible();
|
||||||
const xssFired = await page.evaluate(() => window.__xss === 1);
|
await page.locator('.api-modal button:has-text("Done")').click();
|
||||||
expect(xssFired).toBe(false);
|
await page.waitForLoadState('networkidle');
|
||||||
// And the on-disk text content of the cell should contain the
|
// The description renders as a row — as TEXT, not parsed HTML.
|
||||||
// literal angle brackets, proving they were escaped.
|
const row = page.locator('#table-root tbody tr', { hasText: 'img src' });
|
||||||
const rowText = await page.locator('#tokens tbody tr', { hasText: 'img src' }).textContent();
|
await expect(row).toBeVisible();
|
||||||
expect(rowText).toContain('<img');
|
// 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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<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-11 14:40:24 · bc762a7</span></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>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
|
|
@ -11523,6 +11523,48 @@ window.app.modules.filtering = {
|
||||||
a: 'edit access rules'
|
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
|
// handleForbidden(resp, opts) — render a 403 toast naming the
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
// missing verb. opts.path (optional) is the URL the failed request
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
// hit; when provided, the helper consults /.profile/access?path= to
|
||||||
|
|
@ -11536,8 +11578,9 @@ window.app.modules.filtering = {
|
||||||
async function handleForbidden(resp, opts) {
|
async function handleForbidden(resp, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var missing = '';
|
var missing = '';
|
||||||
|
var body = null;
|
||||||
try {
|
try {
|
||||||
var body = await resp.clone().json();
|
body = await resp.clone().json();
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
if (body && typeof body.missing_verb === 'string') {
|
||||||
missing = body.missing_verb;
|
missing = body.missing_verb;
|
||||||
}
|
}
|
||||||
|
|
@ -11552,6 +11595,16 @@ window.app.modules.filtering = {
|
||||||
msg = prefix + 'Forbidden.';
|
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
|
// Optional elevate offer: only when the caller supplied a
|
||||||
// path AND the path-scoped access view reports an elevation
|
// path AND the path-scoped access view reports an elevation
|
||||||
// grant covering the missing verb. Render as a clickable
|
// grant covering the missing verb. Render as a clickable
|
||||||
|
|
@ -11584,7 +11637,7 @@ window.app.modules.filtering = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<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-11 14:40:25 · bc762a7</span></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>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<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>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
|
|
@ -7022,6 +7022,48 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
a: 'edit access rules'
|
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
|
// handleForbidden(resp, opts) — render a 403 toast naming the
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
// missing verb. opts.path (optional) is the URL the failed request
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
// hit; when provided, the helper consults /.profile/access?path= to
|
||||||
|
|
@ -7035,8 +7077,9 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
async function handleForbidden(resp, opts) {
|
async function handleForbidden(resp, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var missing = '';
|
var missing = '';
|
||||||
|
var body = null;
|
||||||
try {
|
try {
|
||||||
var body = await resp.clone().json();
|
body = await resp.clone().json();
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
if (body && typeof body.missing_verb === 'string') {
|
||||||
missing = body.missing_verb;
|
missing = body.missing_verb;
|
||||||
}
|
}
|
||||||
|
|
@ -7051,6 +7094,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
msg = prefix + 'Forbidden.';
|
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
|
// Optional elevate offer: only when the caller supplied a
|
||||||
// path AND the path-scoped access view reports an elevation
|
// path AND the path-scoped access view reports an elevation
|
||||||
// grant covering the missing verb. Render as a clickable
|
// grant covering the missing verb. Render as a clickable
|
||||||
|
|
@ -7083,7 +7136,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
|
// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
|
||||||
|
|
@ -14391,8 +14444,12 @@ window.__ZDDC_SCHEMA__ = {
|
||||||
box.querySelector('#acc-cancel').addEventListener('click', function () {
|
box.querySelector('#acc-cancel').addEventListener('click', function () {
|
||||||
close(); reject(new Error('cancelled'));
|
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) {
|
overlay.addEventListener('click', function (e) {
|
||||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
|
||||||
|
|
@ -14709,8 +14766,10 @@ window.__ZDDC_SCHEMA__ = {
|
||||||
box.querySelector('#stage-cancel').addEventListener('click', function () {
|
box.querySelector('#stage-cancel').addEventListener('click', function () {
|
||||||
close(); reject(new Error('cancelled'));
|
close(); reject(new Error('cancelled'));
|
||||||
});
|
});
|
||||||
|
var pressedBackdrop = false;
|
||||||
|
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
|
||||||
overlay.addEventListener('click', function (e) {
|
overlay.addEventListener('click', function (e) {
|
||||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
box.querySelector('#stage-submit').addEventListener('click', function () {
|
box.querySelector('#stage-submit').addEventListener('click', function () {
|
||||||
var sel = box.querySelector('input[name="stage-target"]:checked');
|
var sel = box.querySelector('input[name="stage-target"]:checked');
|
||||||
|
|
@ -14760,8 +14819,10 @@ window.__ZDDC_SCHEMA__ = {
|
||||||
box.querySelector('#unstage-cancel').addEventListener('click', function () {
|
box.querySelector('#unstage-cancel').addEventListener('click', function () {
|
||||||
close(); reject(new Error('cancelled'));
|
close(); reject(new Error('cancelled'));
|
||||||
});
|
});
|
||||||
|
var pressedBackdrop = false;
|
||||||
|
overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); });
|
||||||
overlay.addEventListener('click', function (e) {
|
overlay.addEventListener('click', function (e) {
|
||||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
box.querySelector('#unstage-submit').addEventListener('click', function () {
|
box.querySelector('#unstage-submit').addEventListener('click', function () {
|
||||||
var target = input.value.trim();
|
var target = input.value.trim();
|
||||||
|
|
@ -15357,8 +15418,12 @@ window.__ZDDC_SCHEMA__ = {
|
||||||
box.querySelector('#ct-cancel').addEventListener('click', function () {
|
box.querySelector('#ct-cancel').addEventListener('click', function () {
|
||||||
close(); reject(new Error('cancelled'));
|
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) {
|
overlay.addEventListener('click', function (e) {
|
||||||
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', onKeydown);
|
||||||
submit.addEventListener('click', function () {
|
submit.addEventListener('click', function () {
|
||||||
|
|
@ -16209,6 +16274,13 @@ window.__ZDDC_SCHEMA__ = {
|
||||||
function openPartyPicker(opts) {
|
function openPartyPicker(opts) {
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
var kindWord = opts.kind === 'folder' ? 'folder' : 'file';
|
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');
|
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;';
|
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');
|
var box = document.createElement('div');
|
||||||
|
|
@ -16227,10 +16299,10 @@ window.__ZDDC_SCHEMA__ = {
|
||||||
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/<party>/</code>.' +
|
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/<party>/</code>.' +
|
||||||
'</p>' +
|
'</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;">' +
|
'<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 — create one below.</em>') +
|
(partyList || '<em style="color:#888;">No parties yet.</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;">' +
|
'<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__">' +
|
'<input type="radio" name="pp-party" value="__new__"' + (newPartyAllowed ? '' : ' disabled') + '>' +
|
||||||
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' +
|
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</span></span></label>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' +
|
'<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>' +
|
'<label for="pp-newparty">New party name</label><br>' +
|
||||||
|
|
@ -16258,7 +16330,11 @@ window.__ZDDC_SCHEMA__ = {
|
||||||
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
||||||
function cancel() { close(); resolve(null); }
|
function cancel() { close(); resolve(null); }
|
||||||
box.querySelector('#pp-cancel').addEventListener('click', cancel);
|
box.querySelector('#pp-cancel').addEventListener('click', cancel);
|
||||||
overlay.addEventListener('click', function (e) { if (e.target === overlay) 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(); });
|
||||||
box.querySelector('#pp-submit').addEventListener('click', function () {
|
box.querySelector('#pp-submit').addEventListener('click', function () {
|
||||||
var sel = box.querySelector('input[name="pp-party"]:checked');
|
var sel = box.querySelector('input[name="pp-party"]:checked');
|
||||||
if (!sel) { statusError('Pick a party.'); return; }
|
if (!sel) { statusError('Pick a party.'); return; }
|
||||||
|
|
@ -16287,8 +16363,28 @@ window.__ZDDC_SCHEMA__ = {
|
||||||
async function createInAggregator(agg, kind) {
|
async function createInAggregator(agg, kind) {
|
||||||
var up = window.app.modules.upload;
|
var up = window.app.modules.upload;
|
||||||
if (!up) return;
|
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 parties = await fetchParties(agg.project);
|
||||||
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties });
|
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 });
|
||||||
if (!choice) return;
|
if (!choice) return;
|
||||||
// Party names are validated to a URL-safe charset, so no encoding
|
// Party names are validated to a URL-safe charset, so no encoding
|
||||||
// needed for the party segment; makeDir/makeFile encode the leaf.
|
// needed for the party segment; makeDir/makeFile encode the leaf.
|
||||||
|
|
@ -16311,7 +16407,10 @@ window.__ZDDC_SCHEMA__ = {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
var msg = (e && e.message) || String(e);
|
var msg = (e && e.message) || String(e);
|
||||||
if (/\b403\b/.test(msg)) {
|
if (/\b403\b/.test(msg)) {
|
||||||
statusError('Not allowed — registering a new party requires the document-controller role.');
|
// 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.');
|
||||||
} else if (/\b409\b/.test(msg)) {
|
} else if (/\b409\b/.test(msg)) {
|
||||||
statusError('Unknown party — register it first (document controller).');
|
statusError('Unknown party — register it first (document controller).');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1609,6 +1609,21 @@ body {
|
||||||
text-decoration: underline;
|
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 {
|
#projectView ol {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
@ -1778,7 +1793,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</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>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -1951,6 +1966,15 @@ body {
|
||||||
</div><!-- /projectView -->
|
</div><!-- /projectView -->
|
||||||
</main>
|
</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 -->
|
<!-- Help Panel -->
|
||||||
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
||||||
<div class="help-panel__header">
|
<div class="help-panel__header">
|
||||||
|
|
@ -3372,6 +3396,48 @@ body {
|
||||||
a: 'edit access rules'
|
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
|
// handleForbidden(resp, opts) — render a 403 toast naming the
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
// missing verb. opts.path (optional) is the URL the failed request
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
// hit; when provided, the helper consults /.profile/access?path= to
|
||||||
|
|
@ -3385,8 +3451,9 @@ body {
|
||||||
async function handleForbidden(resp, opts) {
|
async function handleForbidden(resp, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var missing = '';
|
var missing = '';
|
||||||
|
var body = null;
|
||||||
try {
|
try {
|
||||||
var body = await resp.clone().json();
|
body = await resp.clone().json();
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
if (body && typeof body.missing_verb === 'string') {
|
||||||
missing = body.missing_verb;
|
missing = body.missing_verb;
|
||||||
}
|
}
|
||||||
|
|
@ -3401,6 +3468,16 @@ body {
|
||||||
msg = prefix + 'Forbidden.';
|
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
|
// Optional elevate offer: only when the caller supplied a
|
||||||
// path AND the path-scoped access view reports an elevation
|
// path AND the path-scoped access view reports an elevation
|
||||||
// grant covering the missing verb. Render as a clickable
|
// grant covering the missing verb. Render as a clickable
|
||||||
|
|
@ -3433,7 +3510,7 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
|
|
|
||||||
|
|
@ -2770,7 +2770,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<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-11 14:40:24 · bc762a7</span></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>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
@ -14071,6 +14071,48 @@ X.B(E,Y);return E}return J}())
|
||||||
a: 'edit access rules'
|
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
|
// handleForbidden(resp, opts) — render a 403 toast naming the
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
// missing verb. opts.path (optional) is the URL the failed request
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
// hit; when provided, the helper consults /.profile/access?path= to
|
||||||
|
|
@ -14084,8 +14126,9 @@ X.B(E,Y);return E}return J}())
|
||||||
async function handleForbidden(resp, opts) {
|
async function handleForbidden(resp, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var missing = '';
|
var missing = '';
|
||||||
|
var body = null;
|
||||||
try {
|
try {
|
||||||
var body = await resp.clone().json();
|
body = await resp.clone().json();
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
if (body && typeof body.missing_verb === 'string') {
|
||||||
missing = body.missing_verb;
|
missing = body.missing_verb;
|
||||||
}
|
}
|
||||||
|
|
@ -14100,6 +14143,16 @@ X.B(E,Y);return E}return J}())
|
||||||
msg = prefix + 'Forbidden.';
|
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
|
// Optional elevate offer: only when the caller supplied a
|
||||||
// path AND the path-scoped access view reports an elevation
|
// path AND the path-scoped access view reports an elevation
|
||||||
// grant covering the missing verb. Render as a clickable
|
// grant covering the missing verb. Render as a clickable
|
||||||
|
|
@ -14132,7 +14185,7 @@ X.B(E,Y);return E}return J}())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(function (app) {
|
(function (app) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
archive=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
|
||||||
transmittal=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
transmittal=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
|
||||||
classifier=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
classifier=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
|
||||||
landing=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
landing=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
|
||||||
form=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
form=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
|
||||||
tables=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7
|
tables=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
|
||||||
browse=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7
|
browse=v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
"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
|
// writeForbidden emits a 403 JSON response naming the missing verb. Used
|
||||||
|
|
@ -34,6 +35,25 @@ func writeForbidden(w http.ResponseWriter, action string) {
|
||||||
_, _ = w.Write(body)
|
_, _ = 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
|
// verbForAction maps a policy.Action constant to its single-character
|
||||||
// verb. Mirrors policy.actionVerb but emits the wire-format letter
|
// verb. Mirrors policy.actionVerb but emits the wire-format letter
|
||||||
// rather than the bitmask, so the JSON body carries "r"/"w"/"c"/"d"/"a"
|
// 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)
|
decider := DeciderFromContext(r)
|
||||||
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
|
allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, urlPath, action)
|
||||||
if !allowed {
|
if !allowed {
|
||||||
writeForbidden(w, action)
|
writeForbiddenWho(w, action, chain) // name who CAN, so the toast can explain
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,15 @@ type AccessView struct {
|
||||||
// the "which roles do I hold here?" answer the browse hovercard
|
// the "which roles do I hold here?" answer the browse hovercard
|
||||||
// surfaces. Elevation-independent (role membership, not admin).
|
// surfaces. Elevation-independent (role membership, not admin).
|
||||||
PathRoles []string `json:"path_roles,omitempty"`
|
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
|
// enumerateAccess builds an AccessView for the given caller. Used by the
|
||||||
|
|
@ -256,6 +265,27 @@ func populatePathScopedAccess(ctx context.Context, decider policy.Decider, cfg c
|
||||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery)
|
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, p, pathQuery)
|
||||||
view.PathVerbs = verbs.String()
|
view.PathVerbs = verbs.String()
|
||||||
view.PathIsAdmin = p.Elevated && p.Email != "" && zddc.IsAdminForChain(chain, p.Email)
|
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
|
// Which cascade roles the caller holds at this path — the answer to
|
||||||
// "the system thinks I'm a document_controller here, right?".
|
// "the system thinks I'm a document_controller here, right?".
|
||||||
view.PathRoles = zddc.RolesForPrincipalInChain(chain, p.Email)
|
view.PathRoles = zddc.RolesForPrincipalInChain(chain, p.Email)
|
||||||
|
|
|
||||||
|
|
@ -1245,6 +1245,54 @@ body.is-elevated::after {
|
||||||
color: var(--text);
|
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. */
|
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
|
||||||
|
|
||||||
.table-main {
|
.table-main {
|
||||||
|
|
@ -1722,7 +1770,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<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-11 14:40:25 · bc762a7</span></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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -1743,6 +1791,7 @@ body.is-elevated::after {
|
||||||
<div class="table-toolbar__right">
|
<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-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-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>
|
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -3543,6 +3592,48 @@ body.is-elevated::after {
|
||||||
a: 'edit access rules'
|
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
|
// handleForbidden(resp, opts) — render a 403 toast naming the
|
||||||
// missing verb. opts.path (optional) is the URL the failed request
|
// missing verb. opts.path (optional) is the URL the failed request
|
||||||
// hit; when provided, the helper consults /.profile/access?path= to
|
// hit; when provided, the helper consults /.profile/access?path= to
|
||||||
|
|
@ -3556,8 +3647,9 @@ body.is-elevated::after {
|
||||||
async function handleForbidden(resp, opts) {
|
async function handleForbidden(resp, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var missing = '';
|
var missing = '';
|
||||||
|
var body = null;
|
||||||
try {
|
try {
|
||||||
var body = await resp.clone().json();
|
body = await resp.clone().json();
|
||||||
if (body && typeof body.missing_verb === 'string') {
|
if (body && typeof body.missing_verb === 'string') {
|
||||||
missing = body.missing_verb;
|
missing = body.missing_verb;
|
||||||
}
|
}
|
||||||
|
|
@ -3572,6 +3664,16 @@ body.is-elevated::after {
|
||||||
msg = prefix + 'Forbidden.';
|
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
|
// Optional elevate offer: only when the caller supplied a
|
||||||
// path AND the path-scoped access view reports an elevation
|
// path AND the path-scoped access view reports an elevation
|
||||||
// grant covering the missing verb. Render as a clickable
|
// grant covering the missing verb. Render as a clickable
|
||||||
|
|
@ -3604,7 +3706,7 @@ body.is-elevated::after {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/context-menu.js — generic context-menu framework exposed on
|
// shared/context-menu.js — generic context-menu framework exposed on
|
||||||
|
|
@ -3996,6 +4098,185 @@ body.is-elevated::after {
|
||||||
window.zddc.menu = { open: open, close: close };
|
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
|
// mode.js — picks table-mode vs form-mode at boot time and unhides the
|
||||||
// matching container. Both apps (tablesApp, formApp) ship in the same
|
// matching container. Both apps (tablesApp, formApp) ship in the same
|
||||||
// bundle but each only paints when its container is visible.
|
// bundle but each only paints when its container is visible.
|
||||||
|
|
@ -7571,6 +7852,191 @@ body.is-elevated::after {
|
||||||
}
|
}
|
||||||
})(window.tablesApp = window.tablesApp || {});
|
})(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) {
|
(function (app) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
@ -7725,7 +8191,9 @@ body.is-elevated::after {
|
||||||
if (verbs.indexOf('c') === -1) {
|
if (verbs.indexOf('c') === -1) {
|
||||||
addRowBtn.classList.add('is-disabled');
|
addRowBtn.classList.add('is-disabled');
|
||||||
addRowBtn.setAttribute('aria-disabled', 'true');
|
addRowBtn.setAttribute('aria-disabled', 'true');
|
||||||
addRowBtn.title = "You don't have create access in this folder.";
|
// 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.";
|
||||||
// Swallow clicks so the no-op feedback is the
|
// Swallow clicks so the no-op feedback is the
|
||||||
// tooltip, not a 403 toast on submission.
|
// tooltip, not a 403 toast on submission.
|
||||||
addRowBtn.addEventListener('click', function (ev) {
|
addRowBtn.addEventListener('click', function (ev) {
|
||||||
|
|
@ -7740,6 +8208,11 @@ 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 columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||||
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
||||||
|
|
||||||
|
|
|
||||||
76
zddc/internal/zddc/whocan.go
Normal file
76
zddc/internal/zddc/whocan.go
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
63
zddc/internal/zddc/whocan_test.go
Normal file
63
zddc/internal/zddc/whocan_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
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