ZDDC/tables/js/mdl-from-archive.js
ZDDC 95c9e42270 feat(tables): "Add from archive" on the project MDL rollup
The MDL owns the workflow of registering deliverables; this is the
catch-up path for files that already exist in the archive but were never
listed. On the project MDL rollup (<project>/mdl/, addable:false), a new
"+ From archive" toolbar button opens an overlay that walks the project
archive into the shared seltable (per-column autofilter + ctrl-shift
selection), dedupes the selection to one deliverable per tracking number,
and PUTs a deliverable .yaml into each originator's archive/<originator>/
mdl/. Identity fields are split positionally from the tracking number per
the project's own table columns (originator is folder-pinned, so omitted
from the body); the server composes/validates the filename. Existing
deliverables are skipped; created/skipped/failed are reported.

- tables/js/mdl-from-archive.js: walkArchive / dedupe / deliverableFromFile
  / instantiateOne + the overlay UI; setup() shows the button only on an
  /mdl/ rollup over http, gated on archive create permission.
- shared/seltable.css: promoted seltable base styles + per-column filter
  row + the overlay chrome (bundled into tables; classifier keeps its
  inline copy).
- main.js wires setup(ctx); template.html adds the (hidden) button;
  build.sh bundles ../shared/seltable.{js,css} + the new module.
- tests/tables-mdl.spec.js (new project): split/dedupe/walk/instantiate
  against in-page mock FS handles; 7 green. tables suite still 47 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:48:22 -05:00

184 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);