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>
184 lines
10 KiB
JavaScript
184 lines
10 KiB
JavaScript
// 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);
|