Compare commits
No commits in common. "main" and "classifier-v0.0.26" have entirely different histories.
main
...
classifier
47 changed files with 2614 additions and 7139 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -227,20 +227,6 @@ Format: `trackingNumber_revision (status) - title.extension`
|
||||||
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
|
- 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,7 +7,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
- **Commit freely** — make commits as appropriate for the work being performed. Each commit should be a coherent, reviewable unit (no WIP/checkpoint noise). The default rule "never commit without explicit ask" does NOT apply in this repo.
|
- **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,12 +173,8 @@
|
||||||
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 && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,12 +82,8 @@
|
||||||
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 && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', onKeydown);
|
||||||
submit.addEventListener('click', function () {
|
submit.addEventListener('click', function () {
|
||||||
|
|
|
||||||
|
|
@ -792,13 +792,6 @@
|
||||||
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');
|
||||||
|
|
@ -817,10 +810,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.</em>') +
|
(partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
|
||||||
'<label title="' + escapeHtml(newPartyTitle) + '" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;' + (newPartyAllowed ? 'cursor:pointer;' : 'opacity:0.6;') + '">' +
|
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
|
||||||
'<input type="radio" name="pp-party" value="__new__"' + (newPartyAllowed ? '' : ' disabled') + '>' +
|
'<input type="radio" name="pp-party" value="__new__">' +
|
||||||
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</span></span></label>' +
|
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</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>' +
|
||||||
|
|
@ -848,11 +841,7 @@
|
||||||
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);
|
||||||
// Close on a genuine backdrop click only — NOT when a drag that began
|
overlay.addEventListener('click', function (e) { if (e.target === overlay) cancel(); });
|
||||||
// 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; }
|
||||||
|
|
@ -881,28 +870,8 @@
|
||||||
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 newPartyHint = (!canNewParty && ssrView && cap.denyHint) ? cap.denyHint(ssrView, 'c') : null;
|
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties });
|
||||||
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.
|
||||||
|
|
@ -925,10 +894,7 @@
|
||||||
} 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)) {
|
||||||
// Name who can — best-effort, for the path the denial came from.
|
statusError('Not allowed — registering a new party requires the document-controller role.');
|
||||||
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,10 +195,8 @@
|
||||||
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 && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay) { 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');
|
||||||
|
|
@ -248,10 +246,8 @@
|
||||||
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 && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay) { 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,7 +26,6 @@ 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"
|
||||||
|
|
@ -57,14 +56,13 @@ concat_files \
|
||||||
"js/classify.js" \
|
"js/classify.js" \
|
||||||
"js/workspace.js" \
|
"js/workspace.js" \
|
||||||
"js/dnd.js" \
|
"js/dnd.js" \
|
||||||
"../shared/seltable.js" \
|
"js/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/rename.js" \
|
"js/mdl-instantiate.js" \
|
||||||
"js/spreadsheet.js" \
|
"js/spreadsheet.js" \
|
||||||
"js/selection.js" \
|
"js/selection.js" \
|
||||||
"js/preview.js" \
|
"js/preview.js" \
|
||||||
|
|
|
||||||
|
|
@ -103,13 +103,10 @@
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Resize Handle — sits just to the RIGHT of the split (past the 1px
|
/* Resize Handle */
|
||||||
border-right), overhanging the right pane's edge, so its grab area + hover
|
|
||||||
highlight never cover the folder tree's vertical scrollbar (which lives on the
|
|
||||||
left pane's right edge). */
|
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -6px;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 5px;
|
width: 5px;
|
||||||
|
|
@ -160,7 +157,6 @@
|
||||||
}
|
}
|
||||||
.tree-toolbar__label { color: var(--text-muted); font-size: 0.8rem; font-weight: 600; }
|
.tree-toolbar__label { color: var(--text-muted); font-size: 0.8rem; font-weight: 600; }
|
||||||
.classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; }
|
.classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; }
|
||||||
.export-list-btn { margin-left: auto; } /* push the export action to the toolbar's right edge */
|
|
||||||
|
|
||||||
/* Live filter box above a file tree. */
|
/* Live filter box above a file tree. */
|
||||||
.tree-filter {
|
.tree-filter {
|
||||||
|
|
@ -188,13 +184,12 @@
|
||||||
/* Folder Item */
|
/* Folder Item */
|
||||||
.folder-item {
|
.folder-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start; /* toggle/icon sit on the name line; count drops below */
|
align-items: center;
|
||||||
padding: 0.1rem 0.5rem; /* tight: the name + count are already stacked */
|
padding: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: background-color 0.15s;
|
transition: background-color 0.15s;
|
||||||
line-height: 1.25;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-item:hover {
|
.folder-item:hover {
|
||||||
|
|
@ -274,15 +269,8 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Name + count stacked vertically (count below the name, not right-aligned). */
|
|
||||||
.folder-namebox {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-name {
|
.folder-name {
|
||||||
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
@ -291,6 +279,7 @@
|
||||||
.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 {
|
||||||
|
|
@ -501,10 +490,6 @@
|
||||||
.file-item:hover { background: var(--bg-hover); }
|
.file-item:hover { background: var(--bg-hover); }
|
||||||
.file-item:active { cursor: grabbing; }
|
.file-item:active { cursor: grabbing; }
|
||||||
.file-item.match-highlight { background: var(--primary-light); outline: 1px solid var(--primary); }
|
.file-item.match-highlight { background: var(--primary-light); outline: 1px solid var(--primary); }
|
||||||
/* Multi-selected source files (ctrl/shift-click) — these drag together and fill a
|
|
||||||
contiguous block of grid rows. */
|
|
||||||
.file-item.selected { background: var(--primary-light, rgba(37,99,235,0.12)); outline: 1px solid var(--primary); outline-offset: -1px; }
|
|
||||||
.file-item.selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
|
|
||||||
.folder-item[draggable="true"] { cursor: grab; }
|
.folder-item[draggable="true"] { cursor: grab; }
|
||||||
.file-icon { color: var(--text-muted); }
|
.file-icon { color: var(--text-muted); }
|
||||||
.file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
@ -586,70 +571,51 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Target tabs: grouped (assign a tracking number) + separate (route) ───── */
|
/* ── By-MDL panel (seltable rows = deliverable drop targets) ─────────────── */
|
||||||
.pane-header--target { flex-wrap: wrap; }
|
#mdlPanel .seltable { height: 100%; }
|
||||||
.target-goal { flex: 1 0 100%; margin: 0 0 0.4rem; font-size: 0.78rem; color: var(--text-muted); line-height: 1.4; }
|
.mdl-rev__input {
|
||||||
.target-goal strong { color: var(--text); }
|
width: 8rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
||||||
.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; }
|
||||||
#worklistPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
|
#mdlPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
|
||||||
#worklistPanel .tfile--err .mdlfile__name { color: var(--danger); }
|
#mdlPanel .tfile--err .mdlfile__name { color: var(--danger); }
|
||||||
#worklistPanel .tfile__remove { opacity: 0.6; }
|
#mdlPanel .tfile__remove { opacity: 0.6; }
|
||||||
#worklistPanel .tfile:hover .tfile__remove { opacity: 1; }
|
#mdlPanel .tfile:hover .tfile__remove { opacity: 1; }
|
||||||
|
|
||||||
/* Paste + Match dialogs (inside the .copy-choice modal shell) */
|
/* ── MDL-from-archive overlay ───────────────────────────────────────────── */
|
||||||
.scratch-modal__body { margin: 0 0 1rem; }
|
.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-paste__ta {
|
.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; }
|
||||||
width: 100%; box-sizing: border-box; resize: vertical; font-family: var(--mono, monospace);
|
.mdl-overlay__head { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); }
|
||||||
font-size: 0.8rem; padding: 0.4rem 0.5rem; border: 1px solid var(--border); border-radius: var(--radius);
|
.mdl-overlay__head h2 { margin: 0; font-size: 1.1rem; }
|
||||||
background: var(--bg); color: var(--text);
|
.mdl-overlay__close { background: none; border: none; font-size: 1.6rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; }
|
||||||
|
.mdl-overlay__close:hover { color: var(--text); }
|
||||||
|
.mdl-overlay__status { padding: 0.4rem 1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); }
|
||||||
|
.mdl-overlay__table { flex: 1; min-height: 0; }
|
||||||
|
.mdl-overlay__foot { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* ── Shared selectable + autofilter table (seltable) ────────────────────── */
|
||||||
|
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
||||||
|
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||||
|
.seltable__filter {
|
||||||
|
flex: 1; min-width: 8rem; padding: 0.3rem 0.5rem;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.scratch-paste__preview, .scratch-match__list { max-height: 38vh; overflow: auto; margin-top: 0.6rem; }
|
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
|
||||||
.scratch-preview__table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
|
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
|
||||||
.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; }
|
.seltable__table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
||||||
.scratch-preview__table th { color: var(--text-muted); font-size: 0.66rem; text-transform: uppercase; }
|
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
|
||||||
.scratch-preview__skip { color: var(--danger); font-size: 0.76rem; padding: 0.1rem 0; }
|
.seltable__table thead th {
|
||||||
.scratch-preview__more { color: var(--text-muted); font-size: 0.76rem; padding: 0.2rem 0; }
|
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
|
||||||
.scratch-match__fuzzy { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--text-muted); }
|
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
|
||||||
.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 */
|
.seltable__row { cursor: pointer; user-select: none; }
|
||||||
.scratch-match__row--review .scratch-match__conf { color: var(--warning, #b8860b); }
|
.seltable__row:hover { background: var(--bg-hover); }
|
||||||
.scratch-match__file { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
|
||||||
.scratch-match__arrow { color: var(--text-muted); }
|
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
|
||||||
.scratch-match__tn { font-family: var(--mono, monospace); }
|
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
|
||||||
.scratch-match__conf { color: var(--text-muted); font-size: 0.72rem; min-width: 6rem; text-align: right; white-space: nowrap; }
|
|
||||||
.worklist-cur { font-family: var(--mono, monospace); color: var(--text-muted); }
|
|
||||||
|
|
||||||
/* The base seltable rules live in shared/seltable.css (bundled by build.sh and
|
|
||||||
shared with the tables tool); only the classifier-specific catalog bits
|
|
||||||
(.seltable__extra, .worklist-rev__input, .worklist-*, .src-badge, #worklistTable) are
|
|
||||||
here. */
|
|
||||||
|
|
||||||
/* ── Copy destination dialog ────────────────────────────────────────────── */
|
/* ── Copy destination dialog ────────────────────────────────────────────── */
|
||||||
.copy-choice__backdrop {
|
.copy-choice__backdrop {
|
||||||
|
|
@ -672,22 +638,9 @@ 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; min-height: 0; } /* seltable owns its own scroll + padding */
|
#trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */
|
||||||
#trackingTree .seltable { height: 100%; }
|
|
||||||
.ttable { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
.ttable { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
||||||
.ttable th, .ttable td {
|
.ttable th, .ttable td {
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
|
|
@ -717,15 +670,7 @@ 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; }
|
||||||
/* The hover-only node controls must NOT reserve column width (they're invisible
|
.ttable__cell:hover .tnode__actions, .ttable__rev:hover .tnode__actions { opacity: 1; }
|
||||||
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; }
|
||||||
|
|
@ -741,36 +686,3 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
||||||
.tfile__badge { font-size: 0.78rem; flex: 0 0 auto; }
|
.tfile__badge { 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) — now on seltable, so
|
|
||||||
the cell classes (.tg-*) ride seltable's th/td; seltable owns layout. ── */
|
|
||||||
.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, .tx-path .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; }
|
|
||||||
/* Placeholder rows (a list item with no file yet) — a hollow "wanted" marker and
|
|
||||||
a muted expected-name hint that doubles as the drop target. */
|
|
||||||
.tg-wanted { color: var(--text-muted); }
|
|
||||||
.tg-expected { color: var(--text-muted); font-style: italic; }
|
|
||||||
.tg-x__btn { opacity: 0.5; }
|
|
||||||
.seltable__row:hover .tg-x__btn { opacity: 1; }
|
|
||||||
.tg-drop-hover { outline: 2px dashed var(--primary); outline-offset: -3px; background: var(--primary-light); }
|
|
||||||
/* The exact placeholder rows a multi-file drop will fill (the drag indicator). */
|
|
||||||
.seltable__row.tg-fill-target { background: var(--primary-light, rgba(37,99,235,0.18)); outline: 2px solid var(--primary); outline-offset: -2px; }
|
|
||||||
|
|
||||||
/* "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); }
|
|
||||||
|
|
|
||||||
|
|
@ -149,10 +149,9 @@
|
||||||
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
|
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
|
||||||
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
|
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
|
||||||
showEmptyCheckbox: document.getElementById('showEmptyCheckbox'),
|
showEmptyCheckbox: document.getElementById('showEmptyCheckbox'),
|
||||||
exportListBtn: document.getElementById('exportListBtn'),
|
exportDatasetBtn: document.getElementById('exportDatasetBtn'),
|
||||||
exportPathsBtn: document.getElementById('exportPathsBtn'),
|
importDatasetBtn: document.getElementById('importDatasetBtn'),
|
||||||
importPathsBtn: document.getElementById('importPathsBtn'),
|
importDatasetInput: document.getElementById('importDatasetInput'),
|
||||||
importPathsInput: document.getElementById('importPathsInput'),
|
|
||||||
resetDatasetBtn: document.getElementById('resetDatasetBtn'),
|
resetDatasetBtn: document.getElementById('resetDatasetBtn'),
|
||||||
treeFilterInput: document.getElementById('treeFilterInput'),
|
treeFilterInput: document.getElementById('treeFilterInput'),
|
||||||
trackingFilterInput: document.getElementById('trackingFilterInput'),
|
trackingFilterInput: document.getElementById('trackingFilterInput'),
|
||||||
|
|
@ -193,19 +192,22 @@
|
||||||
* onto target trees, copy renamed copies out). The source tree (left) stays
|
* onto target trees, copy renamed copies out). The source tree (left) stays
|
||||||
* in both modes; only the right pane swaps.
|
* in both modes; only the right pane swaps.
|
||||||
*/
|
*/
|
||||||
// There is only one surface now (the classify grid + transmittal tree); the
|
function setMode(mode) {
|
||||||
// old Rename-in-place spreadsheet was folded into the By-tracking grid's
|
const classify = mode === 'classify';
|
||||||
// "Rename…" action. setMode is kept as a no-arg enabler for back-compat with
|
app.dom.modeRenameBtn.classList.toggle('active', !classify);
|
||||||
// the workspace/open flows that call it.
|
app.dom.modeClassifyBtn.classList.toggle('active', classify);
|
||||||
function setMode() {
|
if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify;
|
||||||
if (app.dom.targetPane) app.dom.targetPane.hidden = false;
|
if (app.dom.targetPane) app.dom.targetPane.hidden = !classify;
|
||||||
if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = false;
|
// Mode-specific source-tree filters: "Hide Compliant" is for the rename
|
||||||
app.modules.classify.setEnabled(true);
|
// grid; "Hide Assigned" is for the classify workflow.
|
||||||
if (app.modules.targetTree) {
|
if (app.dom.hideCompliantLabel) app.dom.hideCompliantLabel.hidden = classify;
|
||||||
|
if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = !classify;
|
||||||
|
app.modules.classify.setEnabled(classify);
|
||||||
|
if (classify && app.modules.targetTree) {
|
||||||
app.modules.targetTree.init();
|
app.modules.targetTree.init();
|
||||||
app.modules.targetTree.render();
|
app.modules.targetTree.render();
|
||||||
}
|
}
|
||||||
// Re-render the source tree so its per-file markers appear.
|
// Re-render the source tree so its per-file markers appear/disappear.
|
||||||
if (app.modules.tree) app.modules.tree.render();
|
if (app.modules.tree) app.modules.tree.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,102 +221,89 @@
|
||||||
(nodes || []).forEach(function (n) { (n.files || []).forEach(cb); walk(n.children); });
|
(nodes || []).forEach(function (n) { (n.files || []).forEach(cb); walk(n.children); });
|
||||||
})(app.folderTree || []);
|
})(app.folderTree || []);
|
||||||
}
|
}
|
||||||
// CSV cell quoting (RFC4180): quote when the value holds a comma, quote, or
|
function exportDataset() {
|
||||||
// newline; embedded quotes are doubled.
|
var c = app.modules.classify, files = [];
|
||||||
function csvCell(s) { s = (s == null ? '' : String(s)); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }
|
eachSourceFile(function (f) {
|
||||||
// Minimal RFC4180-ish CSV parser → array of rows of string cells. Handles
|
var key = c.srcKeyForFile(f);
|
||||||
// quoted fields with embedded commas/quotes/newlines (titles may contain
|
var a = c.getAssignment(key) || {};
|
||||||
// commas). CRLF/CR are normalized to LF.
|
var d = c.deriveTarget(f);
|
||||||
function parseCsv(text) {
|
var rec = {
|
||||||
var rows = [], row = [], field = '', inQ = false, i = 0;
|
source: key,
|
||||||
text = String(text == null ? '' : text).replace(/\r\n?/g, '\n');
|
originalName: window.zddc.joinExtension(f.originalFilename, f.extension),
|
||||||
for (; i < text.length; i++) {
|
filename: a.excluded ? '' : (d.filename || ''),
|
||||||
var ch = text[i];
|
excluded: !!a.excluded,
|
||||||
if (inQ) {
|
};
|
||||||
if (ch === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else { inQ = false; } }
|
if (!a.excluded && a.transmittalNodeId) {
|
||||||
else { field += ch; }
|
var t = c.transmittalRecord(a.transmittalNodeId);
|
||||||
} else if (ch === '"') { inQ = true; }
|
if (t) rec.transmittal = t;
|
||||||
else if (ch === ',') { row.push(field); field = ''; }
|
|
||||||
else if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
|
|
||||||
else { field += ch; }
|
|
||||||
}
|
}
|
||||||
if (field !== '' || row.length) { row.push(field); rows.push(row); }
|
files.push(rec);
|
||||||
return rows;
|
});
|
||||||
|
var payload = {
|
||||||
|
zddcClassifierFiles: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
_format: 'One record per input file. Set "filename" to its full ZDDC name '
|
||||||
|
+ '"TRACKING_REV (STATUS) - Title.ext" — on import the app splits TRACKING on "-" and the '
|
||||||
|
+ 'final "_" into nested folders, and files in shared paths share ancestors. Set '
|
||||||
|
+ '"excluded": true for non-documents (filename then ignored). "transmittal" is optional: '
|
||||||
|
+ '{party, slot:"received"|"issued", date:"YYYY-MM-DD", type:"TRN"|"SUB", seq, status, title}. '
|
||||||
|
+ 'Classify every "source" key; do not invent files.',
|
||||||
|
outputName: c.serialize().outputName || null,
|
||||||
|
files: files,
|
||||||
|
};
|
||||||
|
var name = 'classifier-dataset';
|
||||||
|
try {
|
||||||
|
if (app.modules.workspace && typeof app.modules.workspace.activeName === 'function') {
|
||||||
|
name = app.modules.workspace.activeName() || name;
|
||||||
}
|
}
|
||||||
// Trigger a client-side download of `text` as `name`.
|
} catch (_) { /* ok */ }
|
||||||
function downloadText(text, name, mime) {
|
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||||
var blob = new Blob([text], { type: mime || 'text/plain' });
|
|
||||||
var url = URL.createObjectURL(blob);
|
var url = URL.createObjectURL(blob);
|
||||||
var a = document.createElement('a'); a.href = url; a.download = name;
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = String(name).replace(/[^\w.-]+/g, '_') + '.zddc-classification.json';
|
||||||
document.body.appendChild(a); a.click(); a.remove();
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
// Import a 2-column CSV (old path, new path) — e.g. an AI-classified list.
|
function importDataset(file) {
|
||||||
// MERGE semantics: only files named in the CSV are touched; others keep their
|
|
||||||
// current classification. Each new path
|
|
||||||
// "<party>/<direction>/<transmittal>/<file>.ext" drives two axes — the
|
|
||||||
// filename sets the tracking number (rename) and the leading segments route a
|
|
||||||
// transmittal. Either axis can apply independently; per-row problems are
|
|
||||||
// collected and offered as a downloadable errors CSV (the list can be huge).
|
|
||||||
function importPaths(file) {
|
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
reader.onload = function () {
|
reader.onload = function () {
|
||||||
var rows = parseCsv(reader.result);
|
var obj;
|
||||||
if (!rows.length) { window.zddc.toast('Import failed — the CSV is empty.', 'error'); return; }
|
try { obj = JSON.parse(reader.result); }
|
||||||
|
catch (e) { window.zddc.toast('Import failed — not valid JSON.', 'error'); return; }
|
||||||
|
if (!obj || !Array.isArray(obj.files)) {
|
||||||
|
window.zddc.toast('Import failed — expected a classifier dataset with a "files" list.', 'error'); return;
|
||||||
|
}
|
||||||
var c = app.modules.classify;
|
var c = app.modules.classify;
|
||||||
// Old path must resolve to a real scanned file (srcKey set).
|
var hasData = c.getTrackingTree().length || c.getTransmittalTree().length
|
||||||
var valid = Object.create(null);
|
|| Object.keys(c.serialize().assignments || {}).length;
|
||||||
eachSourceFile(function (f) { valid[c.srcKeyForFile(f)] = true; });
|
if (hasData && !confirm('Replace the current classification with the imported dataset?')) return;
|
||||||
|
c.reset();
|
||||||
var imported = 0, errors = [];
|
var ok = 0, bad = 0;
|
||||||
rows.forEach(function (cells, idx) {
|
obj.files.forEach(function (rec) {
|
||||||
var oldPath = (cells[0] || '').trim();
|
if (!rec || !rec.source) return;
|
||||||
var newPath = (cells[1] || '').trim();
|
var key = rec.source;
|
||||||
// Tolerate a header row (first row whose first cell isn't a file).
|
if (rec.excluded) { c.setExcluded([key], true); ok++; return; }
|
||||||
if (idx === 0 && !valid[oldPath] && /^(old|path|source|from)\b/i.test(oldPath)) return;
|
if (rec.filename) {
|
||||||
if (!oldPath && !newPath) return; // blank line
|
var p = window.zddc.parseFilename(String(rec.filename).trim());
|
||||||
if (!oldPath) { errors.push([oldPath, newPath, 'missing old path']); return; }
|
|
||||||
if (!valid[oldPath]) { errors.push([oldPath, newPath, 'no such file in the current scan']); return; }
|
|
||||||
if (!newPath) { errors.push([oldPath, newPath, 'missing new path']); return; }
|
|
||||||
|
|
||||||
var segs = newPath.split('/').filter(function (s) { return s !== ''; });
|
|
||||||
if (!segs.length) { errors.push([oldPath, newPath, 'empty new path']); return; }
|
|
||||||
var filename = segs[segs.length - 1];
|
|
||||||
var leading = segs.slice(0, -1);
|
|
||||||
var didTracking = false, didTransmittal = false, rowErr = '';
|
|
||||||
function note(m) { rowErr = rowErr ? rowErr + '; ' + m : m; }
|
|
||||||
|
|
||||||
// Axis 1 — filename → tracking tree (the rename).
|
|
||||||
var p = window.zddc.parseFilename(filename);
|
|
||||||
if (p && p.valid) {
|
if (p && p.valid) {
|
||||||
var stem = p.trackingNumber + '_' + p.revision + ' (' + p.status + ')';
|
var stem = p.trackingNumber + '_' + p.revision + ' (' + p.status + ')';
|
||||||
c.place([oldPath], c.addTrackingPath(null, c.parseFolderLevels(stem)), 'tracking');
|
c.place([key], c.addTrackingPath(null, c.parseFolderLevels(stem)), 'tracking');
|
||||||
if (p.title != null) c.setTitleOverride(oldPath, p.title);
|
if (p.title != null) c.setTitleOverride(key, p.title);
|
||||||
didTracking = true;
|
ok++;
|
||||||
} else {
|
} else { bad++; }
|
||||||
note('filename is not a valid ZDDC name "' + filename + '"');
|
|
||||||
}
|
}
|
||||||
|
if (rec.transmittal && rec.transmittal.party) {
|
||||||
// Axis 2 — <party>/<direction>/<transmittal> → transmittal tree (the
|
var t = rec.transmittal;
|
||||||
// route). Same parser the By-transmittal grid uses.
|
var pid = c.findOrAddParty(t.party);
|
||||||
if (leading.length >= 1) {
|
var bid = c.findOrAddTransmittalBin(pid, t.slot || 'received', {
|
||||||
var terr = c.setTransmittalPath([oldPath], leading.join('/'));
|
date: t.date, type: t.type || 'TRN', seq: t.seq, status: t.status, title: t.title,
|
||||||
if (terr) note(terr); else didTransmittal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (didTracking || didTransmittal) imported++;
|
|
||||||
if (rowErr) errors.push([oldPath, newPath, rowErr]);
|
|
||||||
});
|
});
|
||||||
|
if (bid) c.place([key], bid, 'transmittal');
|
||||||
if (errors.length) {
|
|
||||||
var elines = ['old path,new path,reason'];
|
|
||||||
errors.forEach(function (e) { elines.push(csvCell(e[0]) + ',' + csvCell(e[1]) + ',' + csvCell(e[2])); });
|
|
||||||
downloadText(elines.join('\n'), 'classifier-import-errors.csv', 'text/csv');
|
|
||||||
}
|
}
|
||||||
window.zddc.toast('Imported ' + imported + ' file' + (imported === 1 ? '' : 's')
|
});
|
||||||
+ (errors.length ? (' — ' + errors.length + ' row' + (errors.length === 1 ? '' : 's')
|
window.zddc.toast('Imported ' + ok + ' file' + (ok === 1 ? '' : 's')
|
||||||
+ ' had problems (downloaded classifier-import-errors.csv)') : '') + '.',
|
+ (bad ? (' — ' + bad + ' had an unparseable filename') : '') + '.', bad ? 'warning' : 'success');
|
||||||
errors.length ? 'warning' : 'success');
|
|
||||||
};
|
};
|
||||||
reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); };
|
reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); };
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
|
|
@ -349,9 +338,18 @@
|
||||||
// Drag and drop on welcome screen
|
// Drag and drop on welcome screen
|
||||||
setupWelcomeDragDrop();
|
setupWelcomeDragDrop();
|
||||||
|
|
||||||
// (The old Rename-in-place spreadsheet — Save All / Cancel All / SHA256 /
|
// Bulk actions
|
||||||
// Export hashes — was removed; its rename is now the By-tracking "Rename…".)
|
app.dom.saveAllBtn.addEventListener('click', handleSaveAll);
|
||||||
if (app.dom.hideCompliantCheckbox) app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
|
app.dom.cancelAllBtn.addEventListener('click', handleCancelAll);
|
||||||
|
|
||||||
|
// Export hashes
|
||||||
|
app.dom.exportHashesBtn.addEventListener('click', handleExportHashes);
|
||||||
|
|
||||||
|
// SHA256 toggle
|
||||||
|
app.dom.sha256Checkbox.addEventListener('change', handleSha256Toggle);
|
||||||
|
|
||||||
|
// Hide compliant toggle
|
||||||
|
app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
|
||||||
|
|
||||||
// Classify-mode source-tree filters: show/hide unassigned, assigned, excluded.
|
// Classify-mode source-tree filters: show/hide unassigned, assigned, excluded.
|
||||||
function pushClassifyFilters() {
|
function pushClassifyFilters() {
|
||||||
|
|
@ -368,16 +366,16 @@
|
||||||
[app.dom.showUnassignedCheckbox, app.dom.showPartialCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox, app.dom.showEmptyCheckbox]
|
[app.dom.showUnassignedCheckbox, app.dom.showPartialCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox, app.dom.showEmptyCheckbox]
|
||||||
.forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); });
|
.forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); });
|
||||||
|
|
||||||
// Export the filtered file list (path + file TSV) for the Excel round-trip.
|
|
||||||
if (app.dom.exportListBtn) app.dom.exportListBtn.addEventListener('click', function () {
|
|
||||||
if (app.modules.tree && app.modules.tree.exportFilteredList) app.modules.tree.exportFilteredList();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Collapse tree button
|
// Collapse tree button
|
||||||
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
|
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
|
||||||
|
|
||||||
|
// Workflow mode switch
|
||||||
|
if (app.dom.modeRenameBtn) app.dom.modeRenameBtn.addEventListener('click', function () { setMode('rename'); });
|
||||||
|
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 () {
|
||||||
|
|
@ -394,13 +392,11 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dataset export / import (round-trip the classification through a JSON file).
|
// Dataset export / import (round-trip the classification through a JSON file).
|
||||||
if (app.dom.exportPathsBtn) app.dom.exportPathsBtn.addEventListener('click', function () {
|
if (app.dom.exportDatasetBtn) app.dom.exportDatasetBtn.addEventListener('click', exportDataset);
|
||||||
if (app.modules.tree && app.modules.tree.exportPathList) app.modules.tree.exportPathList();
|
if (app.dom.importDatasetBtn) app.dom.importDatasetBtn.addEventListener('click', function () { app.dom.importDatasetInput.click(); });
|
||||||
});
|
|
||||||
if (app.dom.importPathsBtn) app.dom.importPathsBtn.addEventListener('click', function () { app.dom.importPathsInput.click(); });
|
|
||||||
if (app.dom.resetDatasetBtn) app.dom.resetDatasetBtn.addEventListener('click', resetDataset);
|
if (app.dom.resetDatasetBtn) app.dom.resetDatasetBtn.addEventListener('click', resetDataset);
|
||||||
if (app.dom.importPathsInput) app.dom.importPathsInput.addEventListener('change', function () {
|
if (app.dom.importDatasetInput) app.dom.importDatasetInput.addEventListener('change', function () {
|
||||||
if (this.files && this.files[0]) importPaths(this.files[0]);
|
if (this.files && this.files[0]) importDataset(this.files[0]);
|
||||||
this.value = ''; // allow re-importing the same file
|
this.value = ''; // allow re-importing the same file
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -543,7 +539,12 @@
|
||||||
showMainUI();
|
showMainUI();
|
||||||
if (!shellInited) {
|
if (!shellInited) {
|
||||||
shellInited = true;
|
shellInited = true;
|
||||||
app.modules.preview.init(); // file preview (click a row / original-name link)
|
app.modules.spreadsheet.init(); // Subscribe to store
|
||||||
|
app.modules.selection.init();
|
||||||
|
app.modules.preview.init(); // After selection so it can listen for rowfocused
|
||||||
|
app.modules.resize.init();
|
||||||
|
app.modules.filter.init();
|
||||||
|
app.modules.sort.init();
|
||||||
app.modules.tree.setupKeyboardShortcuts();
|
app.modules.tree.setupKeyboardShortcuts();
|
||||||
if (app.modules.targetTree) app.modules.targetTree.init();
|
if (app.modules.targetTree) app.modules.targetTree.init();
|
||||||
}
|
}
|
||||||
|
|
@ -553,8 +554,9 @@
|
||||||
async function openDirectory(dirHandle) {
|
async function openDirectory(dirHandle) {
|
||||||
app.rootHandle = dirHandle;
|
app.rootHandle = dirHandle;
|
||||||
enterAppShell();
|
enterAppShell();
|
||||||
setMode(); // the single classify surface
|
// Default to Classify & Copy (the primary workflow). The user can switch
|
||||||
|
// to "Rename in place" via the toggle for the spreadsheet.
|
||||||
|
setMode('classify');
|
||||||
// Now scan directory (this will trigger store updates and renders)
|
// Now scan directory (this will trigger store updates and renders)
|
||||||
await app.modules.scanner.scanDirectory(dirHandle);
|
await app.modules.scanner.scanDirectory(dirHandle);
|
||||||
}
|
}
|
||||||
|
|
@ -663,8 +665,18 @@
|
||||||
* Handle keyboard shortcuts
|
* Handle keyboard shortcuts
|
||||||
*/
|
*/
|
||||||
function handleKeyDown(e) {
|
function handleKeyDown(e) {
|
||||||
// (Spreadsheet Ctrl+S / Escape handlers removed with the Rename-in-place
|
// Ctrl+S - Save All
|
||||||
// pane. The By-tracking grid commits edits on change.)
|
if (e.ctrlKey && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!app.dom.saveAllBtn.disabled) {
|
||||||
|
handleSaveAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape - Cancel editing
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
app.modules.spreadsheet.cancelEditing();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -696,7 +708,6 @@
|
||||||
* Update stats display
|
* Update stats display
|
||||||
*/
|
*/
|
||||||
function updateStats() {
|
function updateStats() {
|
||||||
if (!app.dom.totalFiles) return; // spreadsheet pane removed — nothing to update
|
|
||||||
const files = app.modules.store.getDisplayFiles();
|
const files = app.modules.store.getDisplayFiles();
|
||||||
const totalFiles = files.length;
|
const totalFiles = files.length;
|
||||||
const modifiedFiles = files.filter(f => f.isDirty).length;
|
const modifiedFiles = files.filter(f => f.isDirty).length;
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,7 @@
|
||||||
// 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: 'PHASE', optional: false },
|
{ name: 'PROJ', 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 },
|
||||||
|
|
@ -59,9 +57,7 @@
|
||||||
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)
|
||||||
worklist: [], // "From a list" scratch rows: [ { id, trackingNumber, title, revisionCell, source, archiveRevisions } ]
|
mdlList: [], // loaded MDL deliverables (drop targets): [ { id, party, trackingNumber, title, revisionCell } ]
|
||||||
trackingWorkset: Object.create(null), // srcKeys shown as rows in the By-tracking grid (set: key->true)
|
|
||||||
transmittalWorkset: Object.create(null), // srcKeys shown as rows in the By-transmittal grid (set: key->true)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
|
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
|
||||||
|
|
@ -127,7 +123,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, excluded: false, titleOverride: null };
|
a = { trackingNodeId: null, transmittalNodeId: null, mdlNodeId: null, excluded: false, titleOverride: null, titleFromDeliverable: true };
|
||||||
state.assignments[key] = a;
|
state.assignments[key] = a;
|
||||||
}
|
}
|
||||||
return a;
|
return a;
|
||||||
|
|
@ -136,19 +132,22 @@
|
||||||
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.excluded && !a.titleOverride) {
|
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.mdlNodeId && !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. (The "From a list" tab also produces
|
// nodeId null clears that axis.
|
||||||
// 'tracking' placements — see assignFromRow.)
|
|
||||||
function place(keys, nodeId, axis) {
|
function place(keys, nodeId, axis) {
|
||||||
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
|
var field = axis === 'transmittal' ? 'transmittalNodeId' : axis === 'mdl' ? 'mdlNodeId' : 'trackingNodeId';
|
||||||
keys.forEach(function (k) {
|
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);
|
||||||
});
|
});
|
||||||
|
|
@ -159,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; }
|
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; a.mdlNodeId = null; }
|
||||||
cleanAssignment(k);
|
cleanAssignment(k);
|
||||||
});
|
});
|
||||||
clearHashConflicts();
|
clearHashConflicts();
|
||||||
|
|
@ -199,8 +198,9 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// Scratch-list rows are NOT placement targets — drops materialize real
|
(state.mdlList || []).forEach(function (row) {
|
||||||
// tracking-tree nodes (assignFromRow), so the list isn't indexed here.
|
nodeIndex[row.id] = { node: row, kind: 'mdl', parent: null };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
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,10 +333,26 @@
|
||||||
};
|
};
|
||||||
if (out.excluded) return out;
|
if (out.excluded) return out;
|
||||||
|
|
||||||
// Axis 1 — NAME, always the tracking tree. The "From a list" tab drops
|
// Axis 1 — NAME. An MDL deliverable (alternative to the tracking tree)
|
||||||
// also produce tracking-tree placements (assignFromRow), so there is a
|
// supplies the tracking number + title; its revision comes from the
|
||||||
// single name origin.
|
// classifier-local revision cell. Otherwise the tracking tree.
|
||||||
if (a.trackingNodeId) {
|
if (a.mdlNodeId) {
|
||||||
|
var mi = infoFor(a.mdlNodeId);
|
||||||
|
if (mi && mi.kind === 'mdl') {
|
||||||
|
var row = mi.node;
|
||||||
|
out.tracking = row.trackingNumber || '';
|
||||||
|
var ml = parseLeafLabel(row.revisionCell || '');
|
||||||
|
out.revision = ml.revision; out.status = ml.status;
|
||||||
|
out.trackingLeaf = true;
|
||||||
|
if (!a.titleOverride && a.titleFromDeliverable !== false && row.title) out.title = row.title;
|
||||||
|
if (!out.tracking) out.errors.push('deliverable has no tracking number');
|
||||||
|
if (!out.revision) out.errors.push('set a revision for this deliverable (e.g. "A (IFR)")');
|
||||||
|
else if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"');
|
||||||
|
else if (!out.status) out.errors.push('revision needs a "(STATUS)" — e.g. "A (IFR)"');
|
||||||
|
} else {
|
||||||
|
out.errors.push('deliverable no longer loaded');
|
||||||
|
}
|
||||||
|
} else if (a.trackingNodeId) {
|
||||||
var ti = infoFor(a.trackingNodeId);
|
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]
|
||||||
|
|
@ -380,6 +396,15 @@
|
||||||
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) {
|
||||||
|
|
@ -417,13 +442,7 @@
|
||||||
transmittalTree: state.transmittalTree,
|
transmittalTree: state.transmittalTree,
|
||||||
outputName: state.outputName,
|
outputName: state.outputName,
|
||||||
config: state.config,
|
config: state.config,
|
||||||
// Strip the transient row→keys hint (`placed`) — it's rebuilt as
|
mdlList: state.mdlList,
|
||||||
// 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),
|
|
||||||
transmittalWorkset: Object.keys(state.transmittalWorkset),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function load(obj) {
|
function load(obj) {
|
||||||
|
|
@ -433,185 +452,19 @@
|
||||||
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.worklist = (Array.isArray(obj.worklist) ? obj.worklist : []).map(normalizeRow);
|
state.mdlList = Array.isArray(obj.mdlList) ? obj.mdlList : [];
|
||||||
state.trackingWorkset = Object.create(null);
|
|
||||||
(Array.isArray(obj.trackingWorkset) ? obj.trackingWorkset : []).forEach(function (k) { state.trackingWorkset[k] = true; });
|
|
||||||
state.transmittalWorkset = Object.create(null);
|
|
||||||
(Array.isArray(obj.transmittalWorkset) ? obj.transmittalWorkset : []).forEach(function (k) { state.transmittalWorkset[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);
|
|
||||||
state.transmittalWorkset = 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);
|
|
||||||
}
|
|
||||||
// A file was renamed on disk to its canonical name — it's done, so drop it from
|
|
||||||
// the grid model entirely (under its OLD key): clear both placements, the
|
|
||||||
// workset, the assignment, and any worklist-row binding. The compliant file
|
|
||||||
// object itself stays in the scanned tree. No notify — the caller batches.
|
|
||||||
function forgetFile(key) {
|
|
||||||
var a = state.assignments[key], oldTrack = a ? a.trackingNodeId : null;
|
|
||||||
delete state.trackingWorkset[key];
|
|
||||||
place([key], null, 'tracking');
|
|
||||||
place([key], null, 'transmittal');
|
|
||||||
delete state.assignments[key];
|
|
||||||
state.worklist.forEach(function (r) { if (r.placed) delete r.placed[key]; if (r.bound) delete r.bound[key]; });
|
|
||||||
if (oldTrack) pruneEmptyTrackingChain(oldTrack);
|
|
||||||
}
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── By-transmittal grid (one editable row per file) ──────────────────────
|
|
||||||
// The transmittal tab mirrors the By-tracking grid: a flat, per-file surface
|
|
||||||
// where each file carries ONE text input — its full transmittal folder path
|
|
||||||
// "<party>/<received|issued>/<YYYY-MM-DD_TN (STATUS) - Title>". The path is
|
|
||||||
// PARSED into the transmittal tree (find-or-create party/slot/bin); structure
|
|
||||||
// is still derived, never stored. `transmittalWorkset` keeps a file on the
|
|
||||||
// grid before (and after) it has a path, exactly like `trackingWorkset`.
|
|
||||||
function addToTransmittalGrid(keys) {
|
|
||||||
var changed = false;
|
|
||||||
(keys || []).forEach(function (k) { if (!state.transmittalWorkset[k]) { state.transmittalWorkset[k] = true; changed = true; } });
|
|
||||||
if (changed) notify();
|
|
||||||
}
|
|
||||||
function transmittalGridKeys() {
|
|
||||||
var set = Object.create(null);
|
|
||||||
Object.keys(state.transmittalWorkset).forEach(function (k) { set[k] = true; });
|
|
||||||
Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].transmittalNodeId) set[k] = true; });
|
|
||||||
return Object.keys(set);
|
|
||||||
}
|
|
||||||
function transmittalHasFiles(binId) {
|
|
||||||
for (var k in state.assignments) { if (state.assignments[k].transmittalNodeId === binId) return true; }
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Delete a transmittal bin once nothing points at it (so re-routing doesn't
|
|
||||||
// litter the tree); drop the party too if it has no remaining bins.
|
|
||||||
function pruneEmptyTransmittal(binId) {
|
|
||||||
var info = infoFor(binId);
|
|
||||||
if (!info || info.kind !== 'transmittal' || transmittalHasFiles(binId)) return;
|
|
||||||
var slotInfo = info.parent ? infoFor(info.parent.id) : null;
|
|
||||||
var party = slotInfo && slotInfo.parent ? slotInfo.parent : null;
|
|
||||||
deleteNode(binId); // rebuilds the index + clears danglers
|
|
||||||
if (party) {
|
|
||||||
var anyBin = (party.children || []).some(function (slot) { return (slot.children || []).length; });
|
|
||||||
if (!anyBin) deleteNode(party.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function removeFromTransmittalGrid(key) {
|
|
||||||
var a = state.assignments[key], old = a ? a.transmittalNodeId : null;
|
|
||||||
delete state.transmittalWorkset[key];
|
|
||||||
place([key], null, 'transmittal');
|
|
||||||
if (old) pruneEmptyTransmittal(old);
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
// Route keys to the transmittal named by a "<party>/<slot>/<folder>" path,
|
|
||||||
// creating party/slot/bin as needed. Blank path clears the placement (the row
|
|
||||||
// stays, unrouted). Returns '' on success or a short error message; on error
|
|
||||||
// nothing is changed. Empties out (and prunes) any bin a key leaves behind.
|
|
||||||
function setTransmittalPath(keys, path) {
|
|
||||||
keys = keys || [];
|
|
||||||
path = (path == null ? '' : String(path)).trim();
|
|
||||||
var oldBins = Object.create(null);
|
|
||||||
keys.forEach(function (k) { var a = state.assignments[k]; if (a && a.transmittalNodeId) oldBins[a.transmittalNodeId] = true; });
|
|
||||||
if (!path) {
|
|
||||||
place(keys, null, 'transmittal');
|
|
||||||
keys.forEach(function (k) { state.transmittalWorkset[k] = true; });
|
|
||||||
Object.keys(oldBins).forEach(pruneEmptyTransmittal);
|
|
||||||
notify();
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
var segs = path.split('/').filter(function (s) { return s !== ''; });
|
|
||||||
if (segs.length < 3) return 'path must be <party>/<direction>/<transmittal>';
|
|
||||||
var party = segs[0], slot = segs[1].toLowerCase(), folder = segs.slice(2).join('/');
|
|
||||||
if (slot !== 'issued' && slot !== 'received') return 'direction must be "issued" or "received"';
|
|
||||||
var pf = zddc.parseFolder(folder);
|
|
||||||
if (!pf || !pf.valid) return 'not a valid transmittal folder "YYYY-MM-DD_TN (STATUS) - Title"';
|
|
||||||
var tnParts = pf.trackingNumber.split('-');
|
|
||||||
var seq = tnParts.pop(), type = tnParts.pop();
|
|
||||||
var bid = findOrAddTransmittalBin(findOrAddParty(party), slot, {
|
|
||||||
date: pf.date, type: type || 'TRN', seq: seq || '', status: pf.status, title: pf.title,
|
|
||||||
});
|
|
||||||
if (!bid) return 'could not create the transmittal';
|
|
||||||
place(keys, bid, 'transmittal');
|
|
||||||
keys.forEach(function (k) { state.transmittalWorkset[k] = true; });
|
|
||||||
delete oldBins[bid]; // keep the bin we just filled
|
|
||||||
Object.keys(oldBins).forEach(pruneEmptyTransmittal);
|
|
||||||
notify();
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── pattern config ───────────────────────────────────────────────────────
|
// ── pattern config ───────────────────────────────────────────────────────
|
||||||
function normalizeConfig(c) {
|
function normalizeConfig(c) {
|
||||||
var d = defaultConfig();
|
var d = defaultConfig();
|
||||||
|
|
@ -629,264 +482,39 @@
|
||||||
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(); }
|
||||||
|
|
||||||
// ── "From a list" scratch worklist ───────────────────────────────────────
|
// ── MDL deliverables (the "By MDL" drop-target axis) ─────────────────────
|
||||||
// A temporary list of known/typed tracking numbers (from the archive/MDL, a
|
function setMdlList(rows) {
|
||||||
// paste, or a name-match). Dropping a file on a row MATERIALIZES a real
|
state.mdlList = (rows || []).map(function (r) {
|
||||||
// tracking-tree placement (assignFromRow) — the list is pure input, so it can
|
|
||||||
// be cleared without losing any classification. `placed` is a transient
|
|
||||||
// row→keys hint (not the source of truth, not serialized) used to re-stamp a
|
|
||||||
// row's files when its tracking number / revision is edited.
|
|
||||||
function rowSource(r) {
|
|
||||||
if (r && r.source) return { mdl: !!r.source.mdl, archive: !!r.source.archive, pasted: !!r.source.pasted };
|
|
||||||
return { mdl: !!(r && r.inMdl), archive: !!(r && Array.isArray(r.archiveRevisions) && r.archiveRevisions.length), pasted: false };
|
|
||||||
}
|
|
||||||
function normalizeRow(r) {
|
|
||||||
r = r || {};
|
|
||||||
return {
|
return {
|
||||||
id: r.id || uid(), party: r.party || '',
|
id: r.id || uid(), party: r.party || '',
|
||||||
trackingNumber: (r.trackingNumber || '').trim(), title: r.title || '',
|
trackingNumber: r.trackingNumber || '', title: r.title || '',
|
||||||
revisionCell: r.revisionCell || '',
|
revisionCell: r.revisionCell || '',
|
||||||
// The file's existing name (pasted col 4) — a join key for name-match.
|
|
||||||
currentName: (r.currentName || '').trim(),
|
|
||||||
source: rowSource(r),
|
|
||||||
archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [],
|
|
||||||
placed: Object.create(null),
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
function setWorklist(rows) { state.worklist = (rows || []).map(normalizeRow); notify(); }
|
|
||||||
function appendWorklist(rows) {
|
|
||||||
var byTn = Object.create(null);
|
|
||||||
state.worklist.forEach(function (r) { if (r.trackingNumber) byTn[r.trackingNumber] = r; });
|
|
||||||
(rows || []).forEach(function (raw) {
|
|
||||||
var r = normalizeRow(raw), ex = r.trackingNumber ? byTn[r.trackingNumber] : null;
|
|
||||||
if (ex) {
|
|
||||||
if (!ex.revisionCell && r.revisionCell) ex.revisionCell = r.revisionCell;
|
|
||||||
if (!ex.title && r.title) ex.title = r.title;
|
|
||||||
ex.source.mdl = ex.source.mdl || r.source.mdl;
|
|
||||||
ex.source.archive = ex.source.archive || r.source.archive;
|
|
||||||
ex.source.pasted = ex.source.pasted || r.source.pasted;
|
|
||||||
if (r.archiveRevisions.length && !ex.archiveRevisions.length) ex.archiveRevisions = r.archiveRevisions;
|
|
||||||
} else {
|
|
||||||
state.worklist.push(r);
|
|
||||||
if (r.trackingNumber) byTn[r.trackingNumber] = r;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
notify();
|
// Drop placements pointing at deliverables no longer loaded.
|
||||||
}
|
var valid = Object.create(null);
|
||||||
function clearWorklist() { state.worklist = []; notify(); } // rows only — assignments survive
|
state.mdlList.forEach(function (r) { valid[r.id] = true; });
|
||||||
// Remove ONE worklist row (a placeholder's ✕). Any files it placed keep their
|
Object.keys(state.assignments).forEach(function (k) {
|
||||||
// assignments — only the scratch row goes away.
|
|
||||||
function removeWorklistRow(id) {
|
|
||||||
var before = state.worklist.length;
|
|
||||||
state.worklist = state.worklist.filter(function (r) { return r.id !== id; });
|
|
||||||
if (state.worklist.length !== before) notify();
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
if (!keys || !keys.length) return;
|
|
||||||
var leaf = leafForRow(row);
|
|
||||||
if (!leaf) {
|
|
||||||
// No tracking number on the row yet — still CLAIM these files for it
|
|
||||||
// (e.g. a pasted full path on a row whose tracking is still blank). The
|
|
||||||
// binding is recorded in row.bound; when a tracking/rev later lands,
|
|
||||||
// restampRow places the claimed files onto the new leaf.
|
|
||||||
keys.forEach(function (k) { row.placed[k] = true; (row.bound || (row.bound = Object.create(null)))[k] = true; });
|
|
||||||
notify();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
place(keys, leaf, 'tracking');
|
|
||||||
keys.forEach(function (k) {
|
|
||||||
row.placed[k] = true;
|
|
||||||
if (row.title && row.title.trim()) {
|
|
||||||
var a = state.assignments[k];
|
var a = state.assignments[k];
|
||||||
if (a && !a.titleOverride) setTitleOverride(k, row.title);
|
if (a.mdlNodeId && !valid[a.mdlNodeId]) { a.mdlNodeId = null; cleanAssignment(k); }
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
rebuildIndex();
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
function nodeHasFiles(nodeId) {
|
function getMdlList() { return state.mdlList; }
|
||||||
for (var k in state.assignments) { if (state.assignments[k].trackingNodeId === nodeId) return true; }
|
function getMdlRow(id) { var i = infoFor(id); return (i && i.kind === 'mdl') ? i.node : null; }
|
||||||
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), toPlace = [];
|
|
||||||
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 if (row.bound && row.bound[k]) toPlace.push(k); // claimed by path, not yet placed → place now
|
|
||||||
else delete row.placed[k]; // user un-placed it elsewhere — don't resurrect
|
|
||||||
});
|
|
||||||
if (toPlace.length) {
|
|
||||||
place(toPlace, leaf, 'tracking');
|
|
||||||
if (row.title && row.title.trim()) toPlace.forEach(function (k) { var aa = state.assignments[k]; if (aa && !aa.titleOverride) setTitleOverride(k, row.title); });
|
|
||||||
}
|
|
||||||
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.worklist.forEach(function (r) {
|
state.mdlList.forEach(function (r) { if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); changed = true; } });
|
||||||
if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); restampRow(r); changed = true; }
|
|
||||||
});
|
|
||||||
if (changed) notify();
|
if (changed) notify();
|
||||||
}
|
}
|
||||||
|
function setTitleFromDeliverable(key, fromDeliverable) {
|
||||||
// ── paste parsing + name matching (pure helpers, unit-tested) ─────────────
|
var a = assignmentFor(key);
|
||||||
// Parse Excel/TSV text into scratch rows. Columns: Tracking ⇥ Rev(Status) ⇥
|
a.titleFromDeliverable = !!fromDeliverable;
|
||||||
// Title; a 4th bare-status column merges into the revision; a lone cell that
|
cleanAssignment(key);
|
||||||
// parses as a full ZDDC filename is split; a header row is skipped.
|
notify();
|
||||||
// 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 baseName(s) { return String(s == null ? '' : s).split(/[\/\\]/).pop(); }
|
|
||||||
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 key = srcKeyForFile(f);
|
|
||||||
var best = null;
|
|
||||||
named.forEach(function (r) {
|
|
||||||
// A pasted FULL PATH equal to this file's key → an exact, direct
|
|
||||||
// bind (the strongest signal — wins over any name score).
|
|
||||||
if (r.currentName === key) { best = { row: r, confidence: 1, via: 'path' }; return; }
|
|
||||||
// Otherwise score on the name; a path that didn't match exactly is
|
|
||||||
// reduced to its basename so the fuzzy name match still applies.
|
|
||||||
var s = nameScore(baseName(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 ─────────────────────────────────────────
|
||||||
|
|
@ -1063,23 +691,13 @@
|
||||||
transmittalRecord: transmittalRecord,
|
transmittalRecord: transmittalRecord,
|
||||||
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
|
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
|
||||||
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
|
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
|
||||||
// By-tracking grid
|
setMdlList: setMdlList, getMdlList: getMdlList, getMdlRow: getMdlRow,
|
||||||
addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid,
|
|
||||||
trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity, forgetFile: forgetFile,
|
|
||||||
// By-transmittal grid
|
|
||||||
addToTransmittalGrid: addToTransmittalGrid, removeFromTransmittalGrid: removeFromTransmittalGrid,
|
|
||||||
transmittalGridKeys: transmittalGridKeys, setTransmittalPath: setTransmittalPath,
|
|
||||||
setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist,
|
|
||||||
removeWorklistRow: removeWorklistRow,
|
|
||||||
getWorklist: getWorklist, getWorklistRow: getWorklistRow,
|
|
||||||
assignFromRow: assignFromRow, unassignRowFile: unassignRowFile,
|
|
||||||
setRowTracking: setRowTracking, setRowTitle: setRowTitle,
|
|
||||||
setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells,
|
setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells,
|
||||||
parsePastedRows: parsePastedRows, proposeMatches: proposeMatches,
|
setTitleFromDeliverable: setTitleFromDeliverable,
|
||||||
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,
|
deriveTarget: deriveTarget, filesInNode: filesInNode,
|
||||||
fileState: fileState, stats: stats,
|
fileState: fileState, stats: stats,
|
||||||
// persistence
|
// persistence
|
||||||
serialize: serialize, load: load, reset: reset,
|
serialize: serialize, load: load, reset: reset,
|
||||||
|
|
|
||||||
|
|
@ -326,9 +326,7 @@
|
||||||
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);
|
||||||
var pressedBackdrop = false;
|
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|
@ -361,9 +359,7 @@
|
||||||
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);
|
||||||
var pressedBackdrop = false;
|
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
/**
|
|
||||||
* ZDDC Classifier — lazy, multi-select directory picker (modal).
|
|
||||||
*
|
|
||||||
* Given one or more root directory handles, render an expandable checkbox tree
|
|
||||||
* and let the user TICK the directories whose files they want. Ticking a
|
|
||||||
* directory includes its whole subtree; a descendant under a ticked ancestor
|
|
||||||
* shows as checked+disabled (covered). Confirm resolves with the TOPMOST ticked
|
|
||||||
* handles only (a ticked child under a ticked parent is dropped — the parent's
|
|
||||||
* recursive walk covers it). Cancel/Esc/backdrop → [].
|
|
||||||
*
|
|
||||||
* Handle-agnostic: a "handle" is anything exposing async `values()` (yielding
|
|
||||||
* child handles {name, kind}) and `getDirectoryHandle(name)` — satisfied by both
|
|
||||||
* zddc-source.js HttpDirectoryHandle and native FileSystemDirectoryHandle.
|
|
||||||
*
|
|
||||||
* window.app.modules.dirPicker.pick(roots) → Promise<handle[]>
|
|
||||||
* roots: [ { label, handle } ]
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
if (!window.app) window.app = {};
|
|
||||||
if (!window.app.modules) window.app.modules = {};
|
|
||||||
|
|
||||||
function elt(tag, cls, text) {
|
|
||||||
var e = document.createElement(tag);
|
|
||||||
if (cls) e.className = cls;
|
|
||||||
if (text != null) e.textContent = text;
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
// Same skip set as the archive walk: dotfiles, system (_), and risk folders.
|
|
||||||
function hiddenName(nm) { return nm.charAt(0) === '.' || nm.charAt(0) === '_' || nm === 'rsk'; }
|
|
||||||
|
|
||||||
function ancestorChecked(node) {
|
|
||||||
for (var p = node.parent; p; p = p.parent) { if (p.checked) return true; }
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Topmost ticked handles: a node whose own `checked` is set and which has no
|
|
||||||
// checked ancestor. Pure over { checked, handle, children } — also the test seam.
|
|
||||||
function collect(nodes, underChecked, out) {
|
|
||||||
(nodes || []).forEach(function (n) {
|
|
||||||
if (n.checked && !underChecked) out.push(n.handle);
|
|
||||||
collect(n.children, underChecked || !!n.checked, out);
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(roots) {
|
|
||||||
return new Promise(function (resolve) {
|
|
||||||
var done = false, rootNodes = [];
|
|
||||||
function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); }
|
|
||||||
function onKey(e) { if (e.key === 'Escape') finish([]); }
|
|
||||||
|
|
||||||
var back = elt('div', 'copy-choice__backdrop');
|
|
||||||
var box = elt('div', 'copy-choice copy-choice--wide');
|
|
||||||
var h = elt('h3', null, 'Choose directories to scan');
|
|
||||||
var p = elt('p', null, 'Tick the directories whose files you want in the catalog — subfolders are included. Expand with ▸.');
|
|
||||||
var treeWrap = elt('div', 'dir-picker__tree');
|
|
||||||
var btns = elt('div', 'copy-choice__btns');
|
|
||||||
var go = elt('button', 'btn btn-primary', 'Scan'); go.disabled = true;
|
|
||||||
go.addEventListener('click', function () { finish(collect(rootNodes, false, [])); });
|
|
||||||
var cancel = elt('button', 'btn btn-secondary', 'Cancel');
|
|
||||||
cancel.addEventListener('click', function () { finish([]); });
|
|
||||||
btns.appendChild(go); btns.appendChild(cancel);
|
|
||||||
box.appendChild(h); box.appendChild(p); box.appendChild(treeWrap); box.appendChild(btns);
|
|
||||||
back.appendChild(box);
|
|
||||||
var pressedBackdrop = false;
|
|
||||||
back.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === back); });
|
|
||||||
back.addEventListener('click', function (e) { if (e.target === back && pressedBackdrop) finish([]); });
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
document.body.appendChild(back);
|
|
||||||
|
|
||||||
function refreshGo() { go.disabled = collect(rootNodes, false, []).length === 0; }
|
|
||||||
|
|
||||||
// Recompute the displayed checkbox state of a subtree: a node under a
|
|
||||||
// checked ancestor is forced checked + disabled (inherited coverage).
|
|
||||||
function recompute(node, inherited) {
|
|
||||||
node.checkbox.disabled = inherited;
|
|
||||||
node.checkbox.checked = inherited || node.checked;
|
|
||||||
var below = inherited || node.checked;
|
|
||||||
node.children.forEach(function (c) { recompute(c, below); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeNode(handle, label, parent, container) {
|
|
||||||
var node = { handle: handle, name: label, parent: parent, checked: false, expanded: false, loaded: false, children: [], childrenWrap: null, checkbox: null };
|
|
||||||
var rowEl = elt('div', 'dir-picker__row');
|
|
||||||
var twisty = elt('span', 'dir-picker__twisty', '▸');
|
|
||||||
var cb = elt('input'); cb.type = 'checkbox';
|
|
||||||
var nameEl = elt('span', 'dir-picker__name', label);
|
|
||||||
twisty.addEventListener('click', function () { toggle(node, twisty); });
|
|
||||||
nameEl.addEventListener('click', function () { toggle(node, twisty); });
|
|
||||||
cb.addEventListener('change', function () {
|
|
||||||
if (cb.disabled) return;
|
|
||||||
node.checked = cb.checked;
|
|
||||||
recompute(node, ancestorChecked(node));
|
|
||||||
refreshGo();
|
|
||||||
});
|
|
||||||
rowEl.appendChild(twisty); rowEl.appendChild(cb); rowEl.appendChild(nameEl);
|
|
||||||
var kids = elt('div', 'dir-picker__children'); kids.hidden = true;
|
|
||||||
node.checkbox = cb; node.childrenWrap = kids;
|
|
||||||
container.appendChild(rowEl); container.appendChild(kids);
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggle(node, twisty) {
|
|
||||||
node.expanded = !node.expanded;
|
|
||||||
node.childrenWrap.hidden = !node.expanded;
|
|
||||||
twisty.textContent = node.expanded ? '▾' : '▸';
|
|
||||||
if (node.expanded && !node.loaded) {
|
|
||||||
node.loaded = true;
|
|
||||||
twisty.textContent = '…';
|
|
||||||
try {
|
|
||||||
for await (var e of node.handle.values()) {
|
|
||||||
if (e.kind !== 'directory') continue;
|
|
||||||
var nm = String(e.name).replace(/\/$/, '');
|
|
||||||
if (hiddenName(nm)) continue;
|
|
||||||
var childHandle = e.getDirectoryHandle ? e : await node.handle.getDirectoryHandle(nm);
|
|
||||||
var child = makeNode(childHandle, nm, node, node.childrenWrap);
|
|
||||||
node.children.push(child);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
node.childrenWrap.appendChild(elt('div', 'dir-picker__err', 'Could not read — ' + (err.message || err)));
|
|
||||||
}
|
|
||||||
twisty.textContent = node.children.length ? (node.expanded ? '▾' : '▸') : '·';
|
|
||||||
// A freshly-loaded subtree inherits an already-checked ancestor.
|
|
||||||
recompute(node, ancestorChecked(node));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(roots || []).forEach(function (r) { rootNodes.push(makeNode(r.handle, r.label, null, treeWrap)); });
|
|
||||||
if (!rootNodes.length) finish([]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.app.modules.dirPicker = {
|
|
||||||
pick: pick,
|
|
||||||
_collect: function (nodes) { return collect(nodes, false, []); }, // test seam
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
209
classifier/js/mdl-instantiate.js
Normal file
209
classifier/js/mdl-instantiate.js
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
/**
|
||||||
|
* ZDDC Classifier — instantiate MDL deliverables from existing archive files.
|
||||||
|
*
|
||||||
|
* Catch-up flow: the archive already holds issued documents, but the Master
|
||||||
|
* Deliverables List is empty. This reads a project's archive subtree as a flat
|
||||||
|
* file list, lets the user build a selection set (autofilter + ctrl-shift via
|
||||||
|
* the shared seltable), dedupes the selected files to one deliverable per
|
||||||
|
* tracking number, and PUTs a new deliverable .yaml into the originator's
|
||||||
|
* `archive/<originator>/mdl/` on the server. Server-only (needs http + auth).
|
||||||
|
*
|
||||||
|
* A deliverable .yaml's filename IS its tracking number; the server pins
|
||||||
|
* `originator` from the folder and composes the filename, so the body carries
|
||||||
|
* only project/discipline/type/sequence/suffix + title.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
if (!window.app) window.app = {};
|
||||||
|
if (!window.app.modules) window.app.modules = {};
|
||||||
|
|
||||||
|
function T(m, l, o) { if (window.zddc && window.zddc.toast) window.zddc.toast(m, l, o); }
|
||||||
|
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
||||||
|
|
||||||
|
// ── pure core (test seams) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// A tracking number → deliverable {tracking, originator, body{...}} or null
|
||||||
|
// if it doesn't fit the MDL schema (needs orig-proj-disc-type-seq, + suffix).
|
||||||
|
function deliverableFromFile(f) {
|
||||||
|
var segs = String(f.tracking || '').split('-');
|
||||||
|
if (segs.length < 5) return null;
|
||||||
|
var body = { project: segs[1], discipline: segs[2], type: segs[3], sequence: segs[4], title: f.title || '' };
|
||||||
|
if (segs.length >= 6) body.suffix = segs.slice(5).join('-');
|
||||||
|
return { tracking: f.tracking, originator: segs[0], body: body };
|
||||||
|
}
|
||||||
|
// Dedupe a list of archive files to one deliverable per tracking number.
|
||||||
|
function dedupe(files) {
|
||||||
|
var seen = Object.create(null), out = [];
|
||||||
|
(files || []).forEach(function (f) {
|
||||||
|
if (seen[f.tracking]) return;
|
||||||
|
var d = deliverableFromFile(f);
|
||||||
|
if (d) { seen[f.tracking] = true; out.push(d); }
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively walk an archive directory handle → flat list of ZDDC-named
|
||||||
|
// files (skips dot/underscore folders; non-ZDDC names like the mdl yamls
|
||||||
|
// naturally fall out because parseFilename rejects them).
|
||||||
|
async function walkArchive(rootHandle) {
|
||||||
|
var out = [];
|
||||||
|
async function walk(dirH, parts) {
|
||||||
|
for await (var entry of dirH.values()) {
|
||||||
|
var nm = String(entry.name || '').replace(/\/$/, '');
|
||||||
|
if (entry.kind === 'directory') {
|
||||||
|
var c = nm.charAt(0);
|
||||||
|
if (c === '.' || c === '_' || nm === 'mdl' || nm === 'rsk') continue;
|
||||||
|
var childH = await dirH.getDirectoryHandle(nm);
|
||||||
|
await walk(childH, parts.concat(nm));
|
||||||
|
} else {
|
||||||
|
var p = window.zddc.parseFilename(nm);
|
||||||
|
if (p && p.valid) {
|
||||||
|
out.push({
|
||||||
|
id: parts.concat(nm).join('/'),
|
||||||
|
party: parts[0] || '', slot: parts[1] || '', transmittal: parts[2] || '',
|
||||||
|
name: nm, tracking: p.trackingNumber, revision: p.revision, status: p.status, title: p.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(rootHandle, []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write one deliverable into <archiveRoot>/<originator>/mdl/<tracking>.yaml.
|
||||||
|
// Returns 'created' | 'skipped' (already present). Throws on server error.
|
||||||
|
async function instantiateOne(archiveRoot, d) {
|
||||||
|
var dir = await archiveRoot.getDirectoryHandle(d.originator, { create: true });
|
||||||
|
dir = await dir.getDirectoryHandle('mdl', { create: true });
|
||||||
|
var fname = d.tracking + '.yaml';
|
||||||
|
try { await dir.getFileHandle(fname); return 'skipped'; } catch (e) { /* NotFound → create */ }
|
||||||
|
var yaml = window.jsyaml.dump(d.body);
|
||||||
|
var fh = await dir.getFileHandle(fname, { create: true });
|
||||||
|
var w = await fh.createWritable();
|
||||||
|
await w.write(new Blob([yaml], { type: 'application/yaml' }));
|
||||||
|
await w.close();
|
||||||
|
return 'created';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function instantiateAll(archiveRoot, deliverables, onProgress) {
|
||||||
|
var s = { created: 0, skipped: 0, errors: 0 };
|
||||||
|
for (var i = 0; i < deliverables.length; i++) {
|
||||||
|
if (onProgress) onProgress(i + 1, deliverables.length, deliverables[i].tracking);
|
||||||
|
try { s[await instantiateOne(archiveRoot, deliverables[i])]++; }
|
||||||
|
catch (e) { s.errors++; T('Failed to create ' + deliverables[i].tracking + ' — ' + (e.message || e), 'error'); }
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI ───────────────────────────────────────────────────────────────────
|
||||||
|
var overlay = null, statusEl = null, table = null, files = [], archiveRoot = null;
|
||||||
|
|
||||||
|
function close() { if (overlay) { overlay.remove(); overlay = null; table = null; } }
|
||||||
|
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
|
||||||
|
|
||||||
|
async function open() {
|
||||||
|
var copy = window.app.modules.copy;
|
||||||
|
var src = window.zddc && window.zddc.source;
|
||||||
|
if (!src || location.protocol === 'file:') {
|
||||||
|
T('Populating the MDL from the archive needs the classifier served by a zddc-server (open it over http).', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var projects = await copy.fetchAccessProjects();
|
||||||
|
if (projects == null) { T('Could not load your projects from the server.', 'error'); return; }
|
||||||
|
if (!projects.length) { T('No projects you can access on this server.', 'warning'); return; }
|
||||||
|
var proj = await copy.chooseProject(projects);
|
||||||
|
if (!proj) return;
|
||||||
|
buildOverlay(proj);
|
||||||
|
await scan(proj);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverlay(proj) {
|
||||||
|
close();
|
||||||
|
overlay = el('div', 'mdl-overlay');
|
||||||
|
var box = el('div', 'mdl-overlay__box');
|
||||||
|
var head = el('div', 'mdl-overlay__head');
|
||||||
|
head.appendChild(el('h2', null, 'Populate MDL from archive — ' + (proj.title || proj.name)));
|
||||||
|
var x = el('button', 'mdl-overlay__close', '×'); x.title = 'Close'; x.addEventListener('click', close);
|
||||||
|
head.appendChild(x);
|
||||||
|
box.appendChild(head);
|
||||||
|
statusEl = el('div', 'mdl-overlay__status', 'Scanning archive…');
|
||||||
|
box.appendChild(statusEl);
|
||||||
|
var host = el('div', 'mdl-overlay__table');
|
||||||
|
box.appendChild(host);
|
||||||
|
var foot = el('div', 'mdl-overlay__foot');
|
||||||
|
var create = el('button', 'btn btn-primary', 'Create deliverables');
|
||||||
|
create.addEventListener('click', function () { runCreate(create); });
|
||||||
|
foot.appendChild(create);
|
||||||
|
var cancel = el('button', 'btn btn-secondary', 'Close'); cancel.addEventListener('click', close);
|
||||||
|
foot.appendChild(cancel);
|
||||||
|
box.appendChild(foot);
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
table = window.app.modules.seltable.create({
|
||||||
|
container: host,
|
||||||
|
filterPlaceholder: 'Filter by party, transmittal, tracking number, title…',
|
||||||
|
rows: function () { return files; },
|
||||||
|
rowId: function (r) { return r.id; },
|
||||||
|
columns: [
|
||||||
|
{ key: 'party', title: 'Party' },
|
||||||
|
{ key: 'slot', title: 'Slot' },
|
||||||
|
{ key: 'transmittal', title: 'Transmittal' },
|
||||||
|
{ key: 'tracking', title: 'Tracking number' },
|
||||||
|
{ key: 'revision', title: 'Rev', get: function (r) { return r.revision + (r.status ? ' (' + r.status + ')' : ''); } },
|
||||||
|
{ key: 'title', title: 'Title' },
|
||||||
|
],
|
||||||
|
onSelectionChange: function (ids) { create.textContent = ids.length ? ('Create deliverables (' + dedupe(selectedFiles(ids)).length + ')') : 'Create deliverables'; },
|
||||||
|
});
|
||||||
|
table.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedFiles(ids) {
|
||||||
|
var set = {}; ids.forEach(function (i) { set[i] = true; });
|
||||||
|
return files.filter(function (f) { return set[f.id]; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scan(proj) {
|
||||||
|
var src = window.zddc.source;
|
||||||
|
var rel = (proj.url || ('/' + proj.name + '/'));
|
||||||
|
if (rel.charAt(rel.length - 1) !== '/') rel += '/';
|
||||||
|
try {
|
||||||
|
archiveRoot = new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive');
|
||||||
|
setStatus('Scanning archive…');
|
||||||
|
files = await walkArchive(archiveRoot);
|
||||||
|
table.renderBody();
|
||||||
|
setStatus(files.length + ' document file' + (files.length === 1 ? '' : 's') + ' found. Filter + ctrl-shift select, then “Create deliverables”.');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Archive scan failed — ' + (e.message || e));
|
||||||
|
T('Archive scan failed — ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCreate(btn) {
|
||||||
|
if (!table) return;
|
||||||
|
var sel = table.getSelection();
|
||||||
|
if (!sel.length) { T('Select some archive files first (filter + ctrl-shift).', 'warning'); return; }
|
||||||
|
var deliverables = dedupe(selectedFiles(sel));
|
||||||
|
if (!deliverables.length) { T('None of the selected files have a tracking number that fits the deliverable schema.', 'warning'); return; }
|
||||||
|
if (!confirm('Create ' + deliverables.length + ' deliverable(s) in the project MDL?\n\n'
|
||||||
|
+ 'One .yaml per tracking number, in archive/<originator>/mdl/. Already-present ones are skipped.')) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
var s = await instantiateAll(archiveRoot, deliverables, function (i, n, tn) { setStatus('Creating ' + i + '/' + n + ' — ' + tn); });
|
||||||
|
btn.disabled = false;
|
||||||
|
setStatus(s.created + ' created, ' + s.skipped + ' already there'
|
||||||
|
+ (s.errors ? (', ' + s.errors + ' failed') : '') + '. ' + files.length + ' files scanned.');
|
||||||
|
T('MDL: ' + s.created + ' created, ' + s.skipped + ' already there'
|
||||||
|
+ (s.errors ? (', ' + s.errors + ' failed') : '') + '.', s.errors ? 'warning' : 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.modules.mdlInstantiate = {
|
||||||
|
open: open,
|
||||||
|
// test seams
|
||||||
|
deliverableFromFile: deliverableFromFile,
|
||||||
|
dedupe: dedupe,
|
||||||
|
walkArchive: walkArchive,
|
||||||
|
instantiateOne: instantiateOne,
|
||||||
|
instantiateAll: instantiateAll,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
/**
|
|
||||||
* ZDDC Classifier — in-place rename engine.
|
|
||||||
*
|
|
||||||
* Renames SOURCE files to their canonical ZDDC names, ON DISK, IN PLACE.
|
|
||||||
* DESTRUCTIVE — there is no backup. HTTP-backed handles (zddc-server) take the
|
|
||||||
* atomic server-side move (single round-trip); local File System Access handles
|
|
||||||
* copy+remove (the API has no native rename verb). The source folder is the only
|
|
||||||
* thing written.
|
|
||||||
*
|
|
||||||
* Resumable + boring: a file already at its target name is skipped, so a re-run
|
|
||||||
* after an interruption only renames what's left. One file in, one file out.
|
|
||||||
*
|
|
||||||
* Lifted out of the old Rename-in-place spreadsheet so the By-Tracking grid can
|
|
||||||
* drive the same, already-proven rename path.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// The folder handle holding `file`. Fresh-scan files carry it; snapshot-loaded
|
|
||||||
// files resolve it (and their own handle) lazily from the workspace root.
|
|
||||||
async function folderHandleFor(file) {
|
|
||||||
if (file.folderHandle) return file.folderHandle;
|
|
||||||
if (window.app.modules.scanner && window.app.modules.scanner.resolveFileHandle && window.app.rootHandle) {
|
|
||||||
await window.app.modules.scanner.resolveFileHandle(window.app.rootHandle, file);
|
|
||||||
if (file.folderHandle) return file.folderHandle;
|
|
||||||
}
|
|
||||||
throw new Error('source folder not connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename one file in place to `newName`. Returns 'renamed' | 'skipped'.
|
|
||||||
// Mutates the in-memory file object to its NEW identity (originalFilename /
|
|
||||||
// extension / handle) so the rest of the app sees the renamed file.
|
|
||||||
async function renameTo(file, newName) {
|
|
||||||
var oldName = window.zddc.joinExtension(file.originalFilename, file.extension);
|
|
||||||
if (oldName === newName) return 'skipped';
|
|
||||||
if (file.isVirtual) throw new Error('cannot rename a file inside a ZIP — extract it first');
|
|
||||||
|
|
||||||
var folder = await folderHandleFor(file);
|
|
||||||
var perm = await folder.queryPermission({ mode: 'readwrite' });
|
|
||||||
if (perm !== 'granted') {
|
|
||||||
var granted = await folder.requestPermission({ mode: 'readwrite' });
|
|
||||||
if (granted !== 'granted') throw new Error('write permission denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
var src = window.zddc.source;
|
|
||||||
if (src && src.isHttpHandle && src.isHttpHandle(folder)) {
|
|
||||||
var base = new URL(folder.url()).pathname;
|
|
||||||
await src.moveFile(base + encodeURIComponent(oldName), base + encodeURIComponent(newName));
|
|
||||||
file.handle = await folder.getFileHandle(newName);
|
|
||||||
} else {
|
|
||||||
var oldHandle = await folder.getFileHandle(oldName);
|
|
||||||
var data = await oldHandle.getFile();
|
|
||||||
var newHandle = await folder.getFileHandle(newName, { create: true });
|
|
||||||
var w = await newHandle.createWritable();
|
|
||||||
await w.write(data);
|
|
||||||
await w.close();
|
|
||||||
await folder.removeEntry(oldName);
|
|
||||||
file.handle = newHandle;
|
|
||||||
}
|
|
||||||
|
|
||||||
var split = window.zddc.splitExtension(newName);
|
|
||||||
file.originalFilename = split.name;
|
|
||||||
file.extension = split.extension;
|
|
||||||
return 'renamed';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename a batch of { file, newName } items. Returns { renamed, skipped, errors }.
|
|
||||||
// onProgress(done, total, name) is called before each file.
|
|
||||||
async function runInPlace(items, onProgress) {
|
|
||||||
var s = { renamed: 0, skipped: 0, errors: 0 };
|
|
||||||
for (var i = 0; i < items.length; i++) {
|
|
||||||
if (onProgress) onProgress(i + 1, items.length, items[i].newName);
|
|
||||||
try { s[await renameTo(items[i].file, items[i].newName)]++; }
|
|
||||||
catch (e) {
|
|
||||||
s.errors++;
|
|
||||||
if (window.zddc && window.zddc.toast) window.zddc.toast('Rename failed for ' + items[i].newName + ' — ' + (e.message || e), 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.app.modules.rename = { renameTo: renameTo, runInPlace: runInPlace };
|
|
||||||
})();
|
|
||||||
|
|
@ -8,17 +8,12 @@
|
||||||
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 on a table. Defaults to the rename-in-place
|
* Initialize column resizing
|
||||||
* 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(table, onResize) {
|
function init() {
|
||||||
table = table || (window.app.dom && window.app.dom.spreadsheet);
|
const table = 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 => {
|
||||||
|
|
@ -38,8 +33,6 @@
|
||||||
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);
|
||||||
|
|
@ -68,8 +61,6 @@
|
||||||
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
|
||||||
|
|
|
||||||
158
classifier/js/seltable.js
Normal file
158
classifier/js/seltable.js
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
/**
|
||||||
|
* ZDDC Classifier — shared selectable + autofilter table.
|
||||||
|
*
|
||||||
|
* A flat table with one global autofilter (AND of space-separated terms over
|
||||||
|
* every column) and powerful selection for building complex sets quickly:
|
||||||
|
* click replace selection + set anchor
|
||||||
|
* ctrl/cmd-click toggle one row
|
||||||
|
* shift-click range from the anchor (replaces the selection)
|
||||||
|
* ctrl-shift-click ADD the anchor→row range to the existing selection
|
||||||
|
* ctrl/cmd-Enter fire onActivate(selectedIds) — a bulk action
|
||||||
|
* Esc clear
|
||||||
|
* Ranges run over the CURRENTLY FILTERED order, so "filter to a transmittal,
|
||||||
|
* then shift-select the visible block" works. Selection is keyed by a stable
|
||||||
|
* rowId so it survives filtering and re-render.
|
||||||
|
*
|
||||||
|
* Used by the MDL instantiate flow (Phase 1) and the By-MDL drop-target table
|
||||||
|
* (Phase 2).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
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 ft = []; // global filter terms
|
||||||
|
|
||||||
|
function rows() { return getRows() || []; }
|
||||||
|
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 filtered() { return ft.length ? rows().filter(function (r) { return hit(rowBlob(r), ft); }) : rows().slice(); }
|
||||||
|
|
||||||
|
function getSelection() { return Object.keys(selected); }
|
||||||
|
function getFilteredRows() { return filtered(); }
|
||||||
|
function fireSel() { if (opts.onSelectionChange) opts.onSelectionChange(getSelection()); }
|
||||||
|
function setFilter(q) { ft = terms(q); renderBody(); }
|
||||||
|
function selectAllFiltered() { filtered().forEach(function (r) { selected[rowId(r)] = true; }); anchorId = null; renderBody(); fireSel(); }
|
||||||
|
function clearSel() { selected = Object.create(null); anchorId = null; renderBody(); fireSel(); }
|
||||||
|
|
||||||
|
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 filterEl = elt('input', 'seltable__filter'); filterEl.type = 'search';
|
||||||
|
filterEl.placeholder = opts.filterPlaceholder || 'Filter…'; filterEl.spellcheck = false;
|
||||||
|
filterEl.addEventListener('input', function () { setFilter(this.value); });
|
||||||
|
var allBtn = elt('button', 'btn btn-sm btn-secondary', 'Select filtered');
|
||||||
|
allBtn.addEventListener('click', selectAllFiltered);
|
||||||
|
var clrBtn = elt('button', 'btn btn-sm btn-secondary', 'Clear');
|
||||||
|
clrBtn.addEventListener('click', clearSel);
|
||||||
|
countEl = elt('span', 'seltable__count');
|
||||||
|
bar.appendChild(filterEl); 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); 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; // let controls work
|
||||||
|
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, selectAllFiltered: selectAllFiltered, clear: clearSel,
|
||||||
|
// test seam: simulate a row click with modifier keys.
|
||||||
|
clickRow: function (id, mods) {
|
||||||
|
var fr = filtered();
|
||||||
|
var row = fr.filter(function (r) { return String(rowId(r)) === String(id); })[0];
|
||||||
|
if (row) onRowClick(Object.assign({ shiftKey: false, ctrlKey: false, metaKey: false }, mods || {}), row, fr);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.modules.seltable = { create: create };
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,17 +5,6 @@
|
||||||
(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;
|
||||||
|
|
@ -69,8 +58,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' : 'trackingNodeId'; }
|
function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : ax === 'mdl' ? 'mdlNodeId' : 'trackingNodeId'; }
|
||||||
// Bucket a file relative to the active axis (tracking | transmittal):
|
// Bucket a file relative to the active axis (tracking | transmittal | mdl):
|
||||||
// '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) {
|
||||||
|
|
@ -79,7 +68,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'].filter(function (x) { return x !== ax; });
|
var others = ['tracking', 'transmittal', 'mdl'].filter(function (x) { return x !== ax; });
|
||||||
var any = a && others.some(function (x) { return a[axisField(x)]; });
|
var any = a && others.some(function (x) { return a[axisField(x)]; });
|
||||||
return any ? 'partial' : 'unassigned';
|
return any ? 'partial' : 'unassigned';
|
||||||
}
|
}
|
||||||
|
|
@ -111,40 +100,42 @@
|
||||||
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), counts = Object.create(null);
|
var folders = Object.create(null), files = Object.create(null), open = 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; dDir++; tDir += 1 + r.tDir; }
|
if (r.show) show = true;
|
||||||
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; dFile++; }
|
if (!nf || matched || fileMatch) { files[c.srcKeyForFile(f)] = true; show = true; }
|
||||||
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;
|
||||||
counts[folder.path] = { dDir: dDir, tDir: tDir, dFile: dFile, tFile: tFile };
|
// Auto-open ONLY the connector folders on the path down to a match —
|
||||||
return { show: show, hasFile: hasFile, subtreeMatch: descMatch || selfMatch, tDir: tDir, tFile: tFile };
|
// never the matched node itself. Terminal matches and everything
|
||||||
|
// off-path keep their real collapse state; the root's expand-all
|
||||||
|
// covers the rest. (Search reveals where hits are; it doesn't reshape
|
||||||
|
// the tree.)
|
||||||
|
if (nf && descMatch) open[folder.path] = true;
|
||||||
|
return { show: show, hasFile: hasFile, subtreeMatch: descMatch || selfMatch };
|
||||||
}
|
}
|
||||||
(window.app.folderTree || []).forEach(function (root) { walk(root, false); });
|
(window.app.folderTree || []).forEach(function (root) { walk(root, false); });
|
||||||
return { folders: folders, files: files, counts: counts };
|
return { folders: folders, files: files, open: open };
|
||||||
}
|
}
|
||||||
|
// True only for folders the search needs opened to expose a hit beneath them.
|
||||||
|
function autoOpen(folder) { return !!(visible && visible.open && visible.open[folder.path]); }
|
||||||
function folderShown(folder) { return !visible || !!visible.folders[folder.path]; }
|
function folderShown(folder) { return !visible || !!visible.folders[folder.path]; }
|
||||||
function fileShown(file) {
|
function fileShown(file) {
|
||||||
if (!classifyAllows(file)) return false;
|
if (!classifyAllows(file)) return false;
|
||||||
|
|
@ -166,89 +157,6 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Export the filtered file list to TSV (path + file) ──────────────────
|
|
||||||
// Every file passing the CURRENT tree filters (name search + the Show
|
|
||||||
// toggles), across the WHOLE tree — expand/collapse is display-only, so a
|
|
||||||
// collapsed folder's files are included just the same. `path` is the file's
|
|
||||||
// root-relative key (paste it into "Current name" to bind that exact file);
|
|
||||||
// `file` is the bare filename (paste it for a name to match/drop later).
|
|
||||||
function filteredFileObjects() {
|
|
||||||
var c = window.app.modules.classify;
|
|
||||||
var vis = anyFilter() ? computeVisible() : null;
|
|
||||||
var out = [];
|
|
||||||
(function walk(nodes) {
|
|
||||||
(nodes || []).forEach(function (n) {
|
|
||||||
(n.files || []).forEach(function (f) {
|
|
||||||
var show = vis ? !!vis.files[c.srcKeyForFile(f)] : classifyAllows(f);
|
|
||||||
if (show) out.push(f);
|
|
||||||
});
|
|
||||||
walk(n.children);
|
|
||||||
});
|
|
||||||
})(window.app.folderTree || []);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
function buildExportTsv() {
|
|
||||||
var c = window.app.modules.classify;
|
|
||||||
var files = filteredFileObjects().slice().sort(function (a, b) {
|
|
||||||
return cmpName(c.srcKeyForFile(a), c.srcKeyForFile(b));
|
|
||||||
});
|
|
||||||
var lines = ['path\tfile'];
|
|
||||||
files.forEach(function (f) {
|
|
||||||
lines.push(c.srcKeyForFile(f) + '\t' + window.zddc.joinExtension(f.originalFilename, f.extension));
|
|
||||||
});
|
|
||||||
return { tsv: lines.join('\n'), count: files.length };
|
|
||||||
}
|
|
||||||
function exportFilteredList() {
|
|
||||||
var built = buildExportTsv();
|
|
||||||
if (!built.count) { window.zddc.toast('No files to export — nothing passes the current filters.', 'info'); return; }
|
|
||||||
copyOrDownload(built.tsv, built.count);
|
|
||||||
}
|
|
||||||
// Download the filtered file list as a 1-column CSV of full (root-relative)
|
|
||||||
// paths — the same keys “Import paths” matches on. Meant to be handed to an AI
|
|
||||||
// that returns a 2-column old→new mapping.
|
|
||||||
function exportPathList() {
|
|
||||||
var c = window.app.modules.classify;
|
|
||||||
var files = filteredFileObjects().slice().sort(function (a, b) {
|
|
||||||
return cmpName(c.srcKeyForFile(a), c.srcKeyForFile(b));
|
|
||||||
});
|
|
||||||
if (!files.length) { window.zddc.toast('No files to export — nothing passes the current filters.', 'info'); return; }
|
|
||||||
function cell(s) { s = (s == null ? '' : String(s)); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }
|
|
||||||
var lines = ['path'];
|
|
||||||
files.forEach(function (f) { lines.push(cell(c.srcKeyForFile(f))); });
|
|
||||||
try {
|
|
||||||
var blob = new Blob([lines.join('\n')], { type: 'text/csv' });
|
|
||||||
var url = URL.createObjectURL(blob);
|
|
||||||
var a = document.createElement('a'); a.href = url; a.download = 'classifier-paths.csv';
|
|
||||||
document.body.appendChild(a); a.click(); a.remove();
|
|
||||||
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
|
||||||
window.zddc.toast('Exported ' + files.length + ' path' + (files.length === 1 ? '' : 's') + ' to classifier-paths.csv.', 'success');
|
|
||||||
} catch (e) { window.zddc.toast('Could not export the path list — ' + (e.message || e), 'error'); }
|
|
||||||
}
|
|
||||||
function copyOrDownload(text, count) {
|
|
||||||
function ok() { window.zddc.toast('Copied ' + count + ' file' + (count === 1 ? '' : 's') + ' (path + file) — paste into Excel.', 'success'); }
|
|
||||||
function download() {
|
|
||||||
try {
|
|
||||||
var blob = new Blob([text], { type: 'text/tab-separated-values' });
|
|
||||||
var url = URL.createObjectURL(blob);
|
|
||||||
var a = document.createElement('a'); a.href = url; a.download = 'classifier-files.tsv';
|
|
||||||
document.body.appendChild(a); a.click(); a.remove();
|
|
||||||
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
|
||||||
window.zddc.toast('Clipboard unavailable — downloaded classifier-files.tsv instead.', 'info');
|
|
||||||
} catch (e) { window.zddc.toast('Could not copy or download the list — ' + (e.message || e), 'error'); }
|
|
||||||
}
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
navigator.clipboard.writeText(text).then(ok, download);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var ta = document.createElement('textarea');
|
|
||||||
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
||||||
document.body.appendChild(ta); ta.focus(); ta.select();
|
|
||||||
var done = document.execCommand('copy'); ta.remove();
|
|
||||||
done ? ok() : download();
|
|
||||||
} catch (e) { download(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the folder tree
|
* Render the folder tree
|
||||||
*/
|
*/
|
||||||
|
|
@ -267,7 +175,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sortedFolders(window.app.folderTree).forEach(folder => {
|
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);
|
||||||
|
|
@ -300,13 +208,8 @@
|
||||||
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');
|
||||||
// While a filter (autofilter or a Show checkbox) is narrowing the tree,
|
const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
|
||||||
// the badge counts what's VISIBLE; otherwise the raw scanned totals.
|
const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
|
||||||
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('('));
|
||||||
|
|
@ -388,7 +291,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 ? '▼' : '▶';
|
toggle.textContent = (folder.expanded || autoOpen(folder)) ? '▼' : '▶';
|
||||||
toggle.addEventListener('click', (e) => {
|
toggle.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const recursive = e.ctrlKey || e.metaKey;
|
const recursive = e.ctrlKey || e.metaKey;
|
||||||
|
|
@ -419,24 +322,18 @@
|
||||||
if (agg === 'excluded') item.classList.add('excluded');
|
if (agg === 'excluded') item.classList.add('excluded');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name + counts stacked vertically: the count badge sits BELOW the name
|
// Folder 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;
|
||||||
namebox.appendChild(name);
|
item.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);
|
||||||
namebox.appendChild(count);
|
item.appendChild(count);
|
||||||
|
|
||||||
item.appendChild(namebox);
|
|
||||||
|
|
||||||
// Extract button for ZIP roots
|
// Extract button for ZIP roots
|
||||||
if (folder.isZipRoot) {
|
if (folder.isZipRoot) {
|
||||||
|
|
@ -458,15 +355,12 @@
|
||||||
|
|
||||||
div.appendChild(item);
|
div.appendChild(item);
|
||||||
|
|
||||||
// Children render ONLY when the user has expanded this folder. The
|
// Children — when expanded, or opened on the path to a search hit below.
|
||||||
// autofilter and Show toggles never change expand/collapse state — they
|
// The Show toggles never force-expand; search opens only connector folders.
|
||||||
// hide/show rows in place. A collapsed folder stays collapsed even if it
|
if ((folder.expanded || autoOpen(folder)) && folder.children && folder.children.length > 0) {
|
||||||
// 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';
|
||||||
sortedFolders(folder.children).forEach(child => {
|
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);
|
||||||
|
|
@ -474,12 +368,12 @@
|
||||||
div.appendChild(childrenDiv);
|
div.appendChild(childrenDiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify mode: list this folder's own files (draggable leaves) only
|
// Classify mode: list this folder's own files (draggable leaves) when
|
||||||
// when the user has expanded it (the filter never force-expands).
|
// expanded (or opened to reveal a search hit), so they can be dropped.
|
||||||
if (classifyOn() && folder.expanded && folder.files && folder.files.length > 0) {
|
if (classifyOn() && (folder.expanded || autoOpen(folder)) && 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';
|
||||||
sortedFiles(folder.files).forEach(function (file) {
|
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));
|
||||||
});
|
});
|
||||||
|
|
@ -498,12 +392,11 @@
|
||||||
item.className = 'file-item';
|
item.className = 'file-item';
|
||||||
item.style.paddingLeft = `${level * 1.5}rem`;
|
item.style.paddingLeft = `${level * 1.5}rem`;
|
||||||
item.draggable = true;
|
item.draggable = true;
|
||||||
item.title = 'Click to preview · ctrl/shift-click to multi-select · drag onto the grid (a block of rows) or a transmittal';
|
item.title = 'Click to preview · drag onto a tracking folder or transmittal to assign';
|
||||||
const key = c.srcKeyForFile(file);
|
const key = c.srcKeyForFile(file);
|
||||||
item.dataset.key = key;
|
item.dataset.key = key;
|
||||||
const st = c.fileState(file);
|
const st = c.fileState(file);
|
||||||
if (st === 'excluded') item.classList.add('excluded');
|
if (st === 'excluded') item.classList.add('excluded');
|
||||||
if (selectedFileKeys[key]) item.classList.add('selected');
|
|
||||||
|
|
||||||
item.appendChild(stateDot(st));
|
item.appendChild(stateDot(st));
|
||||||
|
|
||||||
|
|
@ -519,43 +412,11 @@
|
||||||
|
|
||||||
item.addEventListener('dragstart', function (e) {
|
item.addEventListener('dragstart', function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Drag the whole multi-selection (in visible top-to-bottom order) when
|
window.app.modules.dnd.setDrag([key], e);
|
||||||
// this file is part of it; otherwise just this one.
|
|
||||||
var keys = (selectedFileKeys[key] && fileSelectionCount() > 1)
|
|
||||||
? visibleFileKeys().filter(function (k) { return selectedFileKeys[k]; })
|
|
||||||
: [key];
|
|
||||||
window.app.modules.dnd.setDrag(keys, e);
|
|
||||||
});
|
});
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── source-file multi-selection (drives a multi-row drag onto the grid) ──
|
|
||||||
var selectedFileKeys = Object.create(null), fileAnchorKey = null;
|
|
||||||
function fileSelectionCount() { return Object.keys(selectedFileKeys).length; }
|
|
||||||
function visibleFileKeys() {
|
|
||||||
return Array.prototype.map.call(window.app.dom.folderTree.querySelectorAll('.file-item'), function (el) { return el.dataset.key; });
|
|
||||||
}
|
|
||||||
function applyFileSelectionClasses() {
|
|
||||||
Array.prototype.forEach.call(window.app.dom.folderTree.querySelectorAll('.file-item'), function (el) {
|
|
||||||
el.classList.toggle('selected', !!selectedFileKeys[el.dataset.key]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// click = replace + anchor; ctrl/cmd = toggle; shift = range from anchor;
|
|
||||||
// ctrl-shift = add range. Ranges run over the visible (DOM) file order.
|
|
||||||
function selectFileClick(key, e) {
|
|
||||||
var keys = visibleFileKeys(), a, b;
|
|
||||||
if (e.shiftKey && fileAnchorKey != null && (a = keys.indexOf(fileAnchorKey)) >= 0 && (b = keys.indexOf(key)) >= 0) {
|
|
||||||
if (!(e.ctrlKey || e.metaKey)) selectedFileKeys = Object.create(null);
|
|
||||||
for (var i = Math.min(a, b); i <= Math.max(a, b); i++) selectedFileKeys[keys[i]] = true;
|
|
||||||
} else if (e.ctrlKey || e.metaKey) {
|
|
||||||
if (selectedFileKeys[key]) delete selectedFileKeys[key]; else selectedFileKeys[key] = true;
|
|
||||||
fileAnchorKey = key;
|
|
||||||
} else {
|
|
||||||
selectedFileKeys = Object.create(null); selectedFileKeys[key] = true; fileAnchorKey = key;
|
|
||||||
}
|
|
||||||
applyFileSelectionClasses();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle folder click with multi-select support
|
* Handle folder click with multi-select support
|
||||||
*/
|
*/
|
||||||
|
|
@ -880,20 +741,16 @@
|
||||||
var ft = window.app.dom.folderTree;
|
var ft = window.app.dom.folderTree;
|
||||||
if (!ft) { classifyWired = false; return; }
|
if (!ft) { classifyWired = false; return; }
|
||||||
ft.addEventListener('contextmenu', onContextMenu);
|
ft.addEventListener('contextmenu', onContextMenu);
|
||||||
// Click a source file → update the multi-selection (ctrl/shift) AND, on a
|
// Single-click a source file → preview it (the "look at it, then assign"
|
||||||
// plain click, preview it (the "look at it, then assign" half). Drag of a
|
// half of the workflow). Drag still assigns; right-click excludes.
|
||||||
// selected file drags the whole selection; right-click excludes.
|
|
||||||
ft.addEventListener('click', function (e) {
|
ft.addEventListener('click', function (e) {
|
||||||
if (!classifyOn()) return;
|
if (!classifyOn()) return;
|
||||||
var fe = e.target.closest('.file-item');
|
var fe = e.target.closest('.file-item');
|
||||||
if (!fe || !fe.dataset.key) return;
|
if (!fe || !fe.dataset.key) return;
|
||||||
selectFileClick(fe.dataset.key, e);
|
|
||||||
if (!e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
||||||
var file = findFileByKey(fe.dataset.key);
|
var file = findFileByKey(fe.dataset.key);
|
||||||
if (file && window.app.modules.preview && window.app.modules.preview.previewFile) {
|
if (file && window.app.modules.preview && window.app.modules.preview.previewFile) {
|
||||||
window.app.modules.preview.previewFile(file);
|
window.app.modules.preview.previewFile(file);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1061,10 +918,6 @@
|
||||||
selectAll,
|
selectAll,
|
||||||
revealFile,
|
revealFile,
|
||||||
setShowFilters,
|
setShowFilters,
|
||||||
setNameFilter,
|
setNameFilter
|
||||||
exportFilteredList,
|
|
||||||
exportPathList,
|
|
||||||
filteredFiles: filteredFileObjects,
|
|
||||||
_buildExportTsv: buildExportTsv
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,11 @@
|
||||||
</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 and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
<div class="mode-switch" id="modeSwitch" role="group" aria-label="Workflow mode">
|
||||||
|
<button id="modeClassifyBtn" class="mode-btn active" title="Map files onto tracking numbers and transmittals, then copy renamed copies to an output directory — the source is never modified">Classify & copy</button>
|
||||||
|
<button id="modeRenameBtn" class="mode-btn" title="Edit a spreadsheet and rename the files in place (edits the source)">Rename in place</button>
|
||||||
|
</div>
|
||||||
|
<button id="mdlInstantiateBtn" class="btn btn-secondary btn-sm" title="Populate a project's Master Deliverables List from its existing archive files (server)">⊞ MDL from archive</button>
|
||||||
<button id="workspacesBtn" class="btn btn-secondary btn-sm" title="Workspaces — open or create a classification project">≡ Workspaces</button>
|
<button id="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>
|
||||||
|
|
@ -81,10 +86,6 @@
|
||||||
<input type="checkbox" id="showEmptyCheckbox" checked>
|
<input type="checkbox" id="showEmptyCheckbox" checked>
|
||||||
Empty
|
Empty
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-sm export-list-btn" id="exportListBtn"
|
|
||||||
title="Copy the filtered file list (path + file columns, no folders) as TSV — paste into Excel, edit, then paste back via “Paste rows”. Paste a full path into the Current name column to bind that exact file.">⬆ Export list</button>
|
|
||||||
<button class="btn btn-sm export-list-btn" id="exportPathsBtn"
|
|
||||||
title="Download the filtered file list as a 1-column CSV of full paths. Feed it to an AI to classify into <party>/<direction>/<transmittal>/<file>.ext, then bring the 2-column result back via “Import paths” above the target list.">⬇ Export paths</button>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="search" id="treeFilterInput" class="tree-filter" spellcheck="false"
|
<input type="search" id="treeFilterInput" class="tree-filter" spellcheck="false"
|
||||||
placeholder="Filter files… (e.g. master deliverables list)" aria-label="Filter files">
|
placeholder="Filter files… (e.g. master deliverables list)" aria-label="Filter files">
|
||||||
|
|
@ -94,50 +95,113 @@
|
||||||
<div class="resize-handle" id="treeResizeHandle"></div>
|
<div class="resize-handle" id="treeResizeHandle"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- The classify surface: one editable By-tracking grid + a
|
<!-- Spreadsheet Table (Rename in place) -->
|
||||||
transmittal tree. Tracking number alone ⇒ a Rename (in place);
|
<main class="spreadsheet-pane" id="spreadsheetPane" hidden>
|
||||||
add a transmittal folder ⇒ a Copy into the archive. -->
|
<div class="pane-header">
|
||||||
|
<div class="pane-header-left">
|
||||||
|
<h3>Files</h3>
|
||||||
|
<div class="file-stats">
|
||||||
|
<span id="totalFiles">0 files</span>
|
||||||
|
<span id="modifiedFiles">0 modified</span>
|
||||||
|
<span id="errorFiles" class="hidden">0 errors</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pane-header-right">
|
||||||
|
<button id="saveAllBtn" class="btn btn-success btn-sm" disabled>Save All</button>
|
||||||
|
<button id="cancelAllBtn" class="btn btn-secondary btn-sm" disabled>Cancel All</button>
|
||||||
|
<span class="header-divider">|</span>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="sha256Checkbox">
|
||||||
|
SHA256
|
||||||
|
</label>
|
||||||
|
<button id="exportHashesBtn" class="btn btn-secondary btn-sm" disabled title="Export SHA256 hashes in sha256sum format">💾 Export Hashes</button>
|
||||||
|
<span class="header-divider">|</span>
|
||||||
|
<button id="togglePreviewBtn" class="btn btn-secondary btn-sm" title="Toggle file preview panel">👁 Preview</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="spreadsheet-container">
|
||||||
|
<table id="spreadsheet" class="spreadsheet">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-row-num">#</th>
|
||||||
|
<th class="col-original">Original Filename
|
||||||
|
<input type="text" class="column-filter" data-filter-field="original" placeholder="filter…" spellcheck="false" aria-label="Filter by original filename">
|
||||||
|
</th>
|
||||||
|
<th class="col-extension">Ext
|
||||||
|
<input type="text" class="column-filter" data-filter-field="extension" placeholder="filter…" spellcheck="false" aria-label="Filter by extension">
|
||||||
|
</th>
|
||||||
|
<th class="col-new">New Filename
|
||||||
|
<input type="text" class="column-filter" data-filter-field="newFilename" placeholder="filter…" spellcheck="false" aria-label="Filter by new filename">
|
||||||
|
</th>
|
||||||
|
<th class="col-trackingNumber">Tracking
|
||||||
|
<input type="text" class="column-filter" data-filter-field="trackingNumber" placeholder="filter…" spellcheck="false" aria-label="Filter by tracking number">
|
||||||
|
</th>
|
||||||
|
<th class="col-revision">Rev
|
||||||
|
<input type="text" class="column-filter" data-filter-field="revision" placeholder="filter…" spellcheck="false" aria-label="Filter by revision">
|
||||||
|
</th>
|
||||||
|
<th class="col-status">Status
|
||||||
|
<input type="text" class="column-filter" data-filter-field="status" placeholder="filter…" spellcheck="false" aria-label="Filter by status">
|
||||||
|
</th>
|
||||||
|
<th class="col-title">Title
|
||||||
|
<input type="text" class="column-filter" data-filter-field="title" placeholder="filter…" spellcheck="false" aria-label="Filter by title">
|
||||||
|
</th>
|
||||||
|
<th class="col-sha256 hidden" id="sha256Column">SHA256
|
||||||
|
<input type="text" class="column-filter" data-filter-field="sha256" placeholder="filter…" spellcheck="false" aria-label="Filter by SHA256">
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="spreadsheetBody">
|
||||||
|
<!-- Dynamically populated -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Target Trees (Classify & Copy mode) — default view -->
|
||||||
<main class="target-pane" id="targetPane">
|
<main class="target-pane" id="targetPane">
|
||||||
<div class="pane-header pane-header--target">
|
<div class="pane-header">
|
||||||
<p class="target-goal">Give each file a <strong>tracking number</strong> (revision + status + title) under <em>By tracking number</em> — that alone is a <strong>Rename</strong> in place. Route it into a <strong>transmittal folder</strong> under <em>By transmittal</em> to <strong>Copy</strong> it into the archive.</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>
|
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
|
||||||
<button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
|
<button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
|
||||||
|
<button class="target-tab" id="mdlTab" role="tab">By MDL</button>
|
||||||
</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>
|
||||||
<span class="header-divider">|</span>
|
<span class="header-divider">|</span>
|
||||||
<button id="importPathsBtn" class="btn btn-secondary btn-sm" title="Import a 2-column CSV (old path, new path). Each new path “<party>/<direction>/<transmittal>/<file>.ext” sets that file’s tracking number (rename) and routes it into a transmittal. Only files named in the CSV are touched — others keep their current classification. Export the source list first via “Export paths” on the left.">Import paths…</button>
|
<button id="exportDatasetBtn" class="btn btn-secondary btn-sm" title="Download the classifications as a filename-per-file JSON to edit (e.g. with an AI), then re-import here. NOT a workspace — no scanned tree.">Export for editing</button>
|
||||||
<input type="file" id="importPathsInput" accept=".csv,text/csv,text/plain" hidden>
|
<button id="importDatasetBtn" class="btn btn-secondary btn-sm" title="Load an edited classification JSON back in — replaces the current classifications. (To move a whole scanned workspace between browsers, use “Import workspace” on the welcome screen.)">Import edits</button>
|
||||||
|
<input type="file" id="importDatasetInput" accept="application/json,.json" hidden>
|
||||||
<button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button>
|
<button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button>
|
||||||
|
<button id="checkDuplicatesBtn" class="btn btn-secondary btn-sm" title="Check for files with the same tracking number + revision but different content (flagged ≠ in red)">Check</button>
|
||||||
|
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to the server archive or a local folder (source untouched, resumable, verified)">Copy…</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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="loadWorklistBtn" class="btn btn-sm btn-secondary" title="Add tracking numbers from the project archive/MDL (pick directories to scan) as rows to fill.">⊞ Load…</button>
|
<button id="addTrackingRootBtn" class="btn btn-sm btn-secondary">+ Root folder</button>
|
||||||
<button id="pasteRowsBtn" class="btn btn-sm btn-secondary" title="Paste rows from Excel: Tracking · Rev (Status) · Title · Current name.">⎘ Paste rows…</button>
|
<span class="target-hint">Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”.</span>
|
||||||
<button id="matchNamesBtn" class="btn btn-sm btn-secondary" title="Auto-match unassigned files to list rows by name.">⚡ Match names</button>
|
|
||||||
<button id="addFilteredBtn" class="btn btn-sm btn-secondary" title="Add every file the left tree currently shows (filters applied) to the grid.">⊕ Add filtered files</button>
|
|
||||||
<button id="clearListBtn" class="btn btn-sm btn-secondary" title="Remove the list (placeholder) rows. Every assignment is kept.">Clear list</button>
|
|
||||||
<button id="trackingColsBtn" class="btn btn-sm btn-secondary" title="Show or hide columns">Columns ▾</button>
|
|
||||||
<span class="target-hint">Drag files in (ctrl-shift-click the left tree to multi-select; drop on a row to fill a contiguous block), or “Add filtered files”. Type the tracking number, revision (“A (IFR)”) and title — already-ZDDC-named files fill in. “Load…” / “Paste rows…” add rows to drop files onto.</span>
|
|
||||||
<button id="renameBtn" class="btn btn-sm btn-danger" disabled title="Rename the name-complete files IN PLACE on disk. Destructive — no backup.">Rename…</button>
|
|
||||||
</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 grid…" aria-label="Filter the tracking grid">
|
placeholder="Filter the tracking tree…" aria-label="Filter tracking tree">
|
||||||
<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>
|
||||||
<div class="target-panel__toolbar">
|
<div class="target-panel__toolbar">
|
||||||
<span class="target-hint">One row per file — type its transmittal folder: <party>/<received|issued>/<YYYY-MM-DD_TN (STATUS) - Title>. Drag files in: drop on a row to put the file in that same folder, ⌘/Ctrl-drop to branch a new transmittal from it.</span>
|
<button id="addPartyBtn" class="btn btn-sm btn-secondary">+ Party</button>
|
||||||
<button id="checkDuplicatesBtn" class="btn btn-secondary btn-sm" title="Check for files with the same tracking number + revision but different content (flagged ≠ in red)">Check</button>
|
<span class="target-hint"><party>/{received,issued}/<transmittal>. Drag files (or a whole folder) into a transmittal.</span>
|
||||||
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy fully-classified files (+ their transmittal folders) into the archive — server or a local folder. Source untouched, resumable, verified.">Copy…</button>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="search" id="transmittalFilterInput" class="tree-filter target-filter" spellcheck="false"
|
<input type="search" id="transmittalFilterInput" class="tree-filter target-filter" spellcheck="false"
|
||||||
placeholder="Filter the transmittal grid…" aria-label="Filter transmittal grid">
|
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>
|
||||||
|
<div class="target-panel__toolbar">
|
||||||
|
<button id="loadMdlBtn" class="btn btn-sm btn-secondary">⊞ Load MDL…</button>
|
||||||
|
<span class="target-hint">Deliverables become drop targets — set a revision, then drag files on. Ctrl-shift select rows + ctrl-Enter to set a revision on many at once.</span>
|
||||||
|
</div>
|
||||||
|
<div id="mdlTree" class="target-tree"></div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -167,16 +231,26 @@
|
||||||
<div id="workspaceList" class="ws-list"><!-- rendered --></div>
|
<div id="workspaceList" class="ws-list"><!-- rendered --></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- One flow, two endings: rename in place or copy to the archive -->
|
<!-- Two-method tutorial -->
|
||||||
<div class="welcome__methods">
|
<div class="welcome__methods">
|
||||||
<section class="method method--primary">
|
<section class="method method--primary">
|
||||||
<h3 class="method__title">Classify, then Rename or Copy</h3>
|
<h3 class="method__title">① Classify & copy <span class="method__tag">recommended · non-destructive</span></h3>
|
||||||
<p class="method__what">Give each file a ZDDC name in the <strong>By tracking number</strong> grid. A tracking number alone <em>is</em> a rename; add a <strong>transmittal folder</strong> and it can be copied into the archive.</p>
|
<p class="method__what">Build a tidy copy of a project in a separate output folder. Your source files are only ever <em>read</em>, never renamed or moved.</p>
|
||||||
<ol class="method__steps">
|
<ol class="method__steps">
|
||||||
<li><strong>New workspace</strong> → pick a folder. It scans <em>once</em> and saves to this browser, so you can close the tab and pick up later.</li>
|
<li><strong>New workspace</strong> → pick a folder. It scans <em>once</em> and saves to this browser, so you can close the tab and pick up later.</li>
|
||||||
<li><strong>Add files to the grid</strong> — drag them from the left tree (ctrl-shift-click to multi-select and fill a block of rows), use <strong>⊕ Add filtered files</strong>, or <strong>⊞ Load…</strong> / <strong>⎘ Paste rows…</strong> a list of expected tracking numbers and drop the matching files on. Already-ZDDC-named files fill in automatically.</li>
|
<li><strong>Preview</strong> a file (single-click it in the left tree) to see what it actually is.</li>
|
||||||
<li>Type each file's <strong>tracking number</strong>, <strong>revision</strong> (e.g. <code>A (IFR)</code>) and <strong>title</strong>.</li>
|
<li><strong>Drag</strong> it onto the right pane — onto a <em>tracking-number</em> folder (the folder path becomes the number, the leaf is the revision, e.g. <code>A (IFR)</code>), and onto a <em>transmittal</em> (party + date + TRN/SUB + sequence).</li>
|
||||||
<li><strong>Rename…</strong> (By tracking number) renames the named files <em>in place</em> on disk — <span class="method__tag method__tag--warn">destructive, no backup</span>. Or place them into a transmittal under <strong>By transmittal</strong> and <strong>Copy…</strong> them into the archive (source untouched, resumable, verified).</li>
|
<li><strong>Copy</strong> when ready → choose an output directory; renamed copies are written as <code><party>/<transmittal>/<name></code>, with duplicates detected.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
<section class="method">
|
||||||
|
<h3 class="method__title">② Rename in place <span class="method__tag method__tag--warn">edits your files</span></h3>
|
||||||
|
<p class="method__what">A quick spreadsheet for a folder you own: fill in tracking number, revision, status and title, and rename the files on disk.</p>
|
||||||
|
<ol class="method__steps">
|
||||||
|
<li>Click <strong>Use Local Directory</strong> (top bar) to open a folder.</li>
|
||||||
|
<li>Switch the toggle to <strong>Rename in place</strong>.</li>
|
||||||
|
<li>Edit cells (or paste columns from Excel); names already in ZDDC format are parsed automatically and validated as you type.</li>
|
||||||
|
<li><strong>Save All</strong> renames the files where they sit.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -472,21 +472,6 @@ 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,15 +195,6 @@
|
||||||
</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,14 +95,6 @@ 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,48 +98,6 @@
|
||||||
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
|
||||||
|
|
@ -153,9 +111,8 @@
|
||||||
async function handleForbidden(resp, opts) {
|
async function handleForbidden(resp, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var missing = '';
|
var missing = '';
|
||||||
var body = null;
|
|
||||||
try {
|
try {
|
||||||
body = await resp.clone().json();
|
var 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;
|
||||||
}
|
}
|
||||||
|
|
@ -170,16 +127,6 @@
|
||||||
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
|
||||||
|
|
@ -212,5 +159,5 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
/* ── Shared selectable + autofilter table (seltable) + its hosting overlay ───
|
|
||||||
Used by the tables tool's "Add from archive". The classifier carries an
|
|
||||||
equivalent copy inline in its layout.css for the catalog. */
|
|
||||||
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
|
||||||
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
|
||||||
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
|
|
||||||
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
|
|
||||||
/* width:auto + nowrap cells → each column shrinks to fit its header/longest cell. */
|
|
||||||
.seltable__table { border-collapse: separate; border-spacing: 0; width: auto; font-size: 0.82rem; }
|
|
||||||
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
|
|
||||||
.seltable__table thead th {
|
|
||||||
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
|
|
||||||
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.seltable__table thead tr.seltable__filters th { top: 1.55rem; padding: 0.15rem 0.35rem; }
|
|
||||||
/* Sortable headers + multi-sort indicator. The title th is position:sticky
|
|
||||||
(a positioning context) so the drag-resizer can absolutely anchor to its edge. */
|
|
||||||
.seltable__th--sortable { cursor: pointer; }
|
|
||||||
.seltable__th--sortable:hover { color: var(--text); }
|
|
||||||
.seltable__sortind { color: var(--primary); font-size: 0.7em; font-weight: 700; }
|
|
||||||
.seltable__resizer {
|
|
||||||
position: absolute; top: 0; right: 0; bottom: 0; width: 6px;
|
|
||||||
cursor: col-resize; user-select: none; touch-action: none;
|
|
||||||
}
|
|
||||||
.seltable__resizer:hover { background: var(--primary); opacity: 0.4; }
|
|
||||||
.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,268 +0,0 @@
|
||||||
/**
|
|
||||||
* ZDDC — shared selectable + autofilter table (used by the classifier worklist +
|
|
||||||
* By-tracking grid, and the tables tool's "Add from archive").
|
|
||||||
*
|
|
||||||
* A flat table with PER-COLUMN autofilters (one input per column, AND-combined),
|
|
||||||
* MULTI-COLUMN SORT (click a header to sort; shift/ctrl-click adds a secondary
|
|
||||||
* key), RESIZABLE columns (drag the header edge), an optional programmatic global
|
|
||||||
* filter, and powerful selection:
|
|
||||||
* 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 + SORTED order. Selection is keyed by a
|
|
||||||
* stable rowId so it survives filtering, sorting, and re-render. Pass
|
|
||||||
* opts.persistKey to remember column widths + sort across reloads (localStorage).
|
|
||||||
*
|
|
||||||
* Column config: { key, title, cls?, get?(row), render?(row, td), filterable?,
|
|
||||||
* sortable?, sortValue?(row) }. sortable/filterable default true; set false for
|
|
||||||
* render-only columns (a remove button, a derived badge).
|
|
||||||
*/
|
|
||||||
(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 loadPersist(key) { try { return JSON.parse(localStorage.getItem(key)) || {}; } catch (_) { return {}; } }
|
|
||||||
function savePersist(key, patch) {
|
|
||||||
var cur = loadPersist(key);
|
|
||||||
for (var k in patch) cur[k] = patch[k];
|
|
||||||
try { localStorage.setItem(key, JSON.stringify(cur)); } catch (_) { /* private mode */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
var persistKey = opts.persistKey || null;
|
|
||||||
var saved = persistKey ? loadPersist(persistKey) : {};
|
|
||||||
var sortState = Array.isArray(saved.sort) ? saved.sort.slice() : []; // [{ key, dir }]
|
|
||||||
var colWidths = saved.widths || {}; // colKey -> px
|
|
||||||
var headEls = Object.create(null); // colKey -> { th, ind }
|
|
||||||
|
|
||||||
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); }
|
|
||||||
|
|
||||||
// ── multi-column sort ──────────────────────────────────────────────
|
|
||||||
function sortVal(col, row) { return col.sortValue ? col.sortValue(row) : colVal(col, row); }
|
|
||||||
function sorted(list) {
|
|
||||||
if (!sortState.length) return list;
|
|
||||||
var arr = list.slice();
|
|
||||||
arr.sort(function (a, b) {
|
|
||||||
for (var i = 0; i < sortState.length; i++) {
|
|
||||||
var s = sortState[i], col = colByKey(s.key); if (!col) continue;
|
|
||||||
var cmp = String(sortVal(col, a)).localeCompare(String(sortVal(col, b)), undefined, { numeric: true, sensitivity: 'base' });
|
|
||||||
if (cmp) return s.dir === 'desc' ? -cmp : cmp;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
function visibleRows() { return sorted(filtered()); }
|
|
||||||
function sortIdx(key) { for (var i = 0; i < sortState.length; i++) { if (sortState[i].key === key) return i; } return -1; }
|
|
||||||
function toggleSort(key, add) {
|
|
||||||
var i = sortIdx(key);
|
|
||||||
if (add) {
|
|
||||||
if (i >= 0) { if (sortState[i].dir === 'asc') sortState[i].dir = 'desc'; else sortState.splice(i, 1); }
|
|
||||||
else sortState.push({ key: key, dir: 'asc' });
|
|
||||||
} else {
|
|
||||||
if (i === 0 && sortState.length === 1) sortState = sortState[0].dir === 'asc' ? [{ key: key, dir: 'desc' }] : [];
|
|
||||||
else sortState = [{ key: key, dir: 'asc' }];
|
|
||||||
}
|
|
||||||
if (persistKey) savePersist(persistKey, { sort: sortState });
|
|
||||||
updateSortIndicators(); renderBody();
|
|
||||||
}
|
|
||||||
function updateSortIndicators() {
|
|
||||||
columns.forEach(function (c) {
|
|
||||||
var h = headEls[c.key]; if (!h) return;
|
|
||||||
var i = sortIdx(c.key);
|
|
||||||
h.ind.textContent = i >= 0 ? ((sortState[i].dir === 'asc' ? ' ▲' : ' ▼') + (sortState.length > 1 ? (i + 1) : '')) : '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// ── resizable columns ──────────────────────────────────────────────
|
|
||||||
function addResizer(th, key) {
|
|
||||||
var rz = elt('div', 'seltable__resizer');
|
|
||||||
rz.addEventListener('mousedown', function (e) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
var startX = e.clientX, startW = th.offsetWidth;
|
|
||||||
function mm(ev) { var w = Math.max(40, startW + (ev.clientX - startX)); th.style.width = th.style.minWidth = th.style.maxWidth = w + 'px'; }
|
|
||||||
function mu() {
|
|
||||||
document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu);
|
|
||||||
colWidths[key] = Math.round(th.offsetWidth);
|
|
||||||
if (persistKey) savePersist(persistKey, { widths: colWidths });
|
|
||||||
}
|
|
||||||
document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu);
|
|
||||||
});
|
|
||||||
th.appendChild(rz);
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
headEls = Object.create(null);
|
|
||||||
columns.forEach(function (c) {
|
|
||||||
var th = elt('th', c.cls || null);
|
|
||||||
th.appendChild(document.createTextNode(c.title || c.key));
|
|
||||||
var ind = elt('span', 'seltable__sortind'); th.appendChild(ind);
|
|
||||||
headEls[c.key] = { th: th, ind: ind };
|
|
||||||
if (colWidths[c.key]) { th.style.width = th.style.minWidth = th.style.maxWidth = colWidths[c.key] + 'px'; }
|
|
||||||
if (c.sortable !== false) {
|
|
||||||
th.classList.add('seltable__th--sortable');
|
|
||||||
th.addEventListener('click', function (e) {
|
|
||||||
if (e.target.classList.contains('seltable__resizer')) return;
|
|
||||||
toggleSort(c.key, e.shiftKey || e.ctrlKey || e.metaKey);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
addResizer(th, c.key);
|
|
||||||
htr.appendChild(th);
|
|
||||||
});
|
|
||||||
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', '');
|
|
||||||
if (colFilters[c.key]) inp.value = colFilters[c.key].join(' ');
|
|
||||||
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(); }
|
|
||||||
});
|
|
||||||
updateSortIndicators();
|
|
||||||
renderBody();
|
|
||||||
}
|
|
||||||
function renderBody() {
|
|
||||||
if (!bodyEl) return;
|
|
||||||
var fr = visibleRows();
|
|
||||||
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,
|
|
||||||
sortBy: toggleSort, getSortState: function () { return sortState.slice(); },
|
|
||||||
clickRow: function (id, mods) {
|
|
||||||
var fr = visibleRows();
|
|
||||||
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 };
|
|
||||||
})();
|
|
||||||
|
|
@ -25,7 +25,6 @@ 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"
|
||||||
|
|
@ -47,7 +46,6 @@ 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" \
|
||||||
|
|
@ -63,7 +61,6 @@ 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,9 +152,7 @@
|
||||||
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');
|
||||||
// Tell them who can (subtly): role-first text + people in the tooltip.
|
addRowBtn.title = "You don't have create access in this folder.";
|
||||||
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) {
|
||||||
|
|
@ -169,11 +167,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Add from archive" — shown only on the project MDL rollup (own gating).
|
|
||||||
if (app.modules.mdlFromArchive && app.modules.mdlFromArchive.setup) {
|
|
||||||
app.modules.mdlFromArchive.setup(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||||
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
// mdl-from-archive.js — "Add from archive" for the project MDL rollup.
|
|
||||||
//
|
|
||||||
// The MDL owns the workflow of registering deliverables; this is the catch-up
|
|
||||||
// path. On the project rollup (<project>/mdl/), walk the project archive into a
|
|
||||||
// shared seltable (autofilter + ctrl-shift selection), dedupe the selection to
|
|
||||||
// one deliverable per tracking number, and PUT a deliverable .yaml into each
|
|
||||||
// originator's archive/<originator>/mdl/. The body's identity fields are split
|
|
||||||
// from the tracking number positionally per the project's own table columns
|
|
||||||
// (originator is folder-pinned, so omitted); the server composes/validates the
|
|
||||||
// filename. Server-only.
|
|
||||||
(function (app) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
function T(m, l, o) { if (window.zddc && window.zddc.toast) window.zddc.toast(m, l, o); }
|
|
||||||
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
|
||||||
function ctxObj() { return (app && app.context) || {}; }
|
|
||||||
|
|
||||||
// The tracking-number identity fields, in order, from the table columns:
|
|
||||||
// everything between `originator` and `title` (e.g. phase, project, area,
|
|
||||||
// discipline, type, sequence, suffix). originator is folder-pinned.
|
|
||||||
function identityFields() {
|
|
||||||
var cols = (ctxObj().columns || []).map(function (c) { return c && c.field; }).filter(Boolean);
|
|
||||||
var oi = cols.indexOf('originator'), ti = cols.indexOf('title');
|
|
||||||
return cols.slice(oi >= 0 ? oi + 1 : 0, ti >= 0 ? ti : cols.length);
|
|
||||||
}
|
|
||||||
// tracking → { tracking, originator, body{identity fields + title} }, or null
|
|
||||||
// if it can't supply the originator + at least one identity segment.
|
|
||||||
function deliverableFromFile(f, idFields) {
|
|
||||||
var segs = String(f.tracking || '').split('-');
|
|
||||||
if (segs.length < 2) return null;
|
|
||||||
var rest = segs.slice(1), body = {};
|
|
||||||
idFields.forEach(function (name, i) { if (rest[i] != null && rest[i] !== '') body[name] = rest[i]; });
|
|
||||||
if (!Object.keys(body).length) return null;
|
|
||||||
body.title = f.title || '';
|
|
||||||
return { tracking: f.tracking, originator: segs[0], body: body };
|
|
||||||
}
|
|
||||||
function dedupe(files, idFields) {
|
|
||||||
var seen = Object.create(null), out = [];
|
|
||||||
(files || []).forEach(function (f) {
|
|
||||||
if (seen[f.tracking]) return;
|
|
||||||
var d = deliverableFromFile(f, idFields);
|
|
||||||
if (d) { seen[f.tracking] = true; out.push(d); }
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function walkArchive(rootHandle) {
|
|
||||||
var out = [];
|
|
||||||
async function walk(dirH, parts) {
|
|
||||||
for await (var entry of dirH.values()) {
|
|
||||||
var nm = String(entry.name || '').replace(/\/$/, '');
|
|
||||||
if (entry.kind === 'directory') {
|
|
||||||
var c = nm.charAt(0);
|
|
||||||
if (c === '.' || c === '_' || nm === 'mdl' || nm === 'rsk') continue;
|
|
||||||
await walk(await dirH.getDirectoryHandle(nm), parts.concat(nm));
|
|
||||||
} else {
|
|
||||||
var p = window.zddc.parseFilename(nm);
|
|
||||||
if (p && p.valid && p.trackingNumber) {
|
|
||||||
out.push({
|
|
||||||
id: parts.concat(nm).join('/'),
|
|
||||||
party: parts[0] || '', slot: parts[1] || '', transmittal: parts[2] || '',
|
|
||||||
tracking: p.trackingNumber, revision: p.revision, status: p.status, title: p.title,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await walk(rootHandle, []);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
async function instantiateOne(archiveRoot, d) {
|
|
||||||
var dir = await archiveRoot.getDirectoryHandle(d.originator, { create: true });
|
|
||||||
dir = await dir.getDirectoryHandle('mdl', { create: true });
|
|
||||||
var fname = d.tracking + '.yaml';
|
|
||||||
try { await dir.getFileHandle(fname); return 'skipped'; } catch (e) { /* NotFound → create */ }
|
|
||||||
var fh = await dir.getFileHandle(fname, { create: true });
|
|
||||||
var w = await fh.createWritable();
|
|
||||||
await w.write(new Blob([window.jsyaml.dump(d.body)], { type: 'application/yaml' }));
|
|
||||||
await w.close();
|
|
||||||
return 'created';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UI ───────────────────────────────────────────────────────────────────
|
|
||||||
var overlay = null, statusEl = null, table = null, files = [], archiveRoot = null;
|
|
||||||
function close() { if (overlay) { overlay.remove(); overlay = null; table = null; } }
|
|
||||||
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
|
|
||||||
|
|
||||||
function archiveBaseUrl() {
|
|
||||||
var proj = (location.pathname || '/').replace(/\/mdl\/.*$/, '/'); // <project>/
|
|
||||||
return location.origin + proj + 'archive/';
|
|
||||||
}
|
|
||||||
async function open() {
|
|
||||||
var src = window.zddc && window.zddc.source;
|
|
||||||
if (!src || (location.protocol !== 'http:' && location.protocol !== 'https:')) {
|
|
||||||
T('Adding from the archive needs the tables page served by a zddc-server.', 'error'); return;
|
|
||||||
}
|
|
||||||
buildOverlay();
|
|
||||||
try {
|
|
||||||
archiveRoot = new src.HttpDirectoryHandle(archiveBaseUrl(), 'archive');
|
|
||||||
setStatus('Scanning archive…');
|
|
||||||
files = await walkArchive(archiveRoot);
|
|
||||||
table.renderBody();
|
|
||||||
setStatus(files.length + ' document file' + (files.length === 1 ? '' : 's') + ' found. Filter + ctrl-shift select, then “Create deliverables”.');
|
|
||||||
} catch (e) { setStatus('Archive scan failed — ' + (e.message || e)); T('Archive scan failed — ' + (e.message || e), 'error'); }
|
|
||||||
}
|
|
||||||
function buildOverlay() {
|
|
||||||
close();
|
|
||||||
overlay = el('div', 'mdlarch-overlay');
|
|
||||||
var box = el('div', 'mdlarch-overlay__box');
|
|
||||||
var head = el('div', 'mdlarch-overlay__head');
|
|
||||||
head.appendChild(el('h2', null, 'Add deliverables from archive'));
|
|
||||||
var x = el('button', 'mdlarch-overlay__close', '×'); x.title = 'Close'; x.addEventListener('click', close);
|
|
||||||
head.appendChild(x); box.appendChild(head);
|
|
||||||
statusEl = el('div', 'mdlarch-overlay__status', 'Scanning archive…'); box.appendChild(statusEl);
|
|
||||||
var host = el('div', 'mdlarch-overlay__table'); box.appendChild(host);
|
|
||||||
var foot = el('div', 'mdlarch-overlay__foot');
|
|
||||||
var create = el('button', 'btn btn-primary', 'Create deliverables');
|
|
||||||
create.addEventListener('click', function () { runCreate(create); });
|
|
||||||
var cancel = el('button', 'btn btn-secondary', 'Close'); cancel.addEventListener('click', close);
|
|
||||||
foot.appendChild(create); foot.appendChild(cancel); box.appendChild(foot);
|
|
||||||
overlay.appendChild(box); document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
table = window.app.modules.seltable.create({
|
|
||||||
container: host,
|
|
||||||
extraTitle: '',
|
|
||||||
rows: function () { return files; },
|
|
||||||
rowId: function (r) { return r.id; },
|
|
||||||
columns: [
|
|
||||||
{ key: 'party', title: 'Party' },
|
|
||||||
{ key: 'slot', title: 'Slot' },
|
|
||||||
{ key: 'transmittal', title: 'Transmittal' },
|
|
||||||
{ key: 'tracking', title: 'Tracking number' },
|
|
||||||
{ key: 'revision', title: 'Rev', get: function (r) { return r.revision + (r.status ? ' (' + r.status + ')' : ''); } },
|
|
||||||
{ key: 'title', title: 'Title' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
table.render();
|
|
||||||
}
|
|
||||||
async function runCreate(btn) {
|
|
||||||
if (!table) return;
|
|
||||||
var sel = table.getSelection();
|
|
||||||
if (!sel.length) { T('Select some archive files first (filter + ctrl-shift).', 'warning'); return; }
|
|
||||||
var picked = {}; sel.forEach(function (i) { picked[i] = true; });
|
|
||||||
var deliverables = dedupe(files.filter(function (f) { return picked[f.id]; }), identityFields());
|
|
||||||
if (!deliverables.length) { T('None of the selected files split into deliverable fields.', 'warning'); return; }
|
|
||||||
if (!confirm('Create ' + deliverables.length + ' deliverable(s) in the project MDL?\n\nOne .yaml per tracking number, in archive/<originator>/mdl/. Already-present ones are skipped.')) return;
|
|
||||||
btn.disabled = true;
|
|
||||||
var s = { created: 0, skipped: 0, errors: 0 };
|
|
||||||
for (var i = 0; i < deliverables.length; i++) {
|
|
||||||
setStatus('Creating ' + (i + 1) + '/' + deliverables.length + ' — ' + deliverables[i].tracking);
|
|
||||||
try { s[await instantiateOne(archiveRoot, deliverables[i])]++; }
|
|
||||||
catch (e) { s.errors++; T('Failed to create ' + deliverables[i].tracking + ' — ' + (e.message || e), 'error'); }
|
|
||||||
}
|
|
||||||
btn.disabled = false;
|
|
||||||
setStatus(s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '.');
|
|
||||||
T('MDL: ' + s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '. Reload to see them.', s.errors ? 'warning' : 'success');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the toolbar button only on the project MDL rollup (addable:false +
|
|
||||||
// an mdl path), over http, gated on create permission. Called from main.js
|
|
||||||
// init once the context is known.
|
|
||||||
function setup(ctx) {
|
|
||||||
var btn = document.getElementById('table-add-from-archive');
|
|
||||||
if (!btn) return;
|
|
||||||
var onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
|
||||||
var isMdlRollup = ctx && ctx.addable === false && /\/mdl\/(table\.html)?$/.test(location.pathname || '');
|
|
||||||
if (!(onHttp && isMdlRollup)) return;
|
|
||||||
btn.hidden = false;
|
|
||||||
btn.addEventListener('click', open);
|
|
||||||
if (window.zddc && window.zddc.cap) {
|
|
||||||
window.zddc.cap.at(archiveBaseUrl().replace(location.origin, '')).then(function (view) {
|
|
||||||
var verbs = (view && view.path_verbs) || '';
|
|
||||||
if (verbs.indexOf('c') === -1) { btn.classList.add('is-disabled'); btn.title = "You don't have create access in this project's archive."; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.modules.mdlFromArchive = {
|
|
||||||
setup: setup, open: open,
|
|
||||||
// test seams
|
|
||||||
identityFields: identityFields, deliverableFromFile: deliverableFromFile,
|
|
||||||
dedupe: dedupe, walkArchive: walkArchive, instantiateOne: instantiateOne,
|
|
||||||
};
|
|
||||||
})(window.tablesApp);
|
|
||||||
|
|
@ -43,7 +43,6 @@
|
||||||
<div class="table-toolbar__right">
|
<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,10 +271,11 @@ test.describe('Browse menu — context & tiers', () => {
|
||||||
expect(res.rwd).toContain('Delete…');
|
expect(res.rwd).toContain('Delete…');
|
||||||
});
|
});
|
||||||
|
|
||||||
// New folder / New file are not toolbar buttons — they live in the
|
test('toolbar Sort and Show-hidden drive state; New buttons present', async ({ page }) => {
|
||||||
// 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 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// shared/cap.js — the "who can?" helpers (denyHint / whoCan) + handleForbidden
|
|
||||||
// enrichment. cap.js is bundled into every server-mode tool; tables.html is a
|
|
||||||
// convenient host. Pure helpers run fine on a file:// page (cap.at short-circuits
|
|
||||||
// offline, but denyHint/whoCan/handleForbidden don't need the network).
|
|
||||||
|
|
||||||
const HOST = 'file://' + path.resolve('tables/dist/tables.html');
|
|
||||||
|
|
||||||
async function load(page) {
|
|
||||||
await page.goto(HOST, { waitUntil: 'load' });
|
|
||||||
await page.waitForFunction(() => window.zddc && window.zddc.cap && window.zddc.cap.denyHint);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('cap.js — who-can hints', () => {
|
|
||||||
test('denyHint is role-first with people as the tooltip detail', async ({ page }) => {
|
|
||||||
await load(page);
|
|
||||||
const h = await page.evaluate(() => {
|
|
||||||
const view = { path_who_can: { c: { roles: ['document_controller'], people: ['alice@example.com', 'bob@example.com'] } } };
|
|
||||||
return window.zddc.cap.denyHint(view, 'c');
|
|
||||||
});
|
|
||||||
expect(h.text).toBe('Only the document controller can create here.'); // role-first, humanized
|
|
||||||
expect(h.title).toBe('alice@example.com, bob@example.com'); // people in the tooltip
|
|
||||||
});
|
|
||||||
|
|
||||||
test('denyHint names people when no role grants the verb', async ({ page }) => {
|
|
||||||
await load(page);
|
|
||||||
const h = await page.evaluate(() =>
|
|
||||||
window.zddc.cap.denyHint({ path_who_can: { w: { people: ['sam@example.com'] } } }, 'w'));
|
|
||||||
expect(h.text).toBe('Ask sam@example.com to write here.');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('denyHint falls back to "an administrator" when nobody is named', async ({ page }) => {
|
|
||||||
await load(page);
|
|
||||||
const h = await page.evaluate(() => window.zddc.cap.denyHint({ path_who_can: {} }, 'd'));
|
|
||||||
expect(h.text).toBe('Ask an administrator to delete here.');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('whoCan reads either a path view or a 403 body', async ({ page }) => {
|
|
||||||
await load(page);
|
|
||||||
const r = await page.evaluate(() => {
|
|
||||||
const fromView = window.zddc.cap.whoCan({ path_who_can: { c: { roles: ['r1'] } } }, 'c');
|
|
||||||
const fromBody = window.zddc.cap.whoCan({ missing_verb: 'c', who_can: { roles: ['r2'] } }, 'c');
|
|
||||||
const miss = window.zddc.cap.whoCan({ path_who_can: { w: {} } }, 'c');
|
|
||||||
return { fromView: fromView && fromView.roles[0], fromBody: fromBody && fromBody.roles[0], miss };
|
|
||||||
});
|
|
||||||
expect(r.fromView).toBe('r1');
|
|
||||||
expect(r.fromBody).toBe('r2');
|
|
||||||
expect(r.miss).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleForbidden appends the who-can hint from the 403 body', async ({ page }) => {
|
|
||||||
await load(page);
|
|
||||||
const msg = await page.evaluate(async () => {
|
|
||||||
let captured = '';
|
|
||||||
window.zddc.toast = (m) => { captured = m; return document.createElement('div'); };
|
|
||||||
const body = JSON.stringify({ error: 'Forbidden', missing_verb: 'c', who_can: { roles: ['document_controller'], people: ['alice@example.com'] } });
|
|
||||||
const resp = new Response(body, { status: 403, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
await window.zddc.cap.handleForbidden(resp, { context: 'Create' });
|
|
||||||
return captured;
|
|
||||||
});
|
|
||||||
expect(msg).toContain('You do not have create access here.');
|
|
||||||
expect(msg).toContain('Only the document controller can create here.'); // who-can appended
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,194 +0,0 @@
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// "Add from archive" for the tables tool's project MDL rollup. The page is
|
|
||||||
// loaded offline (file://) with an injected #table-context whose columns drive
|
|
||||||
// how a tracking number splits into deliverable fields. The walk / dedupe /
|
|
||||||
// instantiate logic is exercised against in-page mock FS-Access handles — no
|
|
||||||
// server needed.
|
|
||||||
|
|
||||||
const HTML_PATH = path.resolve('tables/dist/tables.html');
|
|
||||||
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
|
|
||||||
|
|
||||||
// originator … identity fields … title (originator is folder-pinned → omitted
|
|
||||||
// from the body; everything between originator and title is the tracking split).
|
|
||||||
const MDL_COLUMNS = [
|
|
||||||
{ field: 'originator', title: 'Orig' },
|
|
||||||
{ field: 'phase', title: 'Phase' },
|
|
||||||
{ field: 'project', title: 'Project' },
|
|
||||||
{ field: 'area', title: 'Area' },
|
|
||||||
{ field: 'discipline', title: 'Disc' },
|
|
||||||
{ field: 'type', title: 'Type' },
|
|
||||||
{ field: 'sequence', title: 'Seq' },
|
|
||||||
{ field: 'suffix', title: 'Suffix' },
|
|
||||||
{ field: 'title', title: 'Deliverable' },
|
|
||||||
];
|
|
||||||
|
|
||||||
async function loadRollup(page) {
|
|
||||||
const ctx = { title: 'MDL', columns: MDL_COLUMNS, rows: [], addable: false };
|
|
||||||
const ctxJson = JSON.stringify(ctx).replace(/<\//g, '<\\/');
|
|
||||||
const patched = HTML_RAW.replace(
|
|
||||||
/<script id="table-context" type="application\/json">[\s\S]*?<\/script>/,
|
|
||||||
`<script id="table-context" type="application/json">${ctxJson}</script>`,
|
|
||||||
);
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tables-mdl-'));
|
|
||||||
const tmpPath = path.join(tmpDir, 'tables.html');
|
|
||||||
fs.writeFileSync(tmpPath, patched);
|
|
||||||
await page.goto(`file://${tmpPath}`, { waitUntil: 'load' });
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => window.tablesApp && window.tablesApp.modules && window.tablesApp.modules.mdlFromArchive,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('tables/ — Add deliverables from archive', () => {
|
|
||||||
test('identityFields() = columns between originator and title', async ({ page }) => {
|
|
||||||
await loadRollup(page);
|
|
||||||
const fields = await page.evaluate(() => window.tablesApp.modules.mdlFromArchive.identityFields());
|
|
||||||
expect(fields).toEqual(['phase', 'project', 'area', 'discipline', 'type', 'sequence', 'suffix']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deliverableFromFile splits the tracking number, omits originator, keeps title', async ({ page }) => {
|
|
||||||
await loadRollup(page);
|
|
||||||
const d = await page.evaluate(() => {
|
|
||||||
const m = window.tablesApp.modules.mdlFromArchive;
|
|
||||||
return m.deliverableFromFile(
|
|
||||||
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001-X', title: 'Foundation Plan' },
|
|
||||||
m.identityFields(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
expect(d.originator).toBe('ACME');
|
|
||||||
expect(d.tracking).toBe('ACME-DD-PRJ-A1-CIV-DWG-001-X');
|
|
||||||
expect(d.body).toEqual({
|
|
||||||
phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV',
|
|
||||||
type: 'DWG', sequence: '001', suffix: 'X', title: 'Foundation Plan',
|
|
||||||
});
|
|
||||||
// originator must NOT be in the body (server pins it from the folder).
|
|
||||||
expect(d.body.originator).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a shorter tracking number leaves trailing identity fields unset', async ({ page }) => {
|
|
||||||
await loadRollup(page);
|
|
||||||
const d = await page.evaluate(() => {
|
|
||||||
const m = window.tablesApp.modules.mdlFromArchive;
|
|
||||||
// no suffix segment
|
|
||||||
return m.deliverableFromFile({ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: '' }, m.identityFields());
|
|
||||||
});
|
|
||||||
expect(d.body.sequence).toBe('001');
|
|
||||||
expect('suffix' in d.body).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dedupe collapses duplicate tracking numbers, dropping unsplittable rows', async ({ page }) => {
|
|
||||||
await loadRollup(page);
|
|
||||||
const out = await page.evaluate(() => {
|
|
||||||
const m = window.tablesApp.modules.mdlFromArchive;
|
|
||||||
return m.dedupe([
|
|
||||||
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: 'a' },
|
|
||||||
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: 'a-dup' },
|
|
||||||
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-002', title: 'b' },
|
|
||||||
{ tracking: 'NOPE', title: 'too short' },
|
|
||||||
], m.identityFields());
|
|
||||||
});
|
|
||||||
expect(out.map(d => d.tracking)).toEqual([
|
|
||||||
'ACME-DD-PRJ-A1-CIV-DWG-001', 'ACME-DD-PRJ-A1-CIV-DWG-002',
|
|
||||||
]);
|
|
||||||
expect(out[0].body.title).toBe('a'); // first wins
|
|
||||||
});
|
|
||||||
|
|
||||||
test('walkArchive collects valid document files, skipping mdl/rsk/dot/underscore dirs', async ({ page }) => {
|
|
||||||
await loadRollup(page);
|
|
||||||
const files = await page.evaluate(async () => {
|
|
||||||
// Mock FS-Access directory handles.
|
|
||||||
function dir(name, entries) {
|
|
||||||
return {
|
|
||||||
name, kind: 'directory', _entries: entries,
|
|
||||||
async *values() { for (const e of entries) yield e; },
|
|
||||||
async getDirectoryHandle(n) {
|
|
||||||
const e = entries.find(x => x.name === n && x.kind === 'directory');
|
|
||||||
if (!e) throw new DOMException('not found', 'NotFoundError');
|
|
||||||
return e;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const file = name => ({ name, kind: 'file' });
|
|
||||||
const root = dir('archive', [
|
|
||||||
dir('Acme', [
|
|
||||||
dir('issued', [
|
|
||||||
dir('2026-05-01_ACME-DD-PRJ-A1-CIV-DWG-001 (IFR) - Plan', [
|
|
||||||
file('ACME-DD-PRJ-A1-CIV-DWG-001_B (IFR) - Foundation Plan.pdf'),
|
|
||||||
file('not-a-zddc-file.txt'),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
dir('mdl', [ file('ACME-DD-PRJ-A1-CIV-DWG-001.yaml') ]), // skipped
|
|
||||||
dir('rsk', [ file('whatever_A (IFA) - x.pdf') ]), // skipped
|
|
||||||
]),
|
|
||||||
dir('_system', [ file('ACME-DD-PRJ-A1-CIV-DWG-999_A (IFA) - hidden.pdf') ]), // skipped
|
|
||||||
]);
|
|
||||||
const out = await window.tablesApp.modules.mdlFromArchive.walkArchive(root);
|
|
||||||
return out.map(f => ({ tracking: f.tracking, party: f.party, slot: f.slot, rev: f.revision, title: f.title }));
|
|
||||||
});
|
|
||||||
expect(files).toEqual([
|
|
||||||
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', party: 'Acme', slot: 'issued', rev: 'B', title: 'Foundation Plan' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('instantiateOne writes a yaml on create, skips when it already exists', async ({ page }) => {
|
|
||||||
await loadRollup(page);
|
|
||||||
const result = await page.evaluate(async () => {
|
|
||||||
const writes = [];
|
|
||||||
function fileHandle(name, exists) {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
async createWritable() {
|
|
||||||
return {
|
|
||||||
async write(blob) { writes.push({ name, text: await blob.text() }); },
|
|
||||||
async close() {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
_exists: exists,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function mdlDir() {
|
|
||||||
const present = {}; // tracking.yaml already there
|
|
||||||
present['ACME-DD-PRJ-A1-CIV-DWG-002.yaml'] = true;
|
|
||||||
return {
|
|
||||||
async getFileHandle(n, opts) {
|
|
||||||
if (opts && opts.create) return fileHandle(n, false);
|
|
||||||
if (present[n]) return fileHandle(n, true);
|
|
||||||
throw new DOMException('nf', 'NotFoundError');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function originatorDir() {
|
|
||||||
return { async getDirectoryHandle() { return mdlDir(); } };
|
|
||||||
}
|
|
||||||
const archiveRoot = { async getDirectoryHandle() { return originatorDir(); } };
|
|
||||||
const m = window.tablesApp.modules.mdlFromArchive;
|
|
||||||
const created = await m.instantiateOne(archiveRoot, {
|
|
||||||
tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', originator: 'ACME',
|
|
||||||
body: { phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV', type: 'DWG', sequence: '001', title: 'Plan' },
|
|
||||||
});
|
|
||||||
const skipped = await m.instantiateOne(archiveRoot, {
|
|
||||||
tracking: 'ACME-DD-PRJ-A1-CIV-DWG-002', originator: 'ACME',
|
|
||||||
body: { phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV', type: 'DWG', sequence: '002', title: 'Plan2' },
|
|
||||||
});
|
|
||||||
return { created, skipped, writes };
|
|
||||||
});
|
|
||||||
expect(result.created).toBe('created');
|
|
||||||
expect(result.skipped).toBe('skipped');
|
|
||||||
expect(result.writes.length).toBe(1);
|
|
||||||
expect(result.writes[0].name).toBe('ACME-DD-PRJ-A1-CIV-DWG-001.yaml');
|
|
||||||
expect(result.writes[0].text).toContain('title: Plan');
|
|
||||||
expect(result.writes[0].text).toContain('discipline: CIV');
|
|
||||||
// originator must not be serialized into the body
|
|
||||||
expect(result.writes[0].text).not.toContain('originator:');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the "From archive" button stays hidden when not on an /mdl/ rollup path', async ({ page }) => {
|
|
||||||
await loadRollup(page);
|
|
||||||
// file:// path is not /mdl/, so setup() must not reveal the button.
|
|
||||||
const hidden = await page.evaluate(() => document.getElementById('table-add-from-archive').hidden);
|
|
||||||
expect(hidden).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -22,13 +22,12 @@ test.describe('shared/toast.js', () => {
|
||||||
expect(exposed).toBe(true);
|
expect(exposed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders a toast with the level class and ARIA role', async ({ page }) => {
|
test('renders a single toast with the level class and ARIA role', async ({ page }) => {
|
||||||
const after = await page.evaluate(() => {
|
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 && {
|
||||||
// The message lives in its own span (the toast also holds a × button).
|
text: el.textContent,
|
||||||
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'),
|
||||||
|
|
@ -51,24 +50,18 @@ test.describe('shared/toast.js', () => {
|
||||||
expect(probe).toEqual({ role: 'alert', live: 'assertive' });
|
expect(probe).toEqual({ role: 'alert', live: 'assertive' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('toasts stack, and a "Clear all" control appears at 2+', async ({ page }) => {
|
test('a second toast replaces the first (single-toast policy)', async ({ page }) => {
|
||||||
const r = await page.evaluate(() => {
|
const count = await page.evaluate(() => {
|
||||||
window.zddc.toast('one', 'error'); // sticky so it stays for the count
|
window.zddc.toast('one', 'info');
|
||||||
window.zddc.toast('two', 'error');
|
window.zddc.toast('two', 'info');
|
||||||
return {
|
return document.querySelectorAll('.zddc-toast').length;
|
||||||
count: document.querySelectorAll('.zddc-toast').length,
|
|
||||||
clearAll: !!document.querySelector('.zddc-toasts__clear'),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
expect(r.count).toBe(2); // stack, not replace
|
expect(count).toBe(1);
|
||||||
expect(r.clearAll).toBe(true); // "Clear all" surfaces when 2+ are stacked
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the × button dismisses a toast; clicking the body does not', async ({ page }) => {
|
test('clicking dismisses immediately', async ({ page }) => {
|
||||||
await page.evaluate(() => window.zddc.toast('keep me', 'error')); // sticky
|
await page.evaluate(() => window.zddc.toast('click me', 'info'));
|
||||||
await page.locator('.zddc-toast .zddc-toast__msg').click(); // selecting text ≠ dismiss
|
await page.locator('.zddc-toast').click();
|
||||||
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,23 +162,19 @@ 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`);
|
||||||
// Create via the apiActions modal (the inline #desc form is long gone).
|
await page.fill('#desc', xssDesc);
|
||||||
await page.locator('#api-create-btn').click();
|
await page.click('button[type="submit"]');
|
||||||
await expect(page.locator('.api-modal')).toBeVisible();
|
// Wait for the row to appear in the table.
|
||||||
await page.locator('.api-modal input').first().fill(xssDesc);
|
await expect(page.locator('#tokens tbody')).toContainText('<img');
|
||||||
await page.locator('.api-modal button[type="submit"]').click();
|
// The literal <img> tag should NOT have been parsed as HTML —
|
||||||
await expect(page.locator('.api-modal__secret')).toBeVisible();
|
// window.__xss must remain undefined.
|
||||||
await page.locator('.api-modal button:has-text("Done")').click();
|
const xssFired = await page.evaluate(() => window.__xss === 1);
|
||||||
await page.waitForLoadState('networkidle');
|
expect(xssFired).toBe(false);
|
||||||
// The description renders as a row — as TEXT, not parsed HTML.
|
// And the on-disk text content of the cell should contain the
|
||||||
const row = page.locator('#table-root tbody tr', { hasText: 'img src' });
|
// literal angle brackets, proving they were escaped.
|
||||||
await expect(row).toBeVisible();
|
const rowText = await page.locator('#tokens tbody tr', { hasText: 'img src' }).textContent();
|
||||||
// The <img> must NOT have been parsed (its onerror never fires)…
|
expect(rowText).toContain('<img');
|
||||||
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-18 15:12:59 · 9ca24eb</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<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,48 +11523,6 @@ 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
|
||||||
|
|
@ -11578,9 +11536,8 @@ 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 {
|
||||||
body = await resp.clone().json();
|
var 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;
|
||||||
}
|
}
|
||||||
|
|
@ -11595,16 +11552,6 @@ 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
|
||||||
|
|
@ -11637,7 +11584,7 @@ window.app.modules.filtering = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
</script>
|
</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-18 15:12:59 · 9ca24eb</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<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,48 +7022,6 @@ 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
|
||||||
|
|
@ -7077,9 +7035,8 @@ 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 {
|
||||||
body = await resp.clone().json();
|
var 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;
|
||||||
}
|
}
|
||||||
|
|
@ -7094,16 +7051,6 @@ 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
|
||||||
|
|
@ -7136,7 +7083,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
|
// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
|
||||||
|
|
@ -14444,12 +14391,8 @@ 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 && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
|
||||||
|
|
@ -14766,10 +14709,8 @@ 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 && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay) { 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');
|
||||||
|
|
@ -14819,10 +14760,8 @@ 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 && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay) { 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();
|
||||||
|
|
@ -15418,12 +15357,8 @@ 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 && pressedBackdrop) { close(); reject(new Error('cancelled')); }
|
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
||||||
});
|
});
|
||||||
document.addEventListener('keydown', onKeydown);
|
document.addEventListener('keydown', onKeydown);
|
||||||
submit.addEventListener('click', function () {
|
submit.addEventListener('click', function () {
|
||||||
|
|
@ -16274,13 +16209,6 @@ 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');
|
||||||
|
|
@ -16299,10 +16227,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.</em>') +
|
(partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
|
||||||
'<label title="' + escapeHtml(newPartyTitle) + '" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;' + (newPartyAllowed ? 'cursor:pointer;' : 'opacity:0.6;') + '">' +
|
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
|
||||||
'<input type="radio" name="pp-party" value="__new__"' + (newPartyAllowed ? '' : ' disabled') + '>' +
|
'<input type="radio" name="pp-party" value="__new__">' +
|
||||||
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">' + escapeHtml(newPartyNote) + '</span></span></label>' +
|
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</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>' +
|
||||||
|
|
@ -16330,11 +16258,7 @@ 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);
|
||||||
// Close on a genuine backdrop click only — NOT when a drag that began
|
overlay.addEventListener('click', function (e) { if (e.target === overlay) cancel(); });
|
||||||
// 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; }
|
||||||
|
|
@ -16363,28 +16287,8 @@ 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 newPartyHint = (!canNewParty && ssrView && cap.denyHint) ? cap.denyHint(ssrView, 'c') : null;
|
var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties });
|
||||||
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.
|
||||||
|
|
@ -16407,10 +16311,7 @@ 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)) {
|
||||||
// Name who can — best-effort, for the path the denial came from.
|
statusError('Not allowed — registering a new party requires the document-controller role.');
|
||||||
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,21 +1609,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -1793,7 +1778,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-18 15:12:59 · 9ca24eb</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -1966,15 +1951,6 @@ 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">
|
||||||
|
|
@ -3396,48 +3372,6 @@ 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
|
||||||
|
|
@ -3451,9 +3385,8 @@ body {
|
||||||
async function handleForbidden(resp, opts) {
|
async function handleForbidden(resp, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var missing = '';
|
var missing = '';
|
||||||
var body = null;
|
|
||||||
try {
|
try {
|
||||||
body = await resp.clone().json();
|
var 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;
|
||||||
}
|
}
|
||||||
|
|
@ -3468,16 +3401,6 @@ 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
|
||||||
|
|
@ -3510,7 +3433,7 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(function() {
|
(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-18 15:12:58 · 9ca24eb</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<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,48 +14071,6 @@ 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
|
||||||
|
|
@ -14126,9 +14084,8 @@ 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 {
|
||||||
body = await resp.clone().json();
|
var 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;
|
||||||
}
|
}
|
||||||
|
|
@ -14143,16 +14100,6 @@ 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
|
||||||
|
|
@ -14185,7 +14132,7 @@ X.B(E,Y);return E}return J}())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(function (app) {
|
(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-18 15:12:59 · 9ca24eb
|
archive=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
transmittal=v0.0.27-beta · 2026-06-18 15:12:58 · 9ca24eb
|
transmittal=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
classifier=v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
|
classifier=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
landing=v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
|
landing=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
form=v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
|
form=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
tables=v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
|
tables=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7
|
||||||
browse=v0.0.27-beta · 2026-06-18 15:12:59 · 9ca24eb
|
browse=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ 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
|
||||||
|
|
@ -35,25 +34,6 @@ 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 {
|
||||||
writeForbiddenWho(w, action, chain) // name who CAN, so the toast can explain
|
writeForbidden(w, action)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -181,15 +181,6 @@ 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
|
||||||
|
|
@ -265,27 +256,6 @@ 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,64 +1245,6 @@ 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; }
|
|
||||||
/* Sortable headers + multi-sort indicator. The title th is position:sticky
|
|
||||||
(a positioning context) so the drag-resizer can absolutely anchor to its edge. */
|
|
||||||
.seltable__th--sortable { cursor: pointer; }
|
|
||||||
.seltable__th--sortable:hover { color: var(--text); }
|
|
||||||
.seltable__sortind { color: var(--primary); font-size: 0.7em; font-weight: 700; }
|
|
||||||
.seltable__resizer {
|
|
||||||
position: absolute; top: 0; right: 0; bottom: 0; width: 6px;
|
|
||||||
cursor: col-resize; user-select: none; touch-action: none;
|
|
||||||
}
|
|
||||||
.seltable__resizer:hover { background: var(--primary); opacity: 0.4; }
|
|
||||||
.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 {
|
||||||
|
|
@ -1780,7 +1722,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-18 15:12:59 · 9ca24eb</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -1801,7 +1743,6 @@ 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>
|
||||||
|
|
@ -3602,48 +3543,6 @@ 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
|
||||||
|
|
@ -3657,9 +3556,8 @@ 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 {
|
||||||
body = await resp.clone().json();
|
var 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;
|
||||||
}
|
}
|
||||||
|
|
@ -3674,16 +3572,6 @@ 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
|
||||||
|
|
@ -3716,7 +3604,7 @@ body.is-elevated::after {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden, whoCan: whoCan, denyHint: denyHint };
|
window.zddc.cap = { at: at, has: has, handleForbidden: handleForbidden };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// shared/context-menu.js — generic context-menu framework exposed on
|
// shared/context-menu.js — generic context-menu framework exposed on
|
||||||
|
|
@ -4108,275 +3996,6 @@ 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 worklist +
|
|
||||||
* By-tracking grid, and the tables tool's "Add from archive").
|
|
||||||
*
|
|
||||||
* A flat table with PER-COLUMN autofilters (one input per column, AND-combined),
|
|
||||||
* MULTI-COLUMN SORT (click a header to sort; shift/ctrl-click adds a secondary
|
|
||||||
* key), RESIZABLE columns (drag the header edge), an optional programmatic global
|
|
||||||
* filter, and powerful selection:
|
|
||||||
* 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 + SORTED order. Selection is keyed by a
|
|
||||||
* stable rowId so it survives filtering, sorting, and re-render. Pass
|
|
||||||
* opts.persistKey to remember column widths + sort across reloads (localStorage).
|
|
||||||
*
|
|
||||||
* Column config: { key, title, cls?, get?(row), render?(row, td), filterable?,
|
|
||||||
* sortable?, sortValue?(row) }. sortable/filterable default true; set false for
|
|
||||||
* render-only columns (a remove button, a derived badge).
|
|
||||||
*/
|
|
||||||
(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 loadPersist(key) { try { return JSON.parse(localStorage.getItem(key)) || {}; } catch (_) { return {}; } }
|
|
||||||
function savePersist(key, patch) {
|
|
||||||
var cur = loadPersist(key);
|
|
||||||
for (var k in patch) cur[k] = patch[k];
|
|
||||||
try { localStorage.setItem(key, JSON.stringify(cur)); } catch (_) { /* private mode */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
var persistKey = opts.persistKey || null;
|
|
||||||
var saved = persistKey ? loadPersist(persistKey) : {};
|
|
||||||
var sortState = Array.isArray(saved.sort) ? saved.sort.slice() : []; // [{ key, dir }]
|
|
||||||
var colWidths = saved.widths || {}; // colKey -> px
|
|
||||||
var headEls = Object.create(null); // colKey -> { th, ind }
|
|
||||||
|
|
||||||
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); }
|
|
||||||
|
|
||||||
// ── multi-column sort ──────────────────────────────────────────────
|
|
||||||
function sortVal(col, row) { return col.sortValue ? col.sortValue(row) : colVal(col, row); }
|
|
||||||
function sorted(list) {
|
|
||||||
if (!sortState.length) return list;
|
|
||||||
var arr = list.slice();
|
|
||||||
arr.sort(function (a, b) {
|
|
||||||
for (var i = 0; i < sortState.length; i++) {
|
|
||||||
var s = sortState[i], col = colByKey(s.key); if (!col) continue;
|
|
||||||
var cmp = String(sortVal(col, a)).localeCompare(String(sortVal(col, b)), undefined, { numeric: true, sensitivity: 'base' });
|
|
||||||
if (cmp) return s.dir === 'desc' ? -cmp : cmp;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
function visibleRows() { return sorted(filtered()); }
|
|
||||||
function sortIdx(key) { for (var i = 0; i < sortState.length; i++) { if (sortState[i].key === key) return i; } return -1; }
|
|
||||||
function toggleSort(key, add) {
|
|
||||||
var i = sortIdx(key);
|
|
||||||
if (add) {
|
|
||||||
if (i >= 0) { if (sortState[i].dir === 'asc') sortState[i].dir = 'desc'; else sortState.splice(i, 1); }
|
|
||||||
else sortState.push({ key: key, dir: 'asc' });
|
|
||||||
} else {
|
|
||||||
if (i === 0 && sortState.length === 1) sortState = sortState[0].dir === 'asc' ? [{ key: key, dir: 'desc' }] : [];
|
|
||||||
else sortState = [{ key: key, dir: 'asc' }];
|
|
||||||
}
|
|
||||||
if (persistKey) savePersist(persistKey, { sort: sortState });
|
|
||||||
updateSortIndicators(); renderBody();
|
|
||||||
}
|
|
||||||
function updateSortIndicators() {
|
|
||||||
columns.forEach(function (c) {
|
|
||||||
var h = headEls[c.key]; if (!h) return;
|
|
||||||
var i = sortIdx(c.key);
|
|
||||||
h.ind.textContent = i >= 0 ? ((sortState[i].dir === 'asc' ? ' ▲' : ' ▼') + (sortState.length > 1 ? (i + 1) : '')) : '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// ── resizable columns ──────────────────────────────────────────────
|
|
||||||
function addResizer(th, key) {
|
|
||||||
var rz = elt('div', 'seltable__resizer');
|
|
||||||
rz.addEventListener('mousedown', function (e) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
var startX = e.clientX, startW = th.offsetWidth;
|
|
||||||
function mm(ev) { var w = Math.max(40, startW + (ev.clientX - startX)); th.style.width = th.style.minWidth = th.style.maxWidth = w + 'px'; }
|
|
||||||
function mu() {
|
|
||||||
document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu);
|
|
||||||
colWidths[key] = Math.round(th.offsetWidth);
|
|
||||||
if (persistKey) savePersist(persistKey, { widths: colWidths });
|
|
||||||
}
|
|
||||||
document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu);
|
|
||||||
});
|
|
||||||
th.appendChild(rz);
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
|
||||||
headEls = Object.create(null);
|
|
||||||
columns.forEach(function (c) {
|
|
||||||
var th = elt('th', c.cls || null);
|
|
||||||
th.appendChild(document.createTextNode(c.title || c.key));
|
|
||||||
var ind = elt('span', 'seltable__sortind'); th.appendChild(ind);
|
|
||||||
headEls[c.key] = { th: th, ind: ind };
|
|
||||||
if (colWidths[c.key]) { th.style.width = th.style.minWidth = th.style.maxWidth = colWidths[c.key] + 'px'; }
|
|
||||||
if (c.sortable !== false) {
|
|
||||||
th.classList.add('seltable__th--sortable');
|
|
||||||
th.addEventListener('click', function (e) {
|
|
||||||
if (e.target.classList.contains('seltable__resizer')) return;
|
|
||||||
toggleSort(c.key, e.shiftKey || e.ctrlKey || e.metaKey);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
addResizer(th, c.key);
|
|
||||||
htr.appendChild(th);
|
|
||||||
});
|
|
||||||
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', '');
|
|
||||||
if (colFilters[c.key]) inp.value = colFilters[c.key].join(' ');
|
|
||||||
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(); }
|
|
||||||
});
|
|
||||||
updateSortIndicators();
|
|
||||||
renderBody();
|
|
||||||
}
|
|
||||||
function renderBody() {
|
|
||||||
if (!bodyEl) return;
|
|
||||||
var fr = visibleRows();
|
|
||||||
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,
|
|
||||||
sortBy: toggleSort, getSortState: function () { return sortState.slice(); },
|
|
||||||
clickRow: function (id, mods) {
|
|
||||||
var fr = visibleRows();
|
|
||||||
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.
|
||||||
|
|
@ -7952,191 +7571,6 @@ 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';
|
||||||
|
|
||||||
|
|
@ -8291,9 +7725,7 @@ 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');
|
||||||
// Tell them who can (subtly): role-first text + people in the tooltip.
|
addRowBtn.title = "You don't have create access in this folder.";
|
||||||
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) {
|
||||||
|
|
@ -8308,11 +7740,6 @@ body.is-elevated::after {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Add from archive" — shown only on the project MDL rollup (own gating).
|
|
||||||
if (app.modules.mdlFromArchive && app.modules.mdlFromArchive.setup) {
|
|
||||||
app.modules.mdlFromArchive.setup(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||||
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
package zddc
|
|
||||||
|
|
||||||
import "sort"
|
|
||||||
|
|
||||||
// Authority answers "who can do this here?" for a user who was denied an action.
|
|
||||||
// It is split so the UI can lead with the ROLE ("ask a document controller") and
|
|
||||||
// keep the specific People (admins + role members + direct email grants) as
|
|
||||||
// secondary detail. Both lists are sorted + de-duplicated.
|
|
||||||
//
|
|
||||||
// Safe to surface to anyone who can read the path: the .zddc cascade it is
|
|
||||||
// derived from is already governed by directory read-ACL, so this exposes
|
|
||||||
// nothing a reader couldn't already see in the .zddc itself.
|
|
||||||
type Authority struct {
|
|
||||||
Roles []string `json:"roles,omitempty"`
|
|
||||||
People []string `json:"people,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty reports whether no authority was found (no one is named — the caller
|
|
||||||
// should fall back to "ask an administrator").
|
|
||||||
func (a Authority) Empty() bool { return len(a.Roles) == 0 && len(a.People) == 0 }
|
|
||||||
|
|
||||||
// WhoCan returns the principals that hold `verb` at the chain: every
|
|
||||||
// acl.permissions grantee across the cascade whose verb set includes it, plus
|
|
||||||
// every level's admins (who bypass the ACL entirely). Role grantees are recorded
|
|
||||||
// as Roles AND expanded to their member patterns in People; direct email/glob
|
|
||||||
// grantees go to People. Pure and side-effect-free.
|
|
||||||
func WhoCan(chain PolicyChain, verb VerbSet) Authority {
|
|
||||||
roleSet := map[string]struct{}{}
|
|
||||||
peopleSet := map[string]struct{}{}
|
|
||||||
|
|
||||||
add := func(principal string, levelIdx int) {
|
|
||||||
if principal == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if IsPrincipalRole(principal) {
|
|
||||||
// A bare name is a role only if the cascade actually defines it;
|
|
||||||
// otherwise it's a wildcard pattern (e.g. "*") handled as a person.
|
|
||||||
if _, defined := lookupRoleMembers(chain, levelIdx, principal); defined {
|
|
||||||
roleSet[principal] = struct{}{}
|
|
||||||
for _, m := range RoleMembers(chain, levelIdx, principal) {
|
|
||||||
if m != "" {
|
|
||||||
peopleSet[m] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
peopleSet[principal] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, level := range chain.Levels {
|
|
||||||
for principal, verbStr := range level.ACL.Permissions {
|
|
||||||
if v, _ := ParseVerbSet(verbStr); v.Has(verb) {
|
|
||||||
add(principal, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Admins bypass the ACL clamp, so they can always do this verb.
|
|
||||||
for _, principal := range level.Admins {
|
|
||||||
add(principal, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Authority{Roles: sortedSet(roleSet), People: sortedSet(peopleSet)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedSet(m map[string]struct{}) []string {
|
|
||||||
if len(m) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(m))
|
|
||||||
for k := range m {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
package zddc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWhoCan(t *testing.T) {
|
|
||||||
chain := PolicyChain{
|
|
||||||
Levels: []ZddcFile{
|
|
||||||
{
|
|
||||||
ACL: ACLRules{Permissions: map[string]string{
|
|
||||||
"document_controller": "rwcda",
|
|
||||||
"carol@example.com": "rwc",
|
|
||||||
"*@example.com": "r", // read only — must NOT show up for 'c'
|
|
||||||
}},
|
|
||||||
Roles: map[string]Role{
|
|
||||||
"document_controller": {Members: []string{"alice@example.com", "bob@example.com"}},
|
|
||||||
},
|
|
||||||
Admins: []string{"super@example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
HasAnyFile: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// create: the role (rwcda) + a direct grant (carol rwc) + the admin (bypass).
|
|
||||||
// The read-only *@example.com pattern is excluded.
|
|
||||||
got := WhoCan(chain, VerbC)
|
|
||||||
if want := []string{"document_controller"}; !reflect.DeepEqual(got.Roles, want) {
|
|
||||||
t.Errorf("VerbC Roles = %v, want %v", got.Roles, want)
|
|
||||||
}
|
|
||||||
if want := []string{"alice@example.com", "bob@example.com", "carol@example.com", "super@example.com"}; !reflect.DeepEqual(got.People, want) {
|
|
||||||
t.Errorf("VerbC People = %v, want %v", got.People, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// admin verb: only the role (rwcda) + admin bypass; carol (rwc) lacks 'a'.
|
|
||||||
got = WhoCan(chain, VerbA)
|
|
||||||
if wcContains(got.People, "carol@example.com") {
|
|
||||||
t.Errorf("VerbA People = %v, should not include carol (no 'a')", got.People)
|
|
||||||
}
|
|
||||||
if !wcContains(got.People, "super@example.com") {
|
|
||||||
t.Errorf("VerbA People = %v, want the admin", got.People)
|
|
||||||
}
|
|
||||||
|
|
||||||
// read: granted to the *@example.com pattern (a People entry, not a role).
|
|
||||||
if got = WhoCan(chain, VerbR); !wcContains(got.People, "*@example.com") {
|
|
||||||
t.Errorf("VerbR People = %v, want to include *@example.com", got.People)
|
|
||||||
}
|
|
||||||
|
|
||||||
// empty chain → nobody named.
|
|
||||||
if a := WhoCan(PolicyChain{}, VerbC); !a.Empty() {
|
|
||||||
t.Errorf("empty chain: got %+v, want Empty()", a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func wcContains(ss []string, s string) bool {
|
|
||||||
for _, x := range ss {
|
|
||||||
if x == s {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue