Compare commits
5 commits
2bc582fd9e
...
6c3c58bc70
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c3c58bc70 | |||
| 95c9e42270 | |||
| d4d48cad4a | |||
| 93f1eb8d63 | |||
| fd11278417 |
18 changed files with 646 additions and 375 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -227,6 +227,20 @@ Format: `trackingNumber_revision (status) - title.extension`
|
||||||
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
|
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
|
||||||
- Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
|
- Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
|
||||||
|
|
||||||
|
### Pre-push PII guard (run before EVERY push)
|
||||||
|
|
||||||
|
`main` was rewritten once to scrub a leaked work email (history reset to a single clean commit; all old tags deleted; versioning rebootstrapped at `v0.0.26`). A leaked address persists in **history + tags**, not just files — so it must never re-enter. Before any push:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Flags any email NOT a known synthetic placeholder or the maintainer contact.
|
||||||
|
git grep -InE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' \
|
||||||
|
| grep -viE '@example\.(com|org|io|net)|caseywitt@proton\.me|@(my|your)company\.com|@(partner|acme|beta|vendor|evil|x|company|host|admin|anywhere|other)\.(com|org)|@regulator\.gov|@(zddc\.)?varasys\.io|@bitnest\.cc|@proton\.me|@nhn\.com|@ex\.io'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Empty output = clean.** Any line is a STOP: confirm it's a synthetic placeholder; if it's a real personal/work address, replace it with an `@example.com` placeholder before pushing (and extend the allowlist above only for genuinely-synthetic example domains).
|
||||||
|
- **Conventions** (the scrub genericized everything): no real personal/work emails — use `@example.com`. The only real address allowed anywhere is the maintainer contact **`caseywitt@proton.me`** (`SECURITY.md` + as the git commit author). Generic personas only — `admin` / `alice` / `sam`; party name **Acme**.
|
||||||
|
- **Never** push a branch still carrying pre-scrub history, and **never** push stale local tags (the old 165 are gone; `zddc-server-vX.Y.Z` triggers the release+deploy pipeline).
|
||||||
|
|
||||||
### Releasing — lockstep stable + beta snapshot
|
### Releasing — lockstep stable + beta snapshot
|
||||||
|
|
||||||
**Lockstep convention.** Every stable cut bumps all 8 artifacts (7 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all 8 tools) + 1` — `_coordinated_next_stable` in `shared/build-lib.sh`.
|
**Lockstep convention.** Every stable cut bumps all 8 artifacts (7 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all 8 tools) + 1` — `_coordinated_next_stable` in `shared/build-lib.sh`.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
- **Commit freely** — make commits as appropriate for the work being performed. Each commit should be a coherent, reviewable unit (no WIP/checkpoint noise). The default rule "never commit without explicit ask" does NOT apply in this repo.
|
- **Commit freely** — make commits as appropriate for the work being performed. Each commit should be a coherent, reviewable unit (no WIP/checkpoint noise). The default rule "never commit without explicit ask" does NOT apply in this repo.
|
||||||
- **Push only when explicitly told** — `git push` requires a fresh request from the user every time. Approval to commit does not carry forward to push, and approval to push once does not carry forward to a later push.
|
- **Push only when explicitly told** — `git push` requires a fresh request from the user every time. Approval to commit does not carry forward to push, and approval to push once does not carry forward to a later push.
|
||||||
- **No squashing on push** — keep granular history. Each commit should already be meaningful (per the rule above), so squashing erases useful detail rather than removing noise. Multi-commit branches with a clean history are preferred over force-pushed squash-merges.
|
- **No squashing on push** — keep granular history. Each commit should already be meaningful (per the rule above), so squashing erases useful detail rather than removing noise. Multi-commit branches with a clean history are preferred over force-pushed squash-merges.
|
||||||
|
- **Pre-push PII guard — run before EVERY push.** `main` history was rewritten once to scrub a leaked work email; a leak persists in history + tags, not just files. Before any push, run the guard in AGENTS.md ("Pre-push PII guard"). No real personal/work emails: use `@example.com` in examples; the only real address allowed is the maintainer contact `caseywitt@proton.me` (SECURITY.md + as commit author). Generic personas only (admin / alice / sam); party name **Acme**. Never push stale local tags, and never push a branch still carrying pre-scrub history.
|
||||||
|
|
||||||
## Authoritative docs — read these first
|
## Authoritative docs — read these first
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,12 @@ concat_files \
|
||||||
"js/classify.js" \
|
"js/classify.js" \
|
||||||
"js/workspace.js" \
|
"js/workspace.js" \
|
||||||
"js/dnd.js" \
|
"js/dnd.js" \
|
||||||
"js/seltable.js" \
|
"../shared/seltable.js" \
|
||||||
"js/validator.js" \
|
"js/validator.js" \
|
||||||
"js/scanner.js" \
|
"js/scanner.js" \
|
||||||
"js/tree.js" \
|
"js/tree.js" \
|
||||||
"js/target-tree.js" \
|
"js/target-tree.js" \
|
||||||
"js/copy.js" \
|
"js/copy.js" \
|
||||||
"js/mdl-instantiate.js" \
|
|
||||||
"js/spreadsheet.js" \
|
"js/spreadsheet.js" \
|
||||||
"js/selection.js" \
|
"js/selection.js" \
|
||||||
"js/preview.js" \
|
"js/preview.js" \
|
||||||
|
|
|
||||||
|
|
@ -571,8 +571,24 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── By-MDL panel (seltable rows = deliverable drop targets) ─────────────── */
|
/* ── Catalog overlay (MDL ∪ archive; seltable rows = drop targets) ───────── */
|
||||||
#mdlPanel .seltable { height: 100%; }
|
.target-pane { position: relative; }
|
||||||
|
.target-tabs__catalog { margin-left: 0.75rem; }
|
||||||
|
.catalog-overlay {
|
||||||
|
position: absolute; inset: 0; z-index: 20;
|
||||||
|
display: flex; flex-direction: column; min-height: 0;
|
||||||
|
background: var(--bg); border-left: 2px solid var(--primary);
|
||||||
|
}
|
||||||
|
.catalog-overlay[hidden] { display: none; }
|
||||||
|
.catalog-overlay__head {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
|
||||||
|
padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); background: var(--bg-secondary, var(--bg));
|
||||||
|
}
|
||||||
|
.catalog-overlay__title { font-weight: 700; font-size: 0.9rem; }
|
||||||
|
.catalog-overlay__close { margin-left: auto; background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; }
|
||||||
|
.catalog-overlay__close:hover { color: var(--text); }
|
||||||
|
.catalog-overlay__table { flex: 1; min-height: 0; }
|
||||||
|
.catalog-overlay__table .seltable { height: 100%; }
|
||||||
.mdl-rev__input {
|
.mdl-rev__input {
|
||||||
width: 8rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
width: 8rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem;
|
border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem;
|
||||||
|
|
|
||||||
|
|
@ -374,8 +374,6 @@
|
||||||
if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); });
|
if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); });
|
||||||
if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); });
|
if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); });
|
||||||
if (app.dom.checkDuplicatesBtn) app.dom.checkDuplicatesBtn.addEventListener('click', function () { app.modules.copy.audit(); });
|
if (app.dom.checkDuplicatesBtn) app.dom.checkDuplicatesBtn.addEventListener('click', function () { app.modules.copy.audit(); });
|
||||||
var mdlBtn = document.getElementById('mdlInstantiateBtn');
|
|
||||||
if (mdlBtn) mdlBtn.addEventListener('click', function () { app.modules.mdlInstantiate.open(); });
|
|
||||||
|
|
||||||
// Live source-tree filter (matches file path + name; reveals the hierarchy).
|
// Live source-tree filter (matches file path + name; reveals the hierarchy).
|
||||||
if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () {
|
if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () {
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@
|
||||||
// table columns + (later) revision-modifier menus. Editable by the user.
|
// table columns + (later) revision-modifier menus. Editable by the user.
|
||||||
var DEFAULT_FIELDS = [
|
var DEFAULT_FIELDS = [
|
||||||
{ name: 'ORIG', optional: false },
|
{ name: 'ORIG', optional: false },
|
||||||
{ name: 'PROJ', optional: false },
|
{ name: 'PHASE', optional: false },
|
||||||
|
{ name: 'PROJECT', optional: false },
|
||||||
|
{ name: 'AREA', optional: false },
|
||||||
{ name: 'DISC', optional: false },
|
{ name: 'DISC', optional: false },
|
||||||
{ name: 'TYPE', optional: false },
|
{ name: 'TYPE', optional: false },
|
||||||
{ name: 'SEQ', optional: false },
|
{ name: 'SEQ', optional: false },
|
||||||
|
|
@ -488,6 +490,8 @@
|
||||||
return {
|
return {
|
||||||
id: r.id || uid(), party: r.party || '',
|
id: r.id || uid(), party: r.party || '',
|
||||||
trackingNumber: r.trackingNumber || '', title: r.title || '',
|
trackingNumber: r.trackingNumber || '', title: r.title || '',
|
||||||
|
inMdl: !!r.inMdl,
|
||||||
|
archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [],
|
||||||
revisionCell: r.revisionCell || '',
|
revisionCell: r.revisionCell || '',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
/**
|
|
||||||
* ZDDC Classifier — instantiate MDL deliverables from existing archive files.
|
|
||||||
*
|
|
||||||
* Catch-up flow: the archive already holds issued documents, but the Master
|
|
||||||
* Deliverables List is empty. This reads a project's archive subtree as a flat
|
|
||||||
* file list, lets the user build a selection set (autofilter + ctrl-shift via
|
|
||||||
* the shared seltable), dedupes the selected files to one deliverable per
|
|
||||||
* tracking number, and PUTs a new deliverable .yaml into the originator's
|
|
||||||
* `archive/<originator>/mdl/` on the server. Server-only (needs http + auth).
|
|
||||||
*
|
|
||||||
* A deliverable .yaml's filename IS its tracking number; the server pins
|
|
||||||
* `originator` from the folder and composes the filename, so the body carries
|
|
||||||
* only project/discipline/type/sequence/suffix + title.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
if (!window.app) window.app = {};
|
|
||||||
if (!window.app.modules) window.app.modules = {};
|
|
||||||
|
|
||||||
function T(m, l, o) { if (window.zddc && window.zddc.toast) window.zddc.toast(m, l, o); }
|
|
||||||
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
|
||||||
|
|
||||||
// ── pure core (test seams) ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
// A tracking number → deliverable {tracking, originator, body{...}} or null
|
|
||||||
// if it doesn't fit the MDL schema (needs orig-proj-disc-type-seq, + suffix).
|
|
||||||
function deliverableFromFile(f) {
|
|
||||||
var segs = String(f.tracking || '').split('-');
|
|
||||||
if (segs.length < 5) return null;
|
|
||||||
var body = { project: segs[1], discipline: segs[2], type: segs[3], sequence: segs[4], title: f.title || '' };
|
|
||||||
if (segs.length >= 6) body.suffix = segs.slice(5).join('-');
|
|
||||||
return { tracking: f.tracking, originator: segs[0], body: body };
|
|
||||||
}
|
|
||||||
// Dedupe a list of archive files to one deliverable per tracking number.
|
|
||||||
function dedupe(files) {
|
|
||||||
var seen = Object.create(null), out = [];
|
|
||||||
(files || []).forEach(function (f) {
|
|
||||||
if (seen[f.tracking]) return;
|
|
||||||
var d = deliverableFromFile(f);
|
|
||||||
if (d) { seen[f.tracking] = true; out.push(d); }
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively walk an archive directory handle → flat list of ZDDC-named
|
|
||||||
// files (skips dot/underscore folders; non-ZDDC names like the mdl yamls
|
|
||||||
// naturally fall out because parseFilename rejects them).
|
|
||||||
async function walkArchive(rootHandle) {
|
|
||||||
var out = [];
|
|
||||||
async function walk(dirH, parts) {
|
|
||||||
for await (var entry of dirH.values()) {
|
|
||||||
var nm = String(entry.name || '').replace(/\/$/, '');
|
|
||||||
if (entry.kind === 'directory') {
|
|
||||||
var c = nm.charAt(0);
|
|
||||||
if (c === '.' || c === '_' || nm === 'mdl' || nm === 'rsk') continue;
|
|
||||||
var childH = await dirH.getDirectoryHandle(nm);
|
|
||||||
await walk(childH, parts.concat(nm));
|
|
||||||
} else {
|
|
||||||
var p = window.zddc.parseFilename(nm);
|
|
||||||
if (p && p.valid) {
|
|
||||||
out.push({
|
|
||||||
id: parts.concat(nm).join('/'),
|
|
||||||
party: parts[0] || '', slot: parts[1] || '', transmittal: parts[2] || '',
|
|
||||||
name: nm, tracking: p.trackingNumber, revision: p.revision, status: p.status, title: p.title,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await walk(rootHandle, []);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write one deliverable into <archiveRoot>/<originator>/mdl/<tracking>.yaml.
|
|
||||||
// Returns 'created' | 'skipped' (already present). Throws on server error.
|
|
||||||
async function instantiateOne(archiveRoot, d) {
|
|
||||||
var dir = await archiveRoot.getDirectoryHandle(d.originator, { create: true });
|
|
||||||
dir = await dir.getDirectoryHandle('mdl', { create: true });
|
|
||||||
var fname = d.tracking + '.yaml';
|
|
||||||
try { await dir.getFileHandle(fname); return 'skipped'; } catch (e) { /* NotFound → create */ }
|
|
||||||
var yaml = window.jsyaml.dump(d.body);
|
|
||||||
var fh = await dir.getFileHandle(fname, { create: true });
|
|
||||||
var w = await fh.createWritable();
|
|
||||||
await w.write(new Blob([yaml], { type: 'application/yaml' }));
|
|
||||||
await w.close();
|
|
||||||
return 'created';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function instantiateAll(archiveRoot, deliverables, onProgress) {
|
|
||||||
var s = { created: 0, skipped: 0, errors: 0 };
|
|
||||||
for (var i = 0; i < deliverables.length; i++) {
|
|
||||||
if (onProgress) onProgress(i + 1, deliverables.length, deliverables[i].tracking);
|
|
||||||
try { s[await instantiateOne(archiveRoot, deliverables[i])]++; }
|
|
||||||
catch (e) { s.errors++; T('Failed to create ' + deliverables[i].tracking + ' — ' + (e.message || e), 'error'); }
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UI ───────────────────────────────────────────────────────────────────
|
|
||||||
var overlay = null, statusEl = null, table = null, files = [], archiveRoot = null;
|
|
||||||
|
|
||||||
function close() { if (overlay) { overlay.remove(); overlay = null; table = null; } }
|
|
||||||
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
|
|
||||||
|
|
||||||
async function open() {
|
|
||||||
var copy = window.app.modules.copy;
|
|
||||||
var src = window.zddc && window.zddc.source;
|
|
||||||
if (!src || location.protocol === 'file:') {
|
|
||||||
T('Populating the MDL from the archive needs the classifier served by a zddc-server (open it over http).', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var projects = await copy.fetchAccessProjects();
|
|
||||||
if (projects == null) { T('Could not load your projects from the server.', 'error'); return; }
|
|
||||||
if (!projects.length) { T('No projects you can access on this server.', 'warning'); return; }
|
|
||||||
var proj = await copy.chooseProject(projects);
|
|
||||||
if (!proj) return;
|
|
||||||
buildOverlay(proj);
|
|
||||||
await scan(proj);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOverlay(proj) {
|
|
||||||
close();
|
|
||||||
overlay = el('div', 'mdl-overlay');
|
|
||||||
var box = el('div', 'mdl-overlay__box');
|
|
||||||
var head = el('div', 'mdl-overlay__head');
|
|
||||||
head.appendChild(el('h2', null, 'Populate MDL from archive — ' + (proj.title || proj.name)));
|
|
||||||
var x = el('button', 'mdl-overlay__close', '×'); x.title = 'Close'; x.addEventListener('click', close);
|
|
||||||
head.appendChild(x);
|
|
||||||
box.appendChild(head);
|
|
||||||
statusEl = el('div', 'mdl-overlay__status', 'Scanning archive…');
|
|
||||||
box.appendChild(statusEl);
|
|
||||||
var host = el('div', 'mdl-overlay__table');
|
|
||||||
box.appendChild(host);
|
|
||||||
var foot = el('div', 'mdl-overlay__foot');
|
|
||||||
var create = el('button', 'btn btn-primary', 'Create deliverables');
|
|
||||||
create.addEventListener('click', function () { runCreate(create); });
|
|
||||||
foot.appendChild(create);
|
|
||||||
var cancel = el('button', 'btn btn-secondary', 'Close'); cancel.addEventListener('click', close);
|
|
||||||
foot.appendChild(cancel);
|
|
||||||
box.appendChild(foot);
|
|
||||||
overlay.appendChild(box);
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
table = window.app.modules.seltable.create({
|
|
||||||
container: host,
|
|
||||||
filterPlaceholder: 'Filter by party, transmittal, tracking number, title…',
|
|
||||||
rows: function () { return files; },
|
|
||||||
rowId: function (r) { return r.id; },
|
|
||||||
columns: [
|
|
||||||
{ key: 'party', title: 'Party' },
|
|
||||||
{ key: 'slot', title: 'Slot' },
|
|
||||||
{ key: 'transmittal', title: 'Transmittal' },
|
|
||||||
{ key: 'tracking', title: 'Tracking number' },
|
|
||||||
{ key: 'revision', title: 'Rev', get: function (r) { return r.revision + (r.status ? ' (' + r.status + ')' : ''); } },
|
|
||||||
{ key: 'title', title: 'Title' },
|
|
||||||
],
|
|
||||||
onSelectionChange: function (ids) { create.textContent = ids.length ? ('Create deliverables (' + dedupe(selectedFiles(ids)).length + ')') : 'Create deliverables'; },
|
|
||||||
});
|
|
||||||
table.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedFiles(ids) {
|
|
||||||
var set = {}; ids.forEach(function (i) { set[i] = true; });
|
|
||||||
return files.filter(function (f) { return set[f.id]; });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scan(proj) {
|
|
||||||
var src = window.zddc.source;
|
|
||||||
var rel = (proj.url || ('/' + proj.name + '/'));
|
|
||||||
if (rel.charAt(rel.length - 1) !== '/') rel += '/';
|
|
||||||
try {
|
|
||||||
archiveRoot = new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive');
|
|
||||||
setStatus('Scanning archive…');
|
|
||||||
files = await walkArchive(archiveRoot);
|
|
||||||
table.renderBody();
|
|
||||||
setStatus(files.length + ' document file' + (files.length === 1 ? '' : 's') + ' found. Filter + ctrl-shift select, then “Create deliverables”.');
|
|
||||||
} catch (e) {
|
|
||||||
setStatus('Archive scan failed — ' + (e.message || e));
|
|
||||||
T('Archive scan failed — ' + (e.message || e), 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCreate(btn) {
|
|
||||||
if (!table) return;
|
|
||||||
var sel = table.getSelection();
|
|
||||||
if (!sel.length) { T('Select some archive files first (filter + ctrl-shift).', 'warning'); return; }
|
|
||||||
var deliverables = dedupe(selectedFiles(sel));
|
|
||||||
if (!deliverables.length) { T('None of the selected files have a tracking number that fits the deliverable schema.', 'warning'); return; }
|
|
||||||
if (!confirm('Create ' + deliverables.length + ' deliverable(s) in the project MDL?\n\n'
|
|
||||||
+ 'One .yaml per tracking number, in archive/<originator>/mdl/. Already-present ones are skipped.')) return;
|
|
||||||
btn.disabled = true;
|
|
||||||
var s = await instantiateAll(archiveRoot, deliverables, function (i, n, tn) { setStatus('Creating ' + i + '/' + n + ' — ' + tn); });
|
|
||||||
btn.disabled = false;
|
|
||||||
setStatus(s.created + ' created, ' + s.skipped + ' already there'
|
|
||||||
+ (s.errors ? (', ' + s.errors + ' failed') : '') + '. ' + files.length + ' files scanned.');
|
|
||||||
T('MDL: ' + s.created + ' created, ' + s.skipped + ' already there'
|
|
||||||
+ (s.errors ? (', ' + s.errors + ' failed') : '') + '.', s.errors ? 'warning' : 'success');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.app.modules.mdlInstantiate = {
|
|
||||||
open: open,
|
|
||||||
// test seams
|
|
||||||
deliverableFromFile: deliverableFromFile,
|
|
||||||
dedupe: dedupe,
|
|
||||||
walkArchive: walkArchive,
|
|
||||||
instantiateOne: instantiateOne,
|
|
||||||
instantiateAll: instantiateAll,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
@ -19,8 +19,9 @@
|
||||||
var collapsed = {}; // nodeId -> true when collapsed (default expanded)
|
var collapsed = {}; // nodeId -> true when collapsed (default expanded)
|
||||||
var openForm = null; // { partyId, slot } when a bin form is open
|
var openForm = null; // { partyId, slot } when a bin form is open
|
||||||
var initialized = false;
|
var initialized = false;
|
||||||
var currentTab = 'tracking'; // 'tracking' | 'transmittal' | 'mdl' — the active axis
|
var currentTab = 'tracking'; // 'tracking' | 'transmittal' — the active tab
|
||||||
var mdlTable = null; // the seltable controller for the By-MDL panel
|
var catalogOpen = false; // the Catalog overlay (the 'mdl' axis) is open
|
||||||
|
var mdlTable = null; // the seltable controller for the catalog
|
||||||
var mdlPlaced = {}; // latest placed.mdl map (read by the placed-file cell)
|
var mdlPlaced = {}; // latest placed.mdl map (read by the placed-file cell)
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
|
@ -29,7 +30,7 @@
|
||||||
els = {
|
els = {
|
||||||
trackingTab: document.getElementById('trackingTab'),
|
trackingTab: document.getElementById('trackingTab'),
|
||||||
transmittalTab: document.getElementById('transmittalTab'),
|
transmittalTab: document.getElementById('transmittalTab'),
|
||||||
mdlTab: document.getElementById('mdlTab'),
|
catalogBtn: document.getElementById('catalogBtn'),
|
||||||
trackingPanel: document.getElementById('trackingPanel'),
|
trackingPanel: document.getElementById('trackingPanel'),
|
||||||
transmittalPanel: document.getElementById('transmittalPanel'),
|
transmittalPanel: document.getElementById('transmittalPanel'),
|
||||||
mdlPanel: document.getElementById('mdlPanel'),
|
mdlPanel: document.getElementById('mdlPanel'),
|
||||||
|
|
@ -37,6 +38,7 @@
|
||||||
transmittalTree: document.getElementById('transmittalTree'),
|
transmittalTree: document.getElementById('transmittalTree'),
|
||||||
mdlTree: document.getElementById('mdlTree'),
|
mdlTree: document.getElementById('mdlTree'),
|
||||||
loadMdlBtn: document.getElementById('loadMdlBtn'),
|
loadMdlBtn: document.getElementById('loadMdlBtn'),
|
||||||
|
catalogCloseBtn: document.getElementById('catalogCloseBtn'),
|
||||||
addTrackingRootBtn: document.getElementById('addTrackingRootBtn'),
|
addTrackingRootBtn: document.getElementById('addTrackingRootBtn'),
|
||||||
addPartyBtn: document.getElementById('addPartyBtn'),
|
addPartyBtn: document.getElementById('addPartyBtn'),
|
||||||
stats: document.getElementById('classifyStats'),
|
stats: document.getElementById('classifyStats'),
|
||||||
|
|
@ -44,7 +46,8 @@
|
||||||
|
|
||||||
els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
|
els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
|
||||||
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
|
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
|
||||||
if (els.mdlTab) els.mdlTab.addEventListener('click', function () { showTab('mdl'); });
|
if (els.catalogBtn) els.catalogBtn.addEventListener('click', function () { catalogOpen ? closeCatalog() : openCatalog(); });
|
||||||
|
if (els.catalogCloseBtn) els.catalogCloseBtn.addEventListener('click', closeCatalog);
|
||||||
if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl);
|
if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl);
|
||||||
els.addTrackingRootBtn.addEventListener('click', function () {
|
els.addTrackingRootBtn.addEventListener('click', function () {
|
||||||
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
|
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
|
||||||
|
|
@ -98,18 +101,31 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTab(which) {
|
function showTab(which) {
|
||||||
currentTab = (which === 'transmittal' || which === 'mdl') ? which : 'tracking';
|
currentTab = which === 'transmittal' ? 'transmittal' : 'tracking';
|
||||||
els.trackingTab.classList.toggle('active', currentTab === 'tracking');
|
els.trackingTab.classList.toggle('active', currentTab === 'tracking');
|
||||||
els.transmittalTab.classList.toggle('active', currentTab === 'transmittal');
|
els.transmittalTab.classList.toggle('active', currentTab === 'transmittal');
|
||||||
if (els.mdlTab) els.mdlTab.classList.toggle('active', currentTab === 'mdl');
|
|
||||||
els.trackingPanel.hidden = currentTab !== 'tracking';
|
els.trackingPanel.hidden = currentTab !== 'tracking';
|
||||||
els.transmittalPanel.hidden = currentTab !== 'transmittal';
|
els.transmittalPanel.hidden = currentTab !== 'transmittal';
|
||||||
if (els.mdlPanel) els.mdlPanel.hidden = currentTab !== 'mdl';
|
|
||||||
// The source-tree Show filters are per-axis, so the visible set changes
|
// The source-tree Show filters are per-axis, so the visible set changes
|
||||||
// with the active tab — re-render the left tree.
|
// with the active tab — re-render the left tree.
|
||||||
if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render();
|
if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render();
|
||||||
}
|
}
|
||||||
function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : currentTab === 'mdl' ? 'mdl' : 'tracking'; }
|
// The active axis is the catalog ('mdl') while the overlay is open, else the tab.
|
||||||
|
function activeAxis() { return catalogOpen ? 'mdl' : (currentTab === 'transmittal' ? 'transmittal' : 'tracking'); }
|
||||||
|
function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); }
|
||||||
|
function openCatalog() {
|
||||||
|
catalogOpen = true;
|
||||||
|
if (els.mdlPanel) els.mdlPanel.hidden = false;
|
||||||
|
if (els.catalogBtn) els.catalogBtn.classList.add('active');
|
||||||
|
render();
|
||||||
|
reRenderSource();
|
||||||
|
}
|
||||||
|
function closeCatalog() {
|
||||||
|
catalogOpen = false;
|
||||||
|
if (els.mdlPanel) els.mdlPanel.hidden = true;
|
||||||
|
if (els.catalogBtn) els.catalogBtn.classList.remove('active');
|
||||||
|
reRenderSource();
|
||||||
|
}
|
||||||
|
|
||||||
// Expand a brace pattern into folder names and create them (confirming a
|
// Expand a brace pattern into folder names and create them (confirming a
|
||||||
// multi-create first). parentId null = root folders. See expandFolderPattern.
|
// multi-create first). parentId null = root folders. See expandFolderPattern.
|
||||||
|
|
@ -474,7 +490,7 @@
|
||||||
if (!C().getMdlList().length) {
|
if (!C().getMdlList().length) {
|
||||||
mdlTable = null;
|
mdlTable = null;
|
||||||
els.mdlTree.textContent = '';
|
els.mdlTree.textContent = '';
|
||||||
els.mdlTree.appendChild(el('div', 'target-empty', 'No MDL loaded — “Load MDL…” to bring a project’s deliverables in as drop targets.'));
|
els.mdlTree.appendChild(el('div', 'target-empty', 'Nothing loaded yet — “Load…” to pull in the project’s MDL deliverables and archive tracking numbers.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ensureMdlTable();
|
ensureMdlTable();
|
||||||
|
|
@ -483,31 +499,35 @@
|
||||||
function ensureMdlTable() {
|
function ensureMdlTable() {
|
||||||
if (mdlTable) return mdlTable;
|
if (mdlTable) return mdlTable;
|
||||||
var c = C();
|
var c = C();
|
||||||
|
// One column per configured tracking-number field (split positionally),
|
||||||
|
// then Title, MDL (✓), Archive revisions (informational), and the editable
|
||||||
|
// classifier-local Revision. Each column has its own autofilter.
|
||||||
|
var cols = c.getTrackingFields().map(function (f, i) {
|
||||||
|
return { key: 'f' + i, title: f.name, get: function (r) { return (r.trackingNumber || '').split('-')[i] || ''; } };
|
||||||
|
});
|
||||||
|
cols.push({ key: 'title', title: 'Title', get: function (r) { return r.title || ''; } });
|
||||||
|
cols.push({ key: 'mdl', title: 'MDL', cls: 'catalog-mdl', get: function (r) { return r.inMdl ? '✓' : ''; } });
|
||||||
|
cols.push({ key: 'arev', title: 'Archive revs', get: function (r) { return (r.archiveRevisions || []).join(', '); } });
|
||||||
|
cols.push({
|
||||||
|
key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
|
||||||
|
render: function (r, td) {
|
||||||
|
var inp = document.createElement('input');
|
||||||
|
inp.type = 'text'; inp.className = 'mdl-rev__input'; inp.value = r.revisionCell || '';
|
||||||
|
inp.placeholder = 'A (IFR)'; inp.setAttribute('data-no-select', '');
|
||||||
|
inp.addEventListener('change', function () { c.setRevisionCell(r.id, inp.value.trim()); });
|
||||||
|
td.appendChild(inp);
|
||||||
|
},
|
||||||
|
});
|
||||||
mdlTable = window.app.modules.seltable.create({
|
mdlTable = window.app.modules.seltable.create({
|
||||||
container: els.mdlTree,
|
container: els.mdlTree,
|
||||||
filterPlaceholder: 'Filter deliverables by tracking number, title, party…',
|
|
||||||
extraTitle: 'Files',
|
extraTitle: 'Files',
|
||||||
rows: function () { return c.getMdlList(); },
|
rows: function () { return c.getMdlList(); },
|
||||||
rowId: function (r) { return r.id; },
|
rowId: function (r) { return r.id; },
|
||||||
columns: [
|
columns: cols,
|
||||||
{ key: 'tracking', title: 'Tracking number', get: function (r) { return r.trackingNumber; } },
|
|
||||||
{ key: 'title', title: 'Title', get: function (r) { return r.title; } },
|
|
||||||
{ key: 'party', title: 'Party', get: function (r) { return r.party; } },
|
|
||||||
{
|
|
||||||
key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
|
|
||||||
render: function (r, td) {
|
|
||||||
var inp = document.createElement('input');
|
|
||||||
inp.type = 'text'; inp.className = 'mdl-rev__input'; inp.value = r.revisionCell || '';
|
|
||||||
inp.placeholder = 'A (IFR)'; inp.setAttribute('data-no-select', '');
|
|
||||||
inp.addEventListener('change', function () { c.setRevisionCell(r.id, inp.value.trim()); });
|
|
||||||
td.appendChild(inp);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
onRowDrop: function (rowId, keys) { c.place(keys, rowId, 'mdl'); },
|
onRowDrop: function (rowId, keys) { c.place(keys, rowId, 'mdl'); },
|
||||||
onActivate: function (ids) {
|
onActivate: function (ids) {
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
var v = prompt('Set the revision on ' + ids.length + ' selected deliverable(s) (e.g. "A (IFR)"):', '');
|
var v = prompt('Set the revision on ' + ids.length + ' selected row(s) (e.g. "A (IFR)"):', '');
|
||||||
if (v != null) c.setRevisionCells(ids, v.trim());
|
if (v != null) c.setRevisionCells(ids, v.trim());
|
||||||
},
|
},
|
||||||
rowExtra: function (r, td) { renderMdlPlaced(r, td); },
|
rowExtra: function (r, td) { renderMdlPlaced(r, td); },
|
||||||
|
|
@ -538,22 +558,13 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load deliverables into the MDL table — a local folder of .yaml or a server
|
// Load the catalog: the union of the project's MDL deliverables and its
|
||||||
// project (one or more parties). Each yaml's filename stem is the tracking
|
// archive tracking numbers, deduped by tracking number. Server reads both;
|
||||||
// number; the revision cell starts blank (classifier-local).
|
// a local folder reads just its *.yaml deliverables. Writes/alters nothing —
|
||||||
function yamlToRow(party, stem, obj) {
|
// the revision cell is classifier-local and starts blank.
|
||||||
return {
|
function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; }
|
||||||
id: party + '|' + stem,
|
|
||||||
party: party,
|
|
||||||
trackingNumber: stem,
|
|
||||||
title: (obj && obj.title) || '',
|
|
||||||
revisionCell: '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
async function loadMdl() {
|
async function loadMdl() {
|
||||||
if (window.zddc && window.zddc.source && location.protocol !== 'file:') {
|
if (window.zddc && window.zddc.source && location.protocol !== 'file:') return loadMdlServer();
|
||||||
return loadMdlServer();
|
|
||||||
}
|
|
||||||
return loadMdlLocal();
|
return loadMdlLocal();
|
||||||
}
|
}
|
||||||
async function loadMdlLocal() {
|
async function loadMdlLocal() {
|
||||||
|
|
@ -564,10 +575,11 @@
|
||||||
var rows = [];
|
var rows = [];
|
||||||
try {
|
try {
|
||||||
for await (var entry of dir.values()) {
|
for await (var entry of dir.values()) {
|
||||||
if (entry.kind !== 'file' || !/\.yaml$/i.test(entry.name) || entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
|
var nm = String(entry.name).replace(/\/$/, '');
|
||||||
var txt = await (await entry.getFile()).text();
|
if (entry.kind !== 'file' || !isRowYaml(nm)) continue;
|
||||||
var obj = null; try { obj = window.jsyaml.load(txt); } catch (_) { /* skip bad yaml */ }
|
var obj = null; try { obj = window.jsyaml.load(await (await entry.getFile()).text()); } catch (_) { /* skip */ }
|
||||||
rows.push(yamlToRow(dir.name || 'local', entry.name.replace(/\.yaml$/i, ''), obj));
|
var stem = nm.replace(/\.yaml$/i, '');
|
||||||
|
rows.push({ id: stem, party: dir.name || 'local', trackingNumber: stem, title: (obj && obj.title) || '', inMdl: true, archiveRevisions: [], revisionCell: '' });
|
||||||
}
|
}
|
||||||
} catch (e) { window.zddc.toast('Reading the MDL folder failed — ' + (e.message || e), 'error'); return; }
|
} catch (e) { window.zddc.toast('Reading the MDL folder failed — ' + (e.message || e), 'error'); return; }
|
||||||
finishLoad(rows);
|
finishLoad(rows);
|
||||||
|
|
@ -581,29 +593,63 @@
|
||||||
if (!proj) return;
|
if (!proj) return;
|
||||||
var rel = (proj.url || ('/' + proj.name + '/')); if (rel.charAt(rel.length - 1) !== '/') rel += '/';
|
var rel = (proj.url || ('/' + proj.name + '/')); if (rel.charAt(rel.length - 1) !== '/') rel += '/';
|
||||||
var archive = new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive');
|
var archive = new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive');
|
||||||
var rows = [];
|
var byTn = Object.create(null);
|
||||||
|
function ensure(tn) { return byTn[tn] || (byTn[tn] = { tracking: tn, title: '', inMdl: false, party: '', revs: Object.create(null) }); }
|
||||||
|
window.zddc.toast('Scanning the project MDL + archive…', 'info', { durationMs: 4000 });
|
||||||
try {
|
try {
|
||||||
for await (var party of archive.values()) {
|
for await (var party of archive.values()) {
|
||||||
if (party.kind !== 'directory') continue;
|
if (party.kind !== 'directory') continue;
|
||||||
var pn = String(party.name).replace(/\/$/, '');
|
var pn = String(party.name).replace(/\/$/, '');
|
||||||
if (pn.charAt(0) === '.' || pn.charAt(0) === '_') continue;
|
if (pn.charAt(0) === '.' || pn.charAt(0) === '_') continue;
|
||||||
var mdlDir;
|
for await (var slot of party.values()) {
|
||||||
try { mdlDir = await party.getDirectoryHandle('mdl'); } catch (e) { continue; } // no mdl for this party
|
if (slot.kind !== 'directory') continue;
|
||||||
for await (var entry of mdlDir.values()) {
|
var sn = String(slot.name).replace(/\/$/, '');
|
||||||
var nm = String(entry.name).replace(/\/$/, '');
|
if (sn.charAt(0) === '.' || sn.charAt(0) === '_' || sn === 'rsk') continue;
|
||||||
if (entry.kind !== 'file' || !/\.yaml$/i.test(nm) || nm === 'table.yaml' || nm === 'form.yaml') continue;
|
if (sn === 'mdl') { // registered deliverables
|
||||||
var obj = null;
|
for await (var ye of slot.values()) {
|
||||||
try { obj = window.jsyaml.load(await (await entry.getFile()).text()); } catch (_) { /* skip */ }
|
var ynm = String(ye.name).replace(/\/$/, '');
|
||||||
rows.push(yamlToRow(pn, nm.replace(/\.yaml$/i, ''), obj));
|
if (ye.kind !== 'file' || !isRowYaml(ynm)) continue;
|
||||||
|
var obj = null; try { obj = window.jsyaml.load(await (await ye.getFile()).text()); } catch (_) { /* skip */ }
|
||||||
|
var row = ensure(ynm.replace(/\.yaml$/i, ''));
|
||||||
|
row.inMdl = true; if (!row.title && obj && obj.title) row.title = obj.title; if (!row.party) row.party = pn;
|
||||||
|
}
|
||||||
|
} else { // archive documents → tracking + revisions
|
||||||
|
await walkArchiveInto(slot, ensure, pn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { window.zddc.toast('Reading the project MDL failed — ' + (e.message || e), 'error'); return; }
|
} catch (e) { window.zddc.toast('Reading the project failed — ' + (e.message || e), 'error'); return; }
|
||||||
|
var rows = Object.keys(byTn).map(function (tn) {
|
||||||
|
var x = byTn[tn];
|
||||||
|
return { id: tn, party: x.party, trackingNumber: tn, title: x.title, inMdl: x.inMdl, archiveRevisions: Object.keys(x.revs).sort(), revisionCell: '' };
|
||||||
|
});
|
||||||
finishLoad(rows);
|
finishLoad(rows);
|
||||||
}
|
}
|
||||||
|
// Recursively collect ZDDC-named archive files under a slot → tracking +
|
||||||
|
// the set of revisions seen for each.
|
||||||
|
async function walkArchiveInto(dirH, ensure, party) {
|
||||||
|
for await (var entry of dirH.values()) {
|
||||||
|
var nm = String(entry.name).replace(/\/$/, '');
|
||||||
|
if (entry.kind === 'directory') {
|
||||||
|
if (nm.charAt(0) === '.' || nm.charAt(0) === '_') continue;
|
||||||
|
await walkArchiveInto(await dirH.getDirectoryHandle(nm), ensure, party);
|
||||||
|
} else {
|
||||||
|
var p = window.zddc.parseFilename(nm);
|
||||||
|
if (p && p.valid && p.trackingNumber) {
|
||||||
|
var row = ensure(p.trackingNumber);
|
||||||
|
if (!row.title) row.title = p.title || '';
|
||||||
|
if (!row.party) row.party = party;
|
||||||
|
row.revs[(p.revision + (p.status ? ' (' + p.status + ')' : '')).trim()] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
function finishLoad(rows) {
|
function finishLoad(rows) {
|
||||||
C().setMdlList(rows);
|
C().setMdlList(rows);
|
||||||
showTab('mdl');
|
openCatalog();
|
||||||
window.zddc.toast(rows.length ? ('Loaded ' + rows.length + ' deliverable' + (rows.length === 1 ? '' : 's') + ' — set revisions and drag files on.') : 'No deliverables found.', rows.length ? 'success' : 'warning');
|
window.zddc.toast(rows.length
|
||||||
|
? ('Catalog: ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' (MDL ∪ archive). Filter, drag files on, set revisions.')
|
||||||
|
: 'Nothing found in the MDL or archive.', rows.length ? 'success' : 'warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── events ─────────────────────────────────────────────────────────────
|
// ── events ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -808,8 +854,8 @@
|
||||||
var a = C().getAssignment(key);
|
var a = C().getAssignment(key);
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
if (a.mdlNodeId) {
|
if (a.mdlNodeId) {
|
||||||
showTab('mdl');
|
openCatalog();
|
||||||
if (mdlTable) { mdlTable.setFilter(''); mdlTable.renderBody(); }
|
if (mdlTable) { mdlTable.renderBody(); }
|
||||||
} else if (a.trackingNodeId) {
|
} else if (a.trackingNodeId) {
|
||||||
showTab('tracking'); collapsed = {}; render();
|
showTab('tracking'); collapsed = {}; render();
|
||||||
flashNode(els.trackingTree, a.trackingNodeId);
|
flashNode(els.trackingTree, a.trackingNodeId);
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@
|
||||||
<button id="modeClassifyBtn" class="mode-btn active" title="Map files onto tracking numbers and transmittals, then copy renamed copies to an output directory — the source is never modified">Classify & copy</button>
|
<button id="modeClassifyBtn" class="mode-btn active" title="Map files onto tracking numbers and transmittals, then copy renamed copies to an output directory — the source is never modified">Classify & copy</button>
|
||||||
<button id="modeRenameBtn" class="mode-btn" title="Edit a spreadsheet and rename the files in place (edits the source)">Rename in place</button>
|
<button id="modeRenameBtn" class="mode-btn" title="Edit a spreadsheet and rename the files in place (edits the source)">Rename in place</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="mdlInstantiateBtn" class="btn btn-secondary btn-sm" title="Populate a project's Master Deliverables List from its existing archive files (server)">⊞ MDL from archive</button>
|
|
||||||
<button id="workspacesBtn" class="btn btn-secondary btn-sm" title="Workspaces — open or create a classification project">≡ Workspaces</button>
|
<button id="workspacesBtn" class="btn btn-secondary btn-sm" title="Workspaces — open or create a classification project">≡ Workspaces</button>
|
||||||
<button id="connectDirBtn" class="btn btn-primary btn-sm" title="Connect this workspace's source directory to preview, copy, or finish scanning" hidden>⮷ Connect directory</button>
|
<button id="connectDirBtn" class="btn btn-primary btn-sm" title="Connect this workspace's source directory to preview, copy, or finish scanning" hidden>⮷ Connect directory</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -163,7 +162,7 @@
|
||||||
<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>
|
<button id="catalogBtn" class="btn btn-secondary btn-sm target-tabs__catalog" title="Open the catalog — everything the project knows: MDL deliverables ∪ archive tracking numbers. Drag files onto a row to name them.">⊞ Catalog</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>
|
||||||
|
|
@ -195,14 +194,20 @@
|
||||||
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
|
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
|
||||||
<div id="transmittalTree" class="target-tree"></div>
|
<div id="transmittalTree" class="target-tree"></div>
|
||||||
</section>
|
</section>
|
||||||
<section id="mdlPanel" class="target-panel" hidden>
|
|
||||||
<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>
|
||||||
|
<!-- Catalog overlay (everything the project knows): opened by the
|
||||||
|
Catalog button. Covers the target pane; the left filetree stays
|
||||||
|
the drag source. Read-only on the MDL/archive; the Revision column
|
||||||
|
is classifier-local. -->
|
||||||
|
<section id="mdlPanel" class="catalog-overlay" hidden>
|
||||||
|
<div class="catalog-overlay__head">
|
||||||
|
<span class="catalog-overlay__title">Catalog — MDL ∪ archive</span>
|
||||||
|
<button id="loadMdlBtn" class="btn btn-sm btn-secondary">⊞ Load…</button>
|
||||||
|
<span class="target-hint">Drag files onto a row to assign its tracking number; set the Revision column (ctrl-shift select + ctrl-Enter to set many at once).</span>
|
||||||
|
<button id="catalogCloseBtn" class="catalog-overlay__close" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="mdlTree" class="catalog-overlay__table"></div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,10 @@ export default defineConfig({
|
||||||
name: 'tables',
|
name: 'tables',
|
||||||
testMatch: 'tables.spec.js',
|
testMatch: 'tables.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',
|
||||||
|
|
|
||||||
46
shared/seltable.css
Normal file
46
shared/seltable.css
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/* ── 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; }
|
||||||
|
.seltable__table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
||||||
|
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
|
||||||
|
.seltable__table thead th {
|
||||||
|
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
|
||||||
|
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.seltable__table thead tr.seltable__filters th { top: 1.55rem; padding: 0.15rem 0.35rem; }
|
||||||
|
.seltable__colfilter {
|
||||||
|
width: 100%; min-width: 5rem; padding: 0.15rem 0.35rem;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--bg); color: var(--text); font-size: 0.74rem; font-weight: 400; text-transform: none; letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.seltable__row { cursor: pointer; user-select: none; }
|
||||||
|
.seltable__row:hover { background: var(--bg-hover); }
|
||||||
|
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
|
||||||
|
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
|
||||||
|
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
|
||||||
|
|
||||||
|
/* ── "Add deliverables from archive" overlay (project MDL rollup) ─────────── */
|
||||||
|
.mdlarch-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex; align-items: center; justify-content: center; padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.mdlarch-overlay__box {
|
||||||
|
display: flex; flex-direction: column; min-height: 0;
|
||||||
|
width: min(960px, 95vw); height: min(80vh, 760px);
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
.mdlarch-overlay__head { display: flex; align-items: center; gap: 0.75rem; padding: 0.85rem 1.1rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||||
|
.mdlarch-overlay__head h2 { margin: 0; font-size: 1.05rem; flex: 1; }
|
||||||
|
.mdlarch-overlay__close { border: none; background: none; color: var(--text-muted); font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.25rem; }
|
||||||
|
.mdlarch-overlay__close:hover { color: var(--text); }
|
||||||
|
.mdlarch-overlay__status { padding: 0.5rem 1.1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
||||||
|
.mdlarch-overlay__table { flex: 1; min-height: 0; display: flex; }
|
||||||
|
.mdlarch-overlay__table .seltable { height: 100%; flex: 1; }
|
||||||
|
.mdlarch-overlay__foot { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0.75rem 1.1rem; border-top: 1px solid var(--border); flex: 0 0 auto; }
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
/**
|
/**
|
||||||
* ZDDC Classifier — shared selectable + autofilter table.
|
* ZDDC — shared selectable + autofilter table (used by the classifier catalog
|
||||||
|
* and the tables tool's "Add from archive").
|
||||||
*
|
*
|
||||||
* A flat table with one global autofilter (AND of space-separated terms over
|
* A flat table with PER-COLUMN autofilters (one input per column, AND-combined,
|
||||||
* every column) and powerful selection for building complex sets quickly:
|
* each an AND of space-separated terms) plus an optional programmatic global
|
||||||
|
* filter, and powerful selection for building complex sets quickly:
|
||||||
* click replace selection + set anchor
|
* click replace selection + set anchor
|
||||||
* ctrl/cmd-click toggle one row
|
* ctrl/cmd-click toggle one row
|
||||||
* shift-click range from the anchor (replaces the selection)
|
* shift-click range from the anchor (replaces the selection)
|
||||||
* ctrl-shift-click ADD the anchor→row range to the existing selection
|
* ctrl-shift-click ADD the anchor→row range to the existing selection
|
||||||
* ctrl/cmd-Enter fire onActivate(selectedIds) — a bulk action
|
* ctrl/cmd-Enter fire onActivate(selectedIds) — a bulk action
|
||||||
* Esc clear
|
* Esc clear
|
||||||
* Ranges run over the CURRENTLY FILTERED order, so "filter to a transmittal,
|
* Ranges run over the CURRENTLY FILTERED order. Selection is keyed by a stable
|
||||||
* then shift-select the visible block" works. Selection is keyed by a stable
|
|
||||||
* rowId so it survives filtering and re-render.
|
* rowId so it survives filtering and re-render.
|
||||||
*
|
|
||||||
* Used by the MDL instantiate flow (Phase 1) and the By-MDL drop-target table
|
|
||||||
* (Phase 2).
|
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
@ -41,17 +39,28 @@
|
||||||
var getRows = (typeof opts.rows === 'function') ? opts.rows : function () { return opts.rows || []; };
|
var getRows = (typeof opts.rows === 'function') ? opts.rows : function () { return opts.rows || []; };
|
||||||
var selected = Object.create(null); // id -> true
|
var selected = Object.create(null); // id -> true
|
||||||
var anchorId = null;
|
var anchorId = null;
|
||||||
var ft = []; // global filter terms
|
var globalTerms = []; // programmatic global filter (tests/reveal)
|
||||||
|
var colFilters = Object.create(null); // colKey -> terms[] (the per-column autofilters)
|
||||||
|
|
||||||
function rows() { return getRows() || []; }
|
function rows() { return getRows() || []; }
|
||||||
|
function colByKey(k) { for (var i = 0; i < columns.length; i++) { if (columns[i].key === k) return columns[i]; } return null; }
|
||||||
function colVal(col, row) { return col.get ? col.get(row) : (row[col.key] == null ? '' : row[col.key]); }
|
function colVal(col, row) { return col.get ? col.get(row) : (row[col.key] == null ? '' : row[col.key]); }
|
||||||
function rowBlob(row) { var s = ''; for (var i = 0; i < columns.length; i++) { s += colVal(columns[i], row) + ' '; } return s; }
|
function rowBlob(row) { var s = ''; for (var i = 0; i < columns.length; i++) { s += colVal(columns[i], row) + ' '; } return s; }
|
||||||
function filtered() { return ft.length ? rows().filter(function (r) { return hit(rowBlob(r), ft); }) : rows().slice(); }
|
function rowMatches(row) {
|
||||||
|
if (globalTerms.length && !hit(rowBlob(row), globalTerms)) return false;
|
||||||
|
for (var k in colFilters) {
|
||||||
|
var col = colByKey(k);
|
||||||
|
if (col && !hit(colVal(col, row), colFilters[k])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function filtered() { return rows().filter(rowMatches); }
|
||||||
|
|
||||||
function getSelection() { return Object.keys(selected); }
|
function getSelection() { return Object.keys(selected); }
|
||||||
function getFilteredRows() { return filtered(); }
|
function getFilteredRows() { return filtered(); }
|
||||||
function fireSel() { if (opts.onSelectionChange) opts.onSelectionChange(getSelection()); }
|
function fireSel() { if (opts.onSelectionChange) opts.onSelectionChange(getSelection()); }
|
||||||
function setFilter(q) { ft = terms(q); renderBody(); }
|
function setFilter(q) { globalTerms = terms(q); renderBody(); }
|
||||||
|
function setColFilter(colKey, q) { var t = terms(q); if (t.length) colFilters[colKey] = t; else delete colFilters[colKey]; renderBody(); }
|
||||||
function selectAllFiltered() { filtered().forEach(function (r) { selected[rowId(r)] = true; }); anchorId = null; renderBody(); fireSel(); }
|
function selectAllFiltered() { filtered().forEach(function (r) { selected[rowId(r)] = true; }); anchorId = null; renderBody(); fireSel(); }
|
||||||
function clearSel() { selected = Object.create(null); anchorId = null; renderBody(); fireSel(); }
|
function clearSel() { selected = Object.create(null); anchorId = null; renderBody(); fireSel(); }
|
||||||
|
|
||||||
|
|
@ -75,15 +84,12 @@
|
||||||
container.textContent = '';
|
container.textContent = '';
|
||||||
container.classList.add('seltable');
|
container.classList.add('seltable');
|
||||||
var bar = elt('div', 'seltable__bar');
|
var bar = elt('div', 'seltable__bar');
|
||||||
var filterEl = elt('input', 'seltable__filter'); filterEl.type = 'search';
|
|
||||||
filterEl.placeholder = opts.filterPlaceholder || 'Filter…'; filterEl.spellcheck = false;
|
|
||||||
filterEl.addEventListener('input', function () { setFilter(this.value); });
|
|
||||||
var allBtn = elt('button', 'btn btn-sm btn-secondary', 'Select filtered');
|
var allBtn = elt('button', 'btn btn-sm btn-secondary', 'Select filtered');
|
||||||
allBtn.addEventListener('click', selectAllFiltered);
|
allBtn.addEventListener('click', selectAllFiltered);
|
||||||
var clrBtn = elt('button', 'btn btn-sm btn-secondary', 'Clear');
|
var clrBtn = elt('button', 'btn btn-sm btn-secondary', 'Clear');
|
||||||
clrBtn.addEventListener('click', clearSel);
|
clrBtn.addEventListener('click', clearSel);
|
||||||
countEl = elt('span', 'seltable__count');
|
countEl = elt('span', 'seltable__count');
|
||||||
bar.appendChild(filterEl); bar.appendChild(allBtn); bar.appendChild(clrBtn); bar.appendChild(countEl);
|
bar.appendChild(allBtn); bar.appendChild(clrBtn); bar.appendChild(countEl);
|
||||||
container.appendChild(bar);
|
container.appendChild(bar);
|
||||||
|
|
||||||
var scroll = elt('div', 'seltable__scroll');
|
var scroll = elt('div', 'seltable__scroll');
|
||||||
|
|
@ -91,7 +97,22 @@
|
||||||
var thead = elt('thead'), htr = elt('tr');
|
var thead = elt('thead'), htr = elt('tr');
|
||||||
columns.forEach(function (c) { htr.appendChild(elt('th', c.cls || null, c.title || c.key)); });
|
columns.forEach(function (c) { htr.appendChild(elt('th', c.cls || null, c.title || c.key)); });
|
||||||
if (opts.rowExtra) htr.appendChild(elt('th', 'seltable__extrah', opts.extraTitle || ''));
|
if (opts.rowExtra) htr.appendChild(elt('th', 'seltable__extrah', opts.extraTitle || ''));
|
||||||
thead.appendChild(htr); table.appendChild(thead);
|
thead.appendChild(htr);
|
||||||
|
// Per-column autofilter row.
|
||||||
|
var ftr = elt('tr', 'seltable__filters');
|
||||||
|
columns.forEach(function (c) {
|
||||||
|
var th = elt('th');
|
||||||
|
if (c.filterable !== false) {
|
||||||
|
var inp = elt('input', 'seltable__colfilter'); inp.type = 'search'; inp.placeholder = 'filter…'; inp.spellcheck = false;
|
||||||
|
inp.setAttribute('data-no-select', '');
|
||||||
|
inp.addEventListener('input', function () { setColFilter(c.key, this.value); });
|
||||||
|
th.appendChild(inp);
|
||||||
|
}
|
||||||
|
ftr.appendChild(th);
|
||||||
|
});
|
||||||
|
if (opts.rowExtra) ftr.appendChild(elt('th'));
|
||||||
|
thead.appendChild(ftr);
|
||||||
|
table.appendChild(thead);
|
||||||
bodyEl = elt('tbody'); table.appendChild(bodyEl);
|
bodyEl = elt('tbody'); table.appendChild(bodyEl);
|
||||||
scroll.appendChild(table); container.appendChild(scroll);
|
scroll.appendChild(table); container.appendChild(scroll);
|
||||||
|
|
||||||
|
|
@ -111,7 +132,7 @@
|
||||||
var tr = elt('tr', 'seltable__row' + (selected[id] ? ' is-selected' : ''));
|
var tr = elt('tr', 'seltable__row' + (selected[id] ? ' is-selected' : ''));
|
||||||
tr.dataset.id = id;
|
tr.dataset.id = id;
|
||||||
tr.addEventListener('click', function (e) {
|
tr.addEventListener('click', function (e) {
|
||||||
if (e.target.closest('input,button,select,a,[data-no-select]')) return; // let controls work
|
if (e.target.closest('input,button,select,a,[data-no-select]')) return;
|
||||||
onRowClick(e, row, fr);
|
onRowClick(e, row, fr);
|
||||||
});
|
});
|
||||||
if (opts.onRowDrop) {
|
if (opts.onRowDrop) {
|
||||||
|
|
@ -137,15 +158,14 @@
|
||||||
});
|
});
|
||||||
if (countEl) {
|
if (countEl) {
|
||||||
var nSel = getSelection().length;
|
var nSel = getSelection().length;
|
||||||
countEl.textContent = fr.length + ' shown' + (nSel ? (' · ' + nSel + ' selected') : '');
|
countEl.textContent = fr.length + ' shown' + (nSel ? ' · ' + nSel + ' selected' : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
render: render, renderBody: renderBody,
|
render: render, renderBody: renderBody,
|
||||||
getSelection: getSelection, getFilteredRows: getFilteredRows,
|
getSelection: getSelection, getFilteredRows: getFilteredRows,
|
||||||
setFilter: setFilter, selectAllFiltered: selectAllFiltered, clear: clearSel,
|
setFilter: setFilter, setColFilter: setColFilter, selectAllFiltered: selectAllFiltered, clear: clearSel,
|
||||||
// test seam: simulate a row click with modifier keys.
|
|
||||||
clickRow: function (id, mods) {
|
clickRow: function (id, mods) {
|
||||||
var fr = filtered();
|
var fr = filtered();
|
||||||
var row = fr.filter(function (r) { return String(rowId(r)) === String(id); })[0];
|
var row = fr.filter(function (r) { return String(rowId(r)) === String(id); })[0];
|
||||||
|
|
@ -25,6 +25,7 @@ concat_files \
|
||||||
"../shared/profile-menu.css" \
|
"../shared/profile-menu.css" \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"../shared/context-menu.css" \
|
"../shared/context-menu.css" \
|
||||||
|
"../shared/seltable.css" \
|
||||||
"css/table.css" \
|
"css/table.css" \
|
||||||
"../form/css/form.css" \
|
"../form/css/form.css" \
|
||||||
> "$css_temp"
|
> "$css_temp"
|
||||||
|
|
@ -46,6 +47,7 @@ concat_files \
|
||||||
"../shared/profile-menu.js" \
|
"../shared/profile-menu.js" \
|
||||||
"../shared/cap.js" \
|
"../shared/cap.js" \
|
||||||
"../shared/context-menu.js" \
|
"../shared/context-menu.js" \
|
||||||
|
"../shared/seltable.js" \
|
||||||
"js/mode.js" \
|
"js/mode.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
"js/context.js" \
|
"js/context.js" \
|
||||||
|
|
@ -61,6 +63,7 @@ concat_files \
|
||||||
"js/export.js" \
|
"js/export.js" \
|
||||||
"js/render.js" \
|
"js/render.js" \
|
||||||
"js/api-actions.js" \
|
"js/api-actions.js" \
|
||||||
|
"js/mdl-from-archive.js" \
|
||||||
"js/main.js" \
|
"js/main.js" \
|
||||||
"../form/js/app.js" \
|
"../form/js/app.js" \
|
||||||
"../form/js/context.js" \
|
"../form/js/context.js" \
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Add from archive" — shown only on the project MDL rollup (own gating).
|
||||||
|
if (app.modules.mdlFromArchive && app.modules.mdlFromArchive.setup) {
|
||||||
|
app.modules.mdlFromArchive.setup(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||||
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
||||||
|
|
||||||
|
|
|
||||||
184
tables/js/mdl-from-archive.js
Normal file
184
tables/js/mdl-from-archive.js
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
// mdl-from-archive.js — "Add from archive" for the project MDL rollup.
|
||||||
|
//
|
||||||
|
// The MDL owns the workflow of registering deliverables; this is the catch-up
|
||||||
|
// path. On the project rollup (<project>/mdl/), walk the project archive into a
|
||||||
|
// shared seltable (autofilter + ctrl-shift selection), dedupe the selection to
|
||||||
|
// one deliverable per tracking number, and PUT a deliverable .yaml into each
|
||||||
|
// originator's archive/<originator>/mdl/. The body's identity fields are split
|
||||||
|
// from the tracking number positionally per the project's own table columns
|
||||||
|
// (originator is folder-pinned, so omitted); the server composes/validates the
|
||||||
|
// filename. Server-only.
|
||||||
|
(function (app) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function T(m, l, o) { if (window.zddc && window.zddc.toast) window.zddc.toast(m, l, o); }
|
||||||
|
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
||||||
|
function ctxObj() { return (app && app.context) || {}; }
|
||||||
|
|
||||||
|
// The tracking-number identity fields, in order, from the table columns:
|
||||||
|
// everything between `originator` and `title` (e.g. phase, project, area,
|
||||||
|
// discipline, type, sequence, suffix). originator is folder-pinned.
|
||||||
|
function identityFields() {
|
||||||
|
var cols = (ctxObj().columns || []).map(function (c) { return c && c.field; }).filter(Boolean);
|
||||||
|
var oi = cols.indexOf('originator'), ti = cols.indexOf('title');
|
||||||
|
return cols.slice(oi >= 0 ? oi + 1 : 0, ti >= 0 ? ti : cols.length);
|
||||||
|
}
|
||||||
|
// tracking → { tracking, originator, body{identity fields + title} }, or null
|
||||||
|
// if it can't supply the originator + at least one identity segment.
|
||||||
|
function deliverableFromFile(f, idFields) {
|
||||||
|
var segs = String(f.tracking || '').split('-');
|
||||||
|
if (segs.length < 2) return null;
|
||||||
|
var rest = segs.slice(1), body = {};
|
||||||
|
idFields.forEach(function (name, i) { if (rest[i] != null && rest[i] !== '') body[name] = rest[i]; });
|
||||||
|
if (!Object.keys(body).length) return null;
|
||||||
|
body.title = f.title || '';
|
||||||
|
return { tracking: f.tracking, originator: segs[0], body: body };
|
||||||
|
}
|
||||||
|
function dedupe(files, idFields) {
|
||||||
|
var seen = Object.create(null), out = [];
|
||||||
|
(files || []).forEach(function (f) {
|
||||||
|
if (seen[f.tracking]) return;
|
||||||
|
var d = deliverableFromFile(f, idFields);
|
||||||
|
if (d) { seen[f.tracking] = true; out.push(d); }
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walkArchive(rootHandle) {
|
||||||
|
var out = [];
|
||||||
|
async function walk(dirH, parts) {
|
||||||
|
for await (var entry of dirH.values()) {
|
||||||
|
var nm = String(entry.name || '').replace(/\/$/, '');
|
||||||
|
if (entry.kind === 'directory') {
|
||||||
|
var c = nm.charAt(0);
|
||||||
|
if (c === '.' || c === '_' || nm === 'mdl' || nm === 'rsk') continue;
|
||||||
|
await walk(await dirH.getDirectoryHandle(nm), parts.concat(nm));
|
||||||
|
} else {
|
||||||
|
var p = window.zddc.parseFilename(nm);
|
||||||
|
if (p && p.valid && p.trackingNumber) {
|
||||||
|
out.push({
|
||||||
|
id: parts.concat(nm).join('/'),
|
||||||
|
party: parts[0] || '', slot: parts[1] || '', transmittal: parts[2] || '',
|
||||||
|
tracking: p.trackingNumber, revision: p.revision, status: p.status, title: p.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(rootHandle, []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
async function instantiateOne(archiveRoot, d) {
|
||||||
|
var dir = await archiveRoot.getDirectoryHandle(d.originator, { create: true });
|
||||||
|
dir = await dir.getDirectoryHandle('mdl', { create: true });
|
||||||
|
var fname = d.tracking + '.yaml';
|
||||||
|
try { await dir.getFileHandle(fname); return 'skipped'; } catch (e) { /* NotFound → create */ }
|
||||||
|
var fh = await dir.getFileHandle(fname, { create: true });
|
||||||
|
var w = await fh.createWritable();
|
||||||
|
await w.write(new Blob([window.jsyaml.dump(d.body)], { type: 'application/yaml' }));
|
||||||
|
await w.close();
|
||||||
|
return 'created';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI ───────────────────────────────────────────────────────────────────
|
||||||
|
var overlay = null, statusEl = null, table = null, files = [], archiveRoot = null;
|
||||||
|
function close() { if (overlay) { overlay.remove(); overlay = null; table = null; } }
|
||||||
|
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
|
||||||
|
|
||||||
|
function archiveBaseUrl() {
|
||||||
|
var proj = (location.pathname || '/').replace(/\/mdl\/.*$/, '/'); // <project>/
|
||||||
|
return location.origin + proj + 'archive/';
|
||||||
|
}
|
||||||
|
async function open() {
|
||||||
|
var src = window.zddc && window.zddc.source;
|
||||||
|
if (!src || (location.protocol !== 'http:' && location.protocol !== 'https:')) {
|
||||||
|
T('Adding from the archive needs the tables page served by a zddc-server.', 'error'); return;
|
||||||
|
}
|
||||||
|
buildOverlay();
|
||||||
|
try {
|
||||||
|
archiveRoot = new src.HttpDirectoryHandle(archiveBaseUrl(), 'archive');
|
||||||
|
setStatus('Scanning archive…');
|
||||||
|
files = await walkArchive(archiveRoot);
|
||||||
|
table.renderBody();
|
||||||
|
setStatus(files.length + ' document file' + (files.length === 1 ? '' : 's') + ' found. Filter + ctrl-shift select, then “Create deliverables”.');
|
||||||
|
} catch (e) { setStatus('Archive scan failed — ' + (e.message || e)); T('Archive scan failed — ' + (e.message || e), 'error'); }
|
||||||
|
}
|
||||||
|
function buildOverlay() {
|
||||||
|
close();
|
||||||
|
overlay = el('div', 'mdlarch-overlay');
|
||||||
|
var box = el('div', 'mdlarch-overlay__box');
|
||||||
|
var head = el('div', 'mdlarch-overlay__head');
|
||||||
|
head.appendChild(el('h2', null, 'Add deliverables from archive'));
|
||||||
|
var x = el('button', 'mdlarch-overlay__close', '×'); x.title = 'Close'; x.addEventListener('click', close);
|
||||||
|
head.appendChild(x); box.appendChild(head);
|
||||||
|
statusEl = el('div', 'mdlarch-overlay__status', 'Scanning archive…'); box.appendChild(statusEl);
|
||||||
|
var host = el('div', 'mdlarch-overlay__table'); box.appendChild(host);
|
||||||
|
var foot = el('div', 'mdlarch-overlay__foot');
|
||||||
|
var create = el('button', 'btn btn-primary', 'Create deliverables');
|
||||||
|
create.addEventListener('click', function () { runCreate(create); });
|
||||||
|
var cancel = el('button', 'btn btn-secondary', 'Close'); cancel.addEventListener('click', close);
|
||||||
|
foot.appendChild(create); foot.appendChild(cancel); box.appendChild(foot);
|
||||||
|
overlay.appendChild(box); document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
table = window.app.modules.seltable.create({
|
||||||
|
container: host,
|
||||||
|
extraTitle: '',
|
||||||
|
rows: function () { return files; },
|
||||||
|
rowId: function (r) { return r.id; },
|
||||||
|
columns: [
|
||||||
|
{ key: 'party', title: 'Party' },
|
||||||
|
{ key: 'slot', title: 'Slot' },
|
||||||
|
{ key: 'transmittal', title: 'Transmittal' },
|
||||||
|
{ key: 'tracking', title: 'Tracking number' },
|
||||||
|
{ key: 'revision', title: 'Rev', get: function (r) { return r.revision + (r.status ? ' (' + r.status + ')' : ''); } },
|
||||||
|
{ key: 'title', title: 'Title' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
table.render();
|
||||||
|
}
|
||||||
|
async function runCreate(btn) {
|
||||||
|
if (!table) return;
|
||||||
|
var sel = table.getSelection();
|
||||||
|
if (!sel.length) { T('Select some archive files first (filter + ctrl-shift).', 'warning'); return; }
|
||||||
|
var picked = {}; sel.forEach(function (i) { picked[i] = true; });
|
||||||
|
var deliverables = dedupe(files.filter(function (f) { return picked[f.id]; }), identityFields());
|
||||||
|
if (!deliverables.length) { T('None of the selected files split into deliverable fields.', 'warning'); return; }
|
||||||
|
if (!confirm('Create ' + deliverables.length + ' deliverable(s) in the project MDL?\n\nOne .yaml per tracking number, in archive/<originator>/mdl/. Already-present ones are skipped.')) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
var s = { created: 0, skipped: 0, errors: 0 };
|
||||||
|
for (var i = 0; i < deliverables.length; i++) {
|
||||||
|
setStatus('Creating ' + (i + 1) + '/' + deliverables.length + ' — ' + deliverables[i].tracking);
|
||||||
|
try { s[await instantiateOne(archiveRoot, deliverables[i])]++; }
|
||||||
|
catch (e) { s.errors++; T('Failed to create ' + deliverables[i].tracking + ' — ' + (e.message || e), 'error'); }
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
setStatus(s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '.');
|
||||||
|
T('MDL: ' + s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '. Reload to see them.', s.errors ? 'warning' : 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the toolbar button only on the project MDL rollup (addable:false +
|
||||||
|
// an mdl path), over http, gated on create permission. Called from main.js
|
||||||
|
// init once the context is known.
|
||||||
|
function setup(ctx) {
|
||||||
|
var btn = document.getElementById('table-add-from-archive');
|
||||||
|
if (!btn) return;
|
||||||
|
var onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
||||||
|
var isMdlRollup = ctx && ctx.addable === false && /\/mdl\/(table\.html)?$/.test(location.pathname || '');
|
||||||
|
if (!(onHttp && isMdlRollup)) return;
|
||||||
|
btn.hidden = false;
|
||||||
|
btn.addEventListener('click', open);
|
||||||
|
if (window.zddc && window.zddc.cap) {
|
||||||
|
window.zddc.cap.at(archiveBaseUrl().replace(location.origin, '')).then(function (view) {
|
||||||
|
var verbs = (view && view.path_verbs) || '';
|
||||||
|
if (verbs.indexOf('c') === -1) { btn.classList.add('is-disabled'); btn.title = "You don't have create access in this project's archive."; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.modules.mdlFromArchive = {
|
||||||
|
setup: setup, open: open,
|
||||||
|
// test seams
|
||||||
|
identityFields: identityFields, deliverableFromFile: deliverableFromFile,
|
||||||
|
dedupe: dedupe, walkArchive: walkArchive, instantiateOne: instantiateOne,
|
||||||
|
};
|
||||||
|
})(window.tablesApp);
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
<div class="table-toolbar__right">
|
<div class="table-toolbar__right">
|
||||||
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
|
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
|
||||||
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
|
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
|
||||||
|
<button type="button" id="table-add-from-archive" class="btn btn-secondary btn-sm" hidden title="Register deliverables from existing archive files (project MDL rollup)">+ From archive</button>
|
||||||
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1216,68 +1216,6 @@ test('seltable: autofilter + ctrl-shift selection builds complex sets', async ({
|
||||||
expect(r.ctrlShiftRange).toBe('c,d'); // ctrl-shift range runs over the FILTERED order
|
expect(r.ctrlShiftRange).toBe('c,d'); // ctrl-shift range runs over the FILTERED order
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mdl-instantiate: walks the archive subtree and dedupes to one deliverable per tracking number', async ({ page }) => {
|
|
||||||
const r = await page.evaluate(async () => {
|
|
||||||
const M = window.app.modules.mdlInstantiate;
|
|
||||||
const file = (name) => ({ kind: 'file', name });
|
|
||||||
const dir = (name, dirs, files) => ({
|
|
||||||
kind: 'directory', name,
|
|
||||||
getDirectoryHandle: async (n) => dirs[n],
|
|
||||||
values: async function* () { for (const d of Object.values(dirs)) yield d; for (const f of files) yield f; },
|
|
||||||
});
|
|
||||||
const T1 = dir('T1', {}, [
|
|
||||||
file('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'),
|
|
||||||
file('ACM-PRJ-EL-SPC-0001_B (IFC) - Spec.pdf'), // 2nd revision of same deliverable
|
|
||||||
file('ACM-PRJ-ME-DWG-0003_0 (IFC) - Plan.pdf'),
|
|
||||||
file('notes.txt'), // non-ZDDC → ignored
|
|
||||||
]);
|
|
||||||
const issued = dir('issued', { T1 }, []);
|
|
||||||
const mdl = dir('mdl', {}, [file('ACM-PRJ-EL-SPC-0001.yaml')]); // mdl/ skipped
|
|
||||||
const root = dir('archive', { ACM: dir('ACM', { issued, mdl }, []) }, []);
|
|
||||||
const files = await M.walkArchive(root);
|
|
||||||
const dd = M.dedupe(files);
|
|
||||||
const spc = dd.find((d) => d.tracking === 'ACM-PRJ-EL-SPC-0001');
|
|
||||||
return {
|
|
||||||
count: files.length, party: files[0].party, slot: files[0].slot, transmittal: files[0].transmittal,
|
|
||||||
deliverables: dd.map((d) => d.tracking).sort(),
|
|
||||||
originator: spc.originator, body: spc.body, hasOriginator: 'originator' in spc.body,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
expect(r.count).toBe(3); // 2 SPC revisions + 1 DWG (txt + the mdl yaml ignored)
|
|
||||||
expect(r.party).toBe('ACM');
|
|
||||||
expect(r.slot).toBe('issued');
|
|
||||||
expect(r.transmittal).toBe('T1');
|
|
||||||
expect(r.deliverables).toEqual(['ACM-PRJ-EL-SPC-0001', 'ACM-PRJ-ME-DWG-0003']); // deduped by tracking number
|
|
||||||
expect(r.originator).toBe('ACM');
|
|
||||||
expect(r.body).toEqual({ project: 'PRJ', discipline: 'EL', type: 'SPC', sequence: '0001', title: 'Spec' });
|
|
||||||
expect(r.hasOriginator).toBe(false); // server pins originator from the folder
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mdl-instantiate: writes the deliverable yaml then skips an existing one', async ({ page }) => {
|
|
||||||
const r = await page.evaluate(async () => {
|
|
||||||
const M = window.app.modules.mdlInstantiate;
|
|
||||||
const store = {};
|
|
||||||
const mkdir = (base) => ({
|
|
||||||
getDirectoryHandle: async (n) => mkdir(base + n + '/'),
|
|
||||||
getFileHandle: async (n, opts) => {
|
|
||||||
const full = base + n;
|
|
||||||
if ((!opts || !opts.create) && !(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; }
|
|
||||||
return { createWritable: async () => ({ write: async (b) => { store[full] = await b.text(); }, close: async () => {} }) };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const d = { tracking: 'ACM-PRJ-EL-SPC-0001', originator: 'ACM', body: { project: 'PRJ', discipline: 'EL', type: 'SPC', sequence: '0001', title: 'Spec' } };
|
|
||||||
const first = await M.instantiateOne(mkdir(''), d);
|
|
||||||
const root = mkdir(''); // fresh facade over the same store
|
|
||||||
const second = await M.instantiateOne(root, d);
|
|
||||||
const path = Object.keys(store)[0];
|
|
||||||
return { first, second, path, parsed: window.jsyaml.load(store[path]) };
|
|
||||||
});
|
|
||||||
expect(r.first).toBe('created');
|
|
||||||
expect(r.second).toBe('skipped'); // already present → resumable
|
|
||||||
expect(r.path).toBe('ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml');
|
|
||||||
expect(r.parsed).toEqual({ project: 'PRJ', discipline: 'EL', type: 'SPC', sequence: '0001', title: 'Spec' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('classify: an MDL placement names a file; revision from the cell, transmittal for the path', async ({ page }) => {
|
test('classify: an MDL placement names a file; revision from the cell, transmittal for the path', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
|
|
@ -1311,7 +1249,7 @@ test('classify: an MDL placement names a file; revision from the cell, transmitt
|
||||||
expect(r.fileTitleName).toContain('messy scan 47'); // title toggle → the file's own title
|
expect(r.fileTitleName).toContain('messy scan 47'); // title toggle → the file's own title
|
||||||
});
|
});
|
||||||
|
|
||||||
test('By MDL tab: drop a file on a deliverable row names it; bulk revision applies', async ({ page }) => {
|
test('Catalog: shows archive revs, drop on a row names the file, bulk revision applies', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||||
|
|
@ -1319,20 +1257,22 @@ test('By MDL tab: drop a file on a deliverable row names it; bulk revision appli
|
||||||
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
|
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
|
||||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||||
const key = c.srcKeyForFile(f);
|
const key = c.srcKeyForFile(f);
|
||||||
|
// Catalog rows = MDL ∪ archive merged: one MDL+archive row, one archive-only.
|
||||||
c.setMdlList([
|
c.setMdlList([
|
||||||
{ id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec' },
|
{ id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'] },
|
||||||
{ id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2' },
|
{ id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', inMdl: false, archiveRevisions: ['0 (IFC)'] },
|
||||||
]);
|
]);
|
||||||
tt.showTab('mdl'); tt.render();
|
tt.render(); // builds the catalog seltable into #mdlTree
|
||||||
const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]');
|
const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]');
|
||||||
const hasRow = !!row;
|
const archiveRevsShown = !!row && row.textContent.includes('A (IFR)') && row.textContent.includes('B (IFC)');
|
||||||
window.app.modules.dnd.setDrag([key]);
|
window.app.modules.dnd.setDrag([key]);
|
||||||
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop the file on m1
|
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop the file on m1
|
||||||
const placed = (c.getAssignment(key) || {}).mdlNodeId;
|
const placed = (c.getAssignment(key) || {}).mdlNodeId;
|
||||||
c.setRevisionCells(['m1', 'm2'], 'A (IFR)'); // ctrl-enter bulk path
|
c.setRevisionCells(['m1', 'm2'], 'A (IFR)'); // ctrl-Enter bulk path
|
||||||
return { hasRow, placed, named: c.deriveTarget(f).filename };
|
return { hasRow: !!row, archiveRevsShown, placed, named: c.deriveTarget(f).filename };
|
||||||
});
|
});
|
||||||
expect(r.hasRow).toBe(true);
|
expect(r.hasRow).toBe(true);
|
||||||
expect(r.placed).toBe('m1'); // drop placed the file on the deliverable
|
expect(r.archiveRevsShown).toBe(true); // merged archive revisions shown (informational)
|
||||||
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // bulk-set revision feeds the name
|
expect(r.placed).toBe('m1'); // drop = tracking number only
|
||||||
|
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // revision from the bulk-set column feeds the name
|
||||||
});
|
});
|
||||||
|
|
|
||||||
194
tests/tables-mdl.spec.js
Normal file
194
tests/tables-mdl.spec.js
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// "Add from archive" for the tables tool's project MDL rollup. The page is
|
||||||
|
// loaded offline (file://) with an injected #table-context whose columns drive
|
||||||
|
// how a tracking number splits into deliverable fields. The walk / dedupe /
|
||||||
|
// instantiate logic is exercised against in-page mock FS-Access handles — no
|
||||||
|
// server needed.
|
||||||
|
|
||||||
|
const HTML_PATH = path.resolve('tables/dist/tables.html');
|
||||||
|
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
|
||||||
|
|
||||||
|
// originator … identity fields … title (originator is folder-pinned → omitted
|
||||||
|
// from the body; everything between originator and title is the tracking split).
|
||||||
|
const MDL_COLUMNS = [
|
||||||
|
{ field: 'originator', title: 'Orig' },
|
||||||
|
{ field: 'phase', title: 'Phase' },
|
||||||
|
{ field: 'project', title: 'Project' },
|
||||||
|
{ field: 'area', title: 'Area' },
|
||||||
|
{ field: 'discipline', title: 'Disc' },
|
||||||
|
{ field: 'type', title: 'Type' },
|
||||||
|
{ field: 'sequence', title: 'Seq' },
|
||||||
|
{ field: 'suffix', title: 'Suffix' },
|
||||||
|
{ field: 'title', title: 'Deliverable' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadRollup(page) {
|
||||||
|
const ctx = { title: 'MDL', columns: MDL_COLUMNS, rows: [], addable: false };
|
||||||
|
const ctxJson = JSON.stringify(ctx).replace(/<\//g, '<\\/');
|
||||||
|
const patched = HTML_RAW.replace(
|
||||||
|
/<script id="table-context" type="application\/json">[\s\S]*?<\/script>/,
|
||||||
|
`<script id="table-context" type="application/json">${ctxJson}</script>`,
|
||||||
|
);
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tables-mdl-'));
|
||||||
|
const tmpPath = path.join(tmpDir, 'tables.html');
|
||||||
|
fs.writeFileSync(tmpPath, patched);
|
||||||
|
await page.goto(`file://${tmpPath}`, { waitUntil: 'load' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.tablesApp && window.tablesApp.modules && window.tablesApp.modules.mdlFromArchive,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('tables/ — Add deliverables from archive', () => {
|
||||||
|
test('identityFields() = columns between originator and title', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const fields = await page.evaluate(() => window.tablesApp.modules.mdlFromArchive.identityFields());
|
||||||
|
expect(fields).toEqual(['phase', 'project', 'area', 'discipline', 'type', 'sequence', 'suffix']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deliverableFromFile splits the tracking number, omits originator, keeps title', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const d = await page.evaluate(() => {
|
||||||
|
const m = window.tablesApp.modules.mdlFromArchive;
|
||||||
|
return m.deliverableFromFile(
|
||||||
|
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001-X', title: 'Foundation Plan' },
|
||||||
|
m.identityFields(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(d.originator).toBe('ACME');
|
||||||
|
expect(d.tracking).toBe('ACME-DD-PRJ-A1-CIV-DWG-001-X');
|
||||||
|
expect(d.body).toEqual({
|
||||||
|
phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV',
|
||||||
|
type: 'DWG', sequence: '001', suffix: 'X', title: 'Foundation Plan',
|
||||||
|
});
|
||||||
|
// originator must NOT be in the body (server pins it from the folder).
|
||||||
|
expect(d.body.originator).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a shorter tracking number leaves trailing identity fields unset', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const d = await page.evaluate(() => {
|
||||||
|
const m = window.tablesApp.modules.mdlFromArchive;
|
||||||
|
// no suffix segment
|
||||||
|
return m.deliverableFromFile({ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: '' }, m.identityFields());
|
||||||
|
});
|
||||||
|
expect(d.body.sequence).toBe('001');
|
||||||
|
expect('suffix' in d.body).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dedupe collapses duplicate tracking numbers, dropping unsplittable rows', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const out = await page.evaluate(() => {
|
||||||
|
const m = window.tablesApp.modules.mdlFromArchive;
|
||||||
|
return m.dedupe([
|
||||||
|
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: 'a' },
|
||||||
|
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', title: 'a-dup' },
|
||||||
|
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-002', title: 'b' },
|
||||||
|
{ tracking: 'NOPE', title: 'too short' },
|
||||||
|
], m.identityFields());
|
||||||
|
});
|
||||||
|
expect(out.map(d => d.tracking)).toEqual([
|
||||||
|
'ACME-DD-PRJ-A1-CIV-DWG-001', 'ACME-DD-PRJ-A1-CIV-DWG-002',
|
||||||
|
]);
|
||||||
|
expect(out[0].body.title).toBe('a'); // first wins
|
||||||
|
});
|
||||||
|
|
||||||
|
test('walkArchive collects valid document files, skipping mdl/rsk/dot/underscore dirs', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const files = await page.evaluate(async () => {
|
||||||
|
// Mock FS-Access directory handles.
|
||||||
|
function dir(name, entries) {
|
||||||
|
return {
|
||||||
|
name, kind: 'directory', _entries: entries,
|
||||||
|
async *values() { for (const e of entries) yield e; },
|
||||||
|
async getDirectoryHandle(n) {
|
||||||
|
const e = entries.find(x => x.name === n && x.kind === 'directory');
|
||||||
|
if (!e) throw new DOMException('not found', 'NotFoundError');
|
||||||
|
return e;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const file = name => ({ name, kind: 'file' });
|
||||||
|
const root = dir('archive', [
|
||||||
|
dir('Acme', [
|
||||||
|
dir('issued', [
|
||||||
|
dir('2026-05-01_ACME-DD-PRJ-A1-CIV-DWG-001 (IFR) - Plan', [
|
||||||
|
file('ACME-DD-PRJ-A1-CIV-DWG-001_B (IFR) - Foundation Plan.pdf'),
|
||||||
|
file('not-a-zddc-file.txt'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
dir('mdl', [ file('ACME-DD-PRJ-A1-CIV-DWG-001.yaml') ]), // skipped
|
||||||
|
dir('rsk', [ file('whatever_A (IFA) - x.pdf') ]), // skipped
|
||||||
|
]),
|
||||||
|
dir('_system', [ file('ACME-DD-PRJ-A1-CIV-DWG-999_A (IFA) - hidden.pdf') ]), // skipped
|
||||||
|
]);
|
||||||
|
const out = await window.tablesApp.modules.mdlFromArchive.walkArchive(root);
|
||||||
|
return out.map(f => ({ tracking: f.tracking, party: f.party, slot: f.slot, rev: f.revision, title: f.title }));
|
||||||
|
});
|
||||||
|
expect(files).toEqual([
|
||||||
|
{ tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', party: 'Acme', slot: 'issued', rev: 'B', title: 'Foundation Plan' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('instantiateOne writes a yaml on create, skips when it already exists', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const writes = [];
|
||||||
|
function fileHandle(name, exists) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
async createWritable() {
|
||||||
|
return {
|
||||||
|
async write(blob) { writes.push({ name, text: await blob.text() }); },
|
||||||
|
async close() {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
_exists: exists,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function mdlDir() {
|
||||||
|
const present = {}; // tracking.yaml already there
|
||||||
|
present['ACME-DD-PRJ-A1-CIV-DWG-002.yaml'] = true;
|
||||||
|
return {
|
||||||
|
async getFileHandle(n, opts) {
|
||||||
|
if (opts && opts.create) return fileHandle(n, false);
|
||||||
|
if (present[n]) return fileHandle(n, true);
|
||||||
|
throw new DOMException('nf', 'NotFoundError');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function originatorDir() {
|
||||||
|
return { async getDirectoryHandle() { return mdlDir(); } };
|
||||||
|
}
|
||||||
|
const archiveRoot = { async getDirectoryHandle() { return originatorDir(); } };
|
||||||
|
const m = window.tablesApp.modules.mdlFromArchive;
|
||||||
|
const created = await m.instantiateOne(archiveRoot, {
|
||||||
|
tracking: 'ACME-DD-PRJ-A1-CIV-DWG-001', originator: 'ACME',
|
||||||
|
body: { phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV', type: 'DWG', sequence: '001', title: 'Plan' },
|
||||||
|
});
|
||||||
|
const skipped = await m.instantiateOne(archiveRoot, {
|
||||||
|
tracking: 'ACME-DD-PRJ-A1-CIV-DWG-002', originator: 'ACME',
|
||||||
|
body: { phase: 'DD', project: 'PRJ', area: 'A1', discipline: 'CIV', type: 'DWG', sequence: '002', title: 'Plan2' },
|
||||||
|
});
|
||||||
|
return { created, skipped, writes };
|
||||||
|
});
|
||||||
|
expect(result.created).toBe('created');
|
||||||
|
expect(result.skipped).toBe('skipped');
|
||||||
|
expect(result.writes.length).toBe(1);
|
||||||
|
expect(result.writes[0].name).toBe('ACME-DD-PRJ-A1-CIV-DWG-001.yaml');
|
||||||
|
expect(result.writes[0].text).toContain('title: Plan');
|
||||||
|
expect(result.writes[0].text).toContain('discipline: CIV');
|
||||||
|
// originator must not be serialized into the body
|
||||||
|
expect(result.writes[0].text).not.toContain('originator:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the "From archive" button stays hidden when not on an /mdl/ rollup path', async ({ page }) => {
|
||||||
|
await loadRollup(page);
|
||||||
|
// file:// path is not /mdl/, so setup() must not reveal the button.
|
||||||
|
const hidden = await page.evaluate(() => document.getElementById('table-add-from-archive').hidden);
|
||||||
|
expect(hidden).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue