refactor(classifier): drop "MDL from archive" — it lives in the tables tool now

Instantiating deliverable yamls from existing archive files is an MDL-side
workflow (assigning files to deliverables stays here; registering tracking
numbers belongs with the MDL). It moved to the tables tool's project MDL
rollup in the prior commit, so remove the classifier copy:

- delete classifier/js/mdl-instantiate.js + its build entry
- remove the ⊞ MDL from archive header button + its app.js wiring
- drop the two mdl-instantiate unit tests (the equivalents now live in
  tests/tables-mdl.spec.js)

The read-only Catalog button (MDL ∪ archive, drop-to-assign) is unaffected.
Classifier + classify suites: 58 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-11 15:50:16 -05:00
parent 95c9e42270
commit 6c3c58bc70
5 changed files with 0 additions and 275 deletions

View file

@ -62,7 +62,6 @@ concat_files \
"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

@ -374,8 +374,6 @@
if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); }); if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); });
if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); }); if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); });
if (app.dom.checkDuplicatesBtn) app.dom.checkDuplicatesBtn.addEventListener('click', function () { app.modules.copy.audit(); }); if (app.dom.checkDuplicatesBtn) app.dom.checkDuplicatesBtn.addEventListener('click', function () { app.modules.copy.audit(); });
var mdlBtn = document.getElementById('mdlInstantiateBtn');
if (mdlBtn) mdlBtn.addEventListener('click', function () { app.modules.mdlInstantiate.open(); });
// Live source-tree filter (matches file path + name; reveals the hierarchy). // Live source-tree filter (matches file path + name; reveals the hierarchy).
if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () { if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () {

View file

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

View file

@ -34,7 +34,6 @@
<button id="modeClassifyBtn" class="mode-btn active" title="Map files onto tracking numbers and transmittals, then copy renamed copies to an output directory — the source is never modified">Classify &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>

View file

@ -1216,68 +1216,6 @@ test('seltable: autofilter + ctrl-shift selection builds complex sets', async ({
expect(r.ctrlShiftRange).toBe('c,d'); // ctrl-shift range runs over the FILTERED order expect(r.ctrlShiftRange).toBe('c,d'); // ctrl-shift range runs over the FILTERED order
}); });
test('mdl-instantiate: walks the archive subtree and dedupes to one deliverable per tracking number', async ({ page }) => {
const r = await page.evaluate(async () => {
const M = window.app.modules.mdlInstantiate;
const file = (name) => ({ kind: 'file', name });
const dir = (name, dirs, files) => ({
kind: 'directory', name,
getDirectoryHandle: async (n) => dirs[n],
values: async function* () { for (const d of Object.values(dirs)) yield d; for (const f of files) yield f; },
});
const T1 = dir('T1', {}, [
file('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'),
file('ACM-PRJ-EL-SPC-0001_B (IFC) - Spec.pdf'), // 2nd revision of same deliverable
file('ACM-PRJ-ME-DWG-0003_0 (IFC) - Plan.pdf'),
file('notes.txt'), // non-ZDDC → ignored
]);
const issued = dir('issued', { T1 }, []);
const mdl = dir('mdl', {}, [file('ACM-PRJ-EL-SPC-0001.yaml')]); // mdl/ skipped
const root = dir('archive', { ACM: dir('ACM', { issued, mdl }, []) }, []);
const files = await M.walkArchive(root);
const dd = M.dedupe(files);
const spc = dd.find((d) => d.tracking === 'ACM-PRJ-EL-SPC-0001');
return {
count: files.length, party: files[0].party, slot: files[0].slot, transmittal: files[0].transmittal,
deliverables: dd.map((d) => d.tracking).sort(),
originator: spc.originator, body: spc.body, hasOriginator: 'originator' in spc.body,
};
});
expect(r.count).toBe(3); // 2 SPC revisions + 1 DWG (txt + the mdl yaml ignored)
expect(r.party).toBe('ACM');
expect(r.slot).toBe('issued');
expect(r.transmittal).toBe('T1');
expect(r.deliverables).toEqual(['ACM-PRJ-EL-SPC-0001', 'ACM-PRJ-ME-DWG-0003']); // deduped by tracking number
expect(r.originator).toBe('ACM');
expect(r.body).toEqual({ project: 'PRJ', discipline: 'EL', type: 'SPC', sequence: '0001', title: 'Spec' });
expect(r.hasOriginator).toBe(false); // server pins originator from the folder
});
test('mdl-instantiate: writes the deliverable yaml then skips an existing one', async ({ page }) => {
const r = await page.evaluate(async () => {
const M = window.app.modules.mdlInstantiate;
const store = {};
const mkdir = (base) => ({
getDirectoryHandle: async (n) => mkdir(base + n + '/'),
getFileHandle: async (n, opts) => {
const full = base + n;
if ((!opts || !opts.create) && !(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; }
return { createWritable: async () => ({ write: async (b) => { store[full] = await b.text(); }, close: async () => {} }) };
},
});
const d = { tracking: 'ACM-PRJ-EL-SPC-0001', originator: 'ACM', body: { project: 'PRJ', discipline: 'EL', type: 'SPC', sequence: '0001', title: 'Spec' } };
const first = await M.instantiateOne(mkdir(''), d);
const root = mkdir(''); // fresh facade over the same store
const second = await M.instantiateOne(root, d);
const path = Object.keys(store)[0];
return { first, second, path, parsed: window.jsyaml.load(store[path]) };
});
expect(r.first).toBe('created');
expect(r.second).toBe('skipped'); // already present → resumable
expect(r.path).toBe('ACM/mdl/ACM-PRJ-EL-SPC-0001.yaml');
expect(r.parsed).toEqual({ project: 'PRJ', discipline: 'EL', type: 'SPC', sequence: '0001', title: 'Spec' });
});
test('classify: an MDL placement names a file; revision from the cell, transmittal for the path', async ({ page }) => { test('classify: an MDL placement names a file; revision from the cell, transmittal for the path', async ({ page }) => {
await page.click('#modeClassifyBtn'); await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => { const r = await page.evaluate(() => {