From d4d48cad4a89ee8090764dbcf055a82392103867 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 11 Jun 2026 14:58:31 -0500 Subject: [PATCH] =?UTF-8?q?feat(classifier):=20MDL=20becomes=20a=20read-on?= =?UTF-8?q?ly=20catalog=20(MDL=20=E2=88=AA=20archive)=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframes the By-MDL tab as a "⊞ Catalog" button that opens an overlay over the target pane (the left filetree stays the drag source). The catalog is the de-duplicated union of everything the project knows about a tracking number: its MDL deliverables AND its archive files, merged by tracking number, with an informational "Archive revs" column (which revisions already exist) and an "MDL" flag. Nothing is written or altered — the Revision column is classifier-local and starts blank (never pre-filled from an old archive rev). - Drag a source file onto a row → assigns the tracking number only (the mdl axis); set the revision in the bulk-editable Revision column (ctrl-shift select rows + ctrl-Enter). Per-file Title: MDL/file toggle + ✕ remove kept. - Columns split the tracking number into the configured pattern fields, each with its own autofilter (per-column, via the shared seltable). Default pattern is now the 8-field ORIG-PHASE-PROJECT-AREA-DISC-TYPE-SEQ-SUFFIX. - Server load merges every party's archive//mdl/*.yaml with a recursive walk of the archive documents; local load reads a folder of deliverable yamls. Test: catalog shows merged archive revisions; drop names a file (tracking only); bulk revision feeds the derived name. Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/css/layout.css | 20 ++++- classifier/js/classify.js | 6 +- classifier/js/target-tree.js | 164 ++++++++++++++++++++++------------- classifier/template.html | 22 +++-- tests/classify.spec.js | 20 +++-- 5 files changed, 153 insertions(+), 79 deletions(-) diff --git a/classifier/css/layout.css b/classifier/css/layout.css index e920a3e..c3c83d9 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -571,8 +571,24 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o cursor: wait; } -/* ── By-MDL panel (seltable rows = deliverable drop targets) ─────────────── */ -#mdlPanel .seltable { height: 100%; } +/* ── Catalog overlay (MDL ∪ archive; seltable rows = drop targets) ───────── */ +.target-pane { position: relative; } +.target-tabs__catalog { margin-left: 0.75rem; } +.catalog-overlay { + position: absolute; inset: 0; z-index: 20; + display: flex; flex-direction: column; min-height: 0; + background: var(--bg); border-left: 2px solid var(--primary); +} +.catalog-overlay[hidden] { display: none; } +.catalog-overlay__head { + display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; + padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); background: var(--bg-secondary, var(--bg)); +} +.catalog-overlay__title { font-weight: 700; font-size: 0.9rem; } +.catalog-overlay__close { margin-left: auto; background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; } +.catalog-overlay__close:hover { color: var(--text); } +.catalog-overlay__table { flex: 1; min-height: 0; } +.catalog-overlay__table .seltable { height: 100%; } .mdl-rev__input { 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; diff --git a/classifier/js/classify.js b/classifier/js/classify.js index 64b0c82..933b345 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -33,7 +33,9 @@ // table columns + (later) revision-modifier menus. Editable by the user. var DEFAULT_FIELDS = [ { name: 'ORIG', optional: false }, - { name: 'PROJ', optional: false }, + { name: 'PHASE', optional: false }, + { name: 'PROJECT', optional: false }, + { name: 'AREA', optional: false }, { name: 'DISC', optional: false }, { name: 'TYPE', optional: false }, { name: 'SEQ', optional: false }, @@ -488,6 +490,8 @@ return { id: r.id || uid(), party: r.party || '', trackingNumber: r.trackingNumber || '', title: r.title || '', + inMdl: !!r.inMdl, + archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [], revisionCell: r.revisionCell || '', }; }); diff --git a/classifier/js/target-tree.js b/classifier/js/target-tree.js index fb3202a..c5803ab 100644 --- a/classifier/js/target-tree.js +++ b/classifier/js/target-tree.js @@ -19,8 +19,9 @@ var collapsed = {}; // nodeId -> true when collapsed (default expanded) var openForm = null; // { partyId, slot } when a bin form is open var initialized = false; - var currentTab = 'tracking'; // 'tracking' | 'transmittal' | 'mdl' — the active axis - var mdlTable = null; // the seltable controller for the By-MDL panel + var currentTab = 'tracking'; // 'tracking' | 'transmittal' — the active tab + var catalogOpen = false; // the Catalog overlay (the 'mdl' axis) is open + var mdlTable = null; // the seltable controller for the catalog var mdlPlaced = {}; // latest placed.mdl map (read by the placed-file cell) function init() { @@ -29,7 +30,7 @@ els = { trackingTab: document.getElementById('trackingTab'), transmittalTab: document.getElementById('transmittalTab'), - mdlTab: document.getElementById('mdlTab'), + catalogBtn: document.getElementById('catalogBtn'), trackingPanel: document.getElementById('trackingPanel'), transmittalPanel: document.getElementById('transmittalPanel'), mdlPanel: document.getElementById('mdlPanel'), @@ -37,6 +38,7 @@ transmittalTree: document.getElementById('transmittalTree'), mdlTree: document.getElementById('mdlTree'), loadMdlBtn: document.getElementById('loadMdlBtn'), + catalogCloseBtn: document.getElementById('catalogCloseBtn'), addTrackingRootBtn: document.getElementById('addTrackingRootBtn'), addPartyBtn: document.getElementById('addPartyBtn'), stats: document.getElementById('classifyStats'), @@ -44,7 +46,8 @@ els.trackingTab.addEventListener('click', function () { showTab('tracking'); }); els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); }); - if (els.mdlTab) els.mdlTab.addEventListener('click', function () { showTab('mdl'); }); + if (els.catalogBtn) els.catalogBtn.addEventListener('click', function () { catalogOpen ? closeCatalog() : openCatalog(); }); + if (els.catalogCloseBtn) els.catalogCloseBtn.addEventListener('click', closeCatalog); if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl); els.addTrackingRootBtn.addEventListener('click', function () { var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n' @@ -98,18 +101,31 @@ } function showTab(which) { - currentTab = (which === 'transmittal' || which === 'mdl') ? which : 'tracking'; + currentTab = which === 'transmittal' ? 'transmittal' : 'tracking'; els.trackingTab.classList.toggle('active', currentTab === 'tracking'); els.transmittalTab.classList.toggle('active', currentTab === 'transmittal'); - if (els.mdlTab) els.mdlTab.classList.toggle('active', currentTab === 'mdl'); els.trackingPanel.hidden = currentTab !== 'tracking'; 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 // with the active tab — re-render the left tree. if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); } - function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : currentTab === 'mdl' ? 'mdl' : 'tracking'; } + // The active axis is the catalog ('mdl') while the overlay is open, else the tab. + function activeAxis() { return catalogOpen ? 'mdl' : (currentTab === 'transmittal' ? 'transmittal' : 'tracking'); } + function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); } + function openCatalog() { + catalogOpen = true; + if (els.mdlPanel) els.mdlPanel.hidden = false; + if (els.catalogBtn) els.catalogBtn.classList.add('active'); + render(); + reRenderSource(); + } + function closeCatalog() { + catalogOpen = false; + if (els.mdlPanel) els.mdlPanel.hidden = true; + if (els.catalogBtn) els.catalogBtn.classList.remove('active'); + reRenderSource(); + } // Expand a brace pattern into folder names and create them (confirming a // multi-create first). parentId null = root folders. See expandFolderPattern. @@ -474,7 +490,7 @@ if (!C().getMdlList().length) { mdlTable = null; els.mdlTree.textContent = ''; - els.mdlTree.appendChild(el('div', 'target-empty', 'No MDL loaded — “Load MDL…” to bring a project’s deliverables in as drop targets.')); + els.mdlTree.appendChild(el('div', 'target-empty', 'Nothing loaded yet — “Load…” to pull in the project’s MDL deliverables and archive tracking numbers.')); return; } ensureMdlTable(); @@ -483,31 +499,35 @@ function ensureMdlTable() { if (mdlTable) return mdlTable; 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({ container: els.mdlTree, - filterPlaceholder: 'Filter deliverables by tracking number, title, party…', extraTitle: 'Files', rows: function () { return c.getMdlList(); }, rowId: function (r) { return r.id; }, - 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); - }, - }, - ], + columns: cols, onRowDrop: function (rowId, keys) { c.place(keys, rowId, 'mdl'); }, onActivate: function (ids) { if (!ids.length) return; - var v = prompt('Set the revision on ' + ids.length + ' selected deliverable(s) (e.g. "A (IFR)"):', ''); + var v = prompt('Set the revision on ' + ids.length + ' selected row(s) (e.g. "A (IFR)"):', ''); if (v != null) c.setRevisionCells(ids, v.trim()); }, rowExtra: function (r, td) { renderMdlPlaced(r, td); }, @@ -538,22 +558,13 @@ }); } - // Load deliverables into the MDL table — a local folder of .yaml or a server - // project (one or more parties). Each yaml's filename stem is the tracking - // number; the revision cell starts blank (classifier-local). - function yamlToRow(party, stem, obj) { - return { - id: party + '|' + stem, - party: party, - trackingNumber: stem, - title: (obj && obj.title) || '', - revisionCell: '', - }; - } + // Load the catalog: the union of the project's MDL deliverables and its + // archive tracking numbers, deduped by tracking number. Server reads both; + // a local folder reads just its *.yaml deliverables. Writes/alters nothing — + // the revision cell is classifier-local and starts blank. + function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; } 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(); } async function loadMdlLocal() { @@ -564,10 +575,11 @@ var rows = []; try { for await (var entry of dir.values()) { - if (entry.kind !== 'file' || !/\.yaml$/i.test(entry.name) || entry.name === 'table.yaml' || entry.name === 'form.yaml') continue; - var txt = await (await entry.getFile()).text(); - var obj = null; try { obj = window.jsyaml.load(txt); } catch (_) { /* skip bad yaml */ } - rows.push(yamlToRow(dir.name || 'local', entry.name.replace(/\.yaml$/i, ''), obj)); + var nm = String(entry.name).replace(/\/$/, ''); + if (entry.kind !== 'file' || !isRowYaml(nm)) continue; + var obj = null; try { obj = window.jsyaml.load(await (await entry.getFile()).text()); } catch (_) { /* skip */ } + var stem = nm.replace(/\.yaml$/i, ''); + rows.push({ id: stem, party: dir.name || 'local', trackingNumber: stem, title: (obj && obj.title) || '', inMdl: true, archiveRevisions: [], revisionCell: '' }); } } catch (e) { window.zddc.toast('Reading the MDL folder failed — ' + (e.message || e), 'error'); return; } finishLoad(rows); @@ -581,29 +593,63 @@ if (!proj) return; 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 rows = []; + var byTn = Object.create(null); + function ensure(tn) { return byTn[tn] || (byTn[tn] = { tracking: tn, title: '', inMdl: false, party: '', revs: Object.create(null) }); } + window.zddc.toast('Scanning the project MDL + archive…', 'info', { durationMs: 4000 }); try { for await (var party of archive.values()) { if (party.kind !== 'directory') continue; var pn = String(party.name).replace(/\/$/, ''); if (pn.charAt(0) === '.' || pn.charAt(0) === '_') continue; - var mdlDir; - try { mdlDir = await party.getDirectoryHandle('mdl'); } catch (e) { continue; } // no mdl for this party - for await (var entry of mdlDir.values()) { - var nm = String(entry.name).replace(/\/$/, ''); - if (entry.kind !== 'file' || !/\.yaml$/i.test(nm) || nm === 'table.yaml' || nm === 'form.yaml') continue; - var obj = null; - try { obj = window.jsyaml.load(await (await entry.getFile()).text()); } catch (_) { /* skip */ } - rows.push(yamlToRow(pn, nm.replace(/\.yaml$/i, ''), obj)); + for await (var slot of party.values()) { + if (slot.kind !== 'directory') continue; + var sn = String(slot.name).replace(/\/$/, ''); + if (sn.charAt(0) === '.' || sn.charAt(0) === '_' || sn === 'rsk') continue; + if (sn === 'mdl') { // registered deliverables + for await (var ye of slot.values()) { + var ynm = String(ye.name).replace(/\/$/, ''); + if (ye.kind !== 'file' || !isRowYaml(ynm)) continue; + var obj = null; try { obj = window.jsyaml.load(await (await ye.getFile()).text()); } catch (_) { /* skip */ } + var row = ensure(ynm.replace(/\.yaml$/i, '')); + row.inMdl = true; if (!row.title && obj && obj.title) row.title = obj.title; if (!row.party) row.party = pn; + } + } else { // archive documents → tracking + revisions + await walkArchiveInto(slot, ensure, pn); + } } } - } catch (e) { window.zddc.toast('Reading the project MDL failed — ' + (e.message || e), 'error'); return; } + } catch (e) { window.zddc.toast('Reading the project failed — ' + (e.message || e), 'error'); return; } + var rows = Object.keys(byTn).map(function (tn) { + var x = byTn[tn]; + return { id: tn, party: x.party, trackingNumber: tn, title: x.title, inMdl: x.inMdl, archiveRevisions: Object.keys(x.revs).sort(), revisionCell: '' }; + }); finishLoad(rows); } + // 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) { C().setMdlList(rows); - showTab('mdl'); - 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'); + openCatalog(); + window.zddc.toast(rows.length + ? ('Catalog: ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' (MDL ∪ archive). Filter, drag files on, set revisions.') + : 'Nothing found in the MDL or archive.', rows.length ? 'success' : 'warning'); } // ── events ───────────────────────────────────────────────────────────── @@ -808,8 +854,8 @@ var a = C().getAssignment(key); if (!a) return; if (a.mdlNodeId) { - showTab('mdl'); - if (mdlTable) { mdlTable.setFilter(''); mdlTable.renderBody(); } + openCatalog(); + if (mdlTable) { mdlTable.renderBody(); } } else if (a.trackingNodeId) { showTab('tracking'); collapsed = {}; render(); flashNode(els.trackingTree, a.trackingNodeId); diff --git a/classifier/template.html b/classifier/template.html index f8f8281..5f6f08e 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -163,7 +163,7 @@
- +
@@ -195,14 +195,20 @@ placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
-
+ + diff --git a/tests/classify.spec.js b/tests/classify.spec.js index cfa7beb..688dad5 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -1311,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 }); -test('By MDL tab: drop a file on a deliverable row names it; bulk revision applies', async ({ page }) => { +test('Catalog: shows archive revs, drop on a row names the file, bulk revision applies', async ({ page }) => { await page.click('#modeClassifyBtn'); const r = await page.evaluate(() => { const c = window.app.modules.classify, tt = window.app.modules.targetTree; @@ -1319,20 +1319,22 @@ test('By MDL tab: drop a file on a deliverable row names it; bulk revision appli const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' }; window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }]; const key = c.srcKeyForFile(f); + // Catalog rows = MDL ∪ archive merged: one MDL+archive row, one archive-only. c.setMdlList([ - { 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' }, + { id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'] }, + { id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', inMdl: false, archiveRevisions: ['0 (IFC)'] }, ]); - tt.showTab('mdl'); tt.render(); + tt.render(); // builds the catalog seltable into #mdlTree const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]'); - const hasRow = !!row; + const archiveRevsShown = !!row && row.textContent.includes('A (IFR)') && row.textContent.includes('B (IFC)'); window.app.modules.dnd.setDrag([key]); row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop the file on m1 const placed = (c.getAssignment(key) || {}).mdlNodeId; - c.setRevisionCells(['m1', 'm2'], 'A (IFR)'); // ctrl-enter bulk path - return { hasRow, placed, named: c.deriveTarget(f).filename }; + c.setRevisionCells(['m1', 'm2'], 'A (IFR)'); // ctrl-Enter bulk path + return { hasRow: !!row, archiveRevsShown, placed, named: c.deriveTarget(f).filename }; }); expect(r.hasRow).toBe(true); - expect(r.placed).toBe('m1'); // drop placed the file on the deliverable - expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // bulk-set revision feeds the name + expect(r.archiveRevsShown).toBe(true); // merged archive revisions shown (informational) + expect(r.placed).toBe('m1'); // drop = tracking number only + expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // revision from the bulk-set column feeds the name });