Compare commits

..

No commits in common. "6c3c58bc70ad83062883838459cdd5e0e7404738" and "2bc582fd9e57be123296e3e6ecca22aef93dddd9" have entirely different histories.

18 changed files with 375 additions and 646 deletions

View file

@ -227,20 +227,6 @@ Format: `trackingNumber_revision (status) - title.extension`
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo - 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`.

View file

@ -7,7 +7,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Commit freely** — make commits as appropriate for the work being performed. Each commit should be a coherent, reviewable unit (no WIP/checkpoint noise). The default rule "never commit without explicit ask" does NOT apply in this repo. - **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

View file

@ -56,12 +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/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" \

View file

@ -571,24 +571,8 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
cursor: wait; cursor: wait;
} }
/* ── Catalog overlay (MDL archive; seltable rows = drop targets) ───────── */ /* ── By-MDL panel (seltable rows = deliverable drop targets) ─────────────── */
.target-pane { position: relative; } #mdlPanel .seltable { height: 100%; }
.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;

View file

@ -374,6 +374,8 @@
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 () {

View file

@ -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 },
@ -490,8 +488,6 @@
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 || '',
}; };
}); });

View file

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

View file

@ -1,18 +1,20 @@
/** /**
* ZDDC shared selectable + autofilter table (used by the classifier catalog * ZDDC Classifier shared selectable + autofilter table.
* and the tables tool's "Add from archive").
* *
* A flat table with PER-COLUMN autofilters (one input per column, AND-combined, * A flat table with one global autofilter (AND of space-separated terms over
* each an AND of space-separated terms) plus an optional programmatic global * every column) and powerful selection for building complex sets quickly:
* 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 anchorrow range to the existing selection * ctrl-shift-click ADD the anchorrow 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. Selection is keyed by a stable * Ranges run over the CURRENTLY FILTERED order, so "filter to a transmittal,
* then shift-select the visible block" works. Selection is keyed by a stable
* rowId so it survives filtering and re-render. * 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';
@ -39,28 +41,17 @@
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 globalTerms = []; // programmatic global filter (tests/reveal) var ft = []; // global filter terms
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 rowMatches(row) { function filtered() { return ft.length ? rows().filter(function (r) { return hit(rowBlob(r), ft); }) : rows().slice(); }
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) { globalTerms = terms(q); renderBody(); } function setFilter(q) { ft = 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(); }
@ -84,12 +75,15 @@
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(allBtn); bar.appendChild(clrBtn); bar.appendChild(countEl); bar.appendChild(filterEl); 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');
@ -97,22 +91,7 @@
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); thead.appendChild(htr); table.appendChild(thead);
// 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);
@ -132,7 +111,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; if (e.target.closest('input,button,select,a,[data-no-select]')) return; // let controls work
onRowClick(e, row, fr); onRowClick(e, row, fr);
}); });
if (opts.onRowDrop) { if (opts.onRowDrop) {
@ -158,14 +137,15 @@
}); });
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, setColFilter: setColFilter, selectAllFiltered: selectAllFiltered, clear: clearSel, setFilter: setFilter, selectAllFiltered: selectAllFiltered, clear: clearSel,
// test seam: simulate a row click with modifier keys.
clickRow: function (id, mods) { 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];

View file

@ -19,9 +19,8 @@
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' — the active tab var currentTab = 'tracking'; // 'tracking' | 'transmittal' | 'mdl' — the active axis
var catalogOpen = false; // the Catalog overlay (the 'mdl' axis) is open var mdlTable = null; // the seltable controller for the By-MDL panel
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() {
@ -30,7 +29,7 @@
els = { els = {
trackingTab: document.getElementById('trackingTab'), trackingTab: document.getElementById('trackingTab'),
transmittalTab: document.getElementById('transmittalTab'), transmittalTab: document.getElementById('transmittalTab'),
catalogBtn: document.getElementById('catalogBtn'), mdlTab: document.getElementById('mdlTab'),
trackingPanel: document.getElementById('trackingPanel'), trackingPanel: document.getElementById('trackingPanel'),
transmittalPanel: document.getElementById('transmittalPanel'), transmittalPanel: document.getElementById('transmittalPanel'),
mdlPanel: document.getElementById('mdlPanel'), mdlPanel: document.getElementById('mdlPanel'),
@ -38,7 +37,6 @@
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'),
@ -46,8 +44,7 @@
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.catalogBtn) els.catalogBtn.addEventListener('click', function () { catalogOpen ? closeCatalog() : openCatalog(); }); if (els.mdlTab) els.mdlTab.addEventListener('click', function () { showTab('mdl'); });
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'
@ -101,31 +98,18 @@
} }
function showTab(which) { function showTab(which) {
currentTab = which === 'transmittal' ? 'transmittal' : 'tracking'; currentTab = (which === 'transmittal' || which === 'mdl') ? which : '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();
} }
// The active axis is the catalog ('mdl') while the overlay is open, else the tab. function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : currentTab === 'mdl' ? 'mdl' : 'tracking'; }
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.
@ -490,7 +474,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', 'Nothing loaded yet — “Load…” to pull in the projects MDL deliverables and archive tracking numbers.')); els.mdlTree.appendChild(el('div', 'target-empty', 'No MDL loaded — “Load MDL…” to bring a projects deliverables in as drop targets.'));
return; return;
} }
ensureMdlTable(); ensureMdlTable();
@ -499,35 +483,31 @@
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: cols, columns: [
{ 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 row(s) (e.g. "A (IFR)"):', ''); var v = prompt('Set the revision on ' + ids.length + ' selected deliverable(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); },
@ -558,13 +538,22 @@
}); });
} }
// Load the catalog: the union of the project's MDL deliverables and its // Load deliverables into the MDL table — a local folder of .yaml or a server
// archive tracking numbers, deduped by tracking number. Server reads both; // project (one or more parties). Each yaml's filename stem is the tracking
// a local folder reads just its *.yaml deliverables. Writes/alters nothing — // number; the revision cell starts blank (classifier-local).
// the revision cell is classifier-local and starts blank. function yamlToRow(party, stem, obj) {
function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; } return {
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:') return loadMdlServer(); if (window.zddc && window.zddc.source && location.protocol !== 'file:') {
return loadMdlServer();
}
return loadMdlLocal(); return loadMdlLocal();
} }
async function loadMdlLocal() { async function loadMdlLocal() {
@ -575,11 +564,10 @@
var rows = []; var rows = [];
try { try {
for await (var entry of dir.values()) { for await (var entry of dir.values()) {
var nm = String(entry.name).replace(/\/$/, ''); if (entry.kind !== 'file' || !/\.yaml$/i.test(entry.name) || entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
if (entry.kind !== 'file' || !isRowYaml(nm)) continue; var txt = await (await entry.getFile()).text();
var obj = null; try { obj = window.jsyaml.load(await (await entry.getFile()).text()); } catch (_) { /* skip */ } var obj = null; try { obj = window.jsyaml.load(txt); } catch (_) { /* skip bad yaml */ }
var stem = nm.replace(/\.yaml$/i, ''); rows.push(yamlToRow(dir.name || 'local', entry.name.replace(/\.yaml$/i, ''), obj));
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);
@ -593,63 +581,29 @@
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 byTn = Object.create(null); var rows = [];
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;
for await (var slot of party.values()) { var mdlDir;
if (slot.kind !== 'directory') continue; try { mdlDir = await party.getDirectoryHandle('mdl'); } catch (e) { continue; } // no mdl for this party
var sn = String(slot.name).replace(/\/$/, ''); for await (var entry of mdlDir.values()) {
if (sn.charAt(0) === '.' || sn.charAt(0) === '_' || sn === 'rsk') continue; var nm = String(entry.name).replace(/\/$/, '');
if (sn === 'mdl') { // registered deliverables if (entry.kind !== 'file' || !/\.yaml$/i.test(nm) || nm === 'table.yaml' || nm === 'form.yaml') continue;
for await (var ye of slot.values()) { var obj = null;
var ynm = String(ye.name).replace(/\/$/, ''); try { obj = window.jsyaml.load(await (await entry.getFile()).text()); } catch (_) { /* skip */ }
if (ye.kind !== 'file' || !isRowYaml(ynm)) continue; rows.push(yamlToRow(pn, nm.replace(/\.yaml$/i, ''), obj));
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 failed — ' + (e.message || e), 'error'); return; } } catch (e) { window.zddc.toast('Reading the project MDL 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);
openCatalog(); showTab('mdl');
window.zddc.toast(rows.length 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');
? ('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 ─────────────────────────────────────────────────────────────
@ -854,8 +808,8 @@
var a = C().getAssignment(key); var a = C().getAssignment(key);
if (!a) return; if (!a) return;
if (a.mdlNodeId) { if (a.mdlNodeId) {
openCatalog(); showTab('mdl');
if (mdlTable) { mdlTable.renderBody(); } if (mdlTable) { mdlTable.setFilter(''); 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);

View file

@ -34,6 +34,7 @@
<button id="modeClassifyBtn" class="mode-btn active" title="Map files onto tracking numbers and transmittals, then copy renamed copies to an output directory — the source is never modified">Classify &amp; copy</button> <button id="modeClassifyBtn" class="mode-btn active" title="Map files onto tracking numbers and transmittals, then copy renamed copies to an output directory — the source is never modified">Classify &amp; copy</button>
<button id="modeRenameBtn" class="mode-btn" title="Edit a spreadsheet and rename the files in place (edits the source)">Rename in place</button> <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>
@ -162,7 +163,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 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> <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>
@ -194,20 +195,14 @@
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">&times;</button>
</div>
<div id="mdlTree" class="catalog-overlay__table"></div>
</section>
</main> </main>
</div> </div>

View file

@ -95,10 +95,6 @@ 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',

View file

@ -1,46 +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; }
.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; }

View file

@ -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" \

View file

@ -167,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 : [];

View file

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

View file

@ -43,7 +43,6 @@
<div class="table-toolbar__right"> <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>

View file

@ -1216,6 +1216,68 @@ 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(() => {
@ -1249,7 +1311,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('Catalog: shows archive revs, drop on a row names the file, bulk revision applies', async ({ page }) => { test('By MDL tab: drop a file on a deliverable row names it; 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;
@ -1257,22 +1319,20 @@ test('Catalog: shows archive revs, drop on a row names the file, bulk revision a
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', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'] }, { id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec' },
{ id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', inMdl: false, archiveRevisions: ['0 (IFC)'] }, { id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2' },
]); ]);
tt.render(); // builds the catalog seltable into #mdlTree tt.showTab('mdl'); tt.render();
const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]'); const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]');
const archiveRevsShown = !!row && row.textContent.includes('A (IFR)') && row.textContent.includes('B (IFC)'); const hasRow = !!row;
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: !!row, archiveRevsShown, placed, named: c.deriveTarget(f).filename }; return { hasRow, placed, named: c.deriveTarget(f).filename };
}); });
expect(r.hasRow).toBe(true); expect(r.hasRow).toBe(true);
expect(r.archiveRevsShown).toBe(true); // merged archive revisions shown (informational) expect(r.placed).toBe('m1'); // drop placed the file on the deliverable
expect(r.placed).toBe('m1'); // drop = tracking number only expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // bulk-set revision feeds the name
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // revision from the bulk-set column feeds the name
}); });

View file

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