From 9851cc4463aa94ef1470d8281e5ca3b9bc9084a3 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 10 Jun 2026 11:52:44 -0500 Subject: [PATCH] feat(classifier): switch dataset export/import to a filename-per-file format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the ID-based dataset export/import (which required an external editor to build a nested tree and keep node ids consistent) with a flat, AI-friendly list: one record per input file carrying its full ZDDC filename — and an optional transmittal {party, slot, date, type, seq, status, title}. - Export: one {source, originalName, filename, excluded, transmittal?} record per source file (filename = the derived ZDDC name, "" if unassigned). - Import: parses each filename and rebuilds the tracking tree (parseFolderLevels + addTrackingPath, sharing ancestors); excluded files are marked; transmittals are reconstructed with party/bin dedup. No node ids for the editor to manage. New classify helpers: transmittalRecord (export), findOrAddParty / findOrAddTransmittalBin (import dedup). serialize/load stay for workspace persistence. Test rewritten for the filename round-trip (classify.spec.js -> 34). Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/js/app.js | 92 ++++++++++++++++++++++++++------------- classifier/js/classify.js | 36 +++++++++++++++ tests/classify.spec.js | 51 ++++++++++++++-------- 3 files changed, 130 insertions(+), 49 deletions(-) diff --git a/classifier/js/app.js b/classifier/js/app.js index b1b625d..c619896 100644 --- a/classifier/js/app.js +++ b/classifier/js/app.js @@ -204,39 +204,45 @@ if (app.modules.tree) app.modules.tree.render(); } - // ── dataset export / import ──────────────────────────────────────────── - // Round-trip the full classification (trees + assignments + output name) as - // JSON so it can be edited externally (e.g. by an AI) and re-imported. The - // exported `sourceFiles` list is informational — it tells the editor which - // files exist; only the canonical state is read back on import. - function collectSourceFiles() { - var c = app.modules.classify, out = []; + // ── dataset export / import (one record per file) ────────────────────── + // Round-trip the classification as a flat list of files, each carrying its + // full ZDDC filename (and optional transmittal). An external editor (e.g. an + // AI) just sets filenames; on import the app parses each filename and + // rebuilds the tracking tree (no node ids to manage). + function eachSourceFile(cb) { (function walk(nodes) { - (nodes || []).forEach(function (n) { - (n.files || []).forEach(function (f) { - out.push({ key: c.srcKeyForFile(f), name: window.zddc.joinExtension(f.originalFilename, f.extension) }); - }); - walk(n.children); - }); + (nodes || []).forEach(function (n) { (n.files || []).forEach(cb); walk(n.children); }); })(app.folderTree || []); - return out; } function exportDataset() { - var s = app.modules.classify.serialize(); + var c = app.modules.classify, files = []; + eachSourceFile(function (f) { + var key = c.srcKeyForFile(f); + var a = c.getAssignment(key) || {}; + var d = c.deriveTarget(f); + var rec = { + source: key, + originalName: window.zddc.joinExtension(f.originalFilename, f.extension), + filename: a.excluded ? '' : (d.filename || ''), + excluded: !!a.excluded, + }; + if (!a.excluded && a.transmittalNodeId) { + var t = c.transmittalRecord(a.transmittalNodeId); + if (t) rec.transmittal = t; + } + files.push(rec); + }); var payload = { - zddcClassifierDataset: 1, + zddcClassifierFiles: 1, exportedAt: new Date().toISOString(), - _format: 'ZDDC Classifier dataset. trackingTree/transmittalTree are folder trees of ' - + '{id,name,children}. assignments maps each source file (key) to its placement ' - + '{trackingNodeId, transmittalNodeId, excluded, titleOverride}, referencing node ids ' - + 'in the trees. The tracking number is a node\'s ancestor names joined with "-"; the ' - + 'leaf folder is "REV (STATUS)". sourceFiles lists every available file (informational; ' - + 'ignored on import). Edit names/structure/assignments and re-import; keep ids consistent.', - outputName: s.outputName || null, - trackingTree: s.trackingTree || [], - transmittalTree: s.transmittalTree || [], - assignments: s.assignments || {}, - sourceFiles: collectSourceFiles(), + _format: 'One record per input file. Set "filename" to its full ZDDC name ' + + '"TRACKING_REV (STATUS) - Title.ext" — on import the app splits TRACKING on "-" and the ' + + 'final "_" into nested folders, and files in shared paths share ancestors. Set ' + + '"excluded": true for non-documents (filename then ignored). "transmittal" is optional: ' + + '{party, slot:"received"|"issued", date:"YYYY-MM-DD", type:"TRN"|"SUB", seq, status, title}. ' + + 'Classify every "source" key; do not invent files.', + outputName: c.serialize().outputName || null, + files: files, }; var name = 'classifier-dataset'; try { @@ -258,15 +264,39 @@ var obj; try { obj = JSON.parse(reader.result); } catch (e) { window.zddc.toast('Import failed — not valid JSON.', 'error'); return; } - if (!obj || (!obj.trackingTree && !obj.transmittalTree && !obj.assignments)) { - window.zddc.toast('Import failed — not a classifier dataset.', 'error'); return; + if (!obj || !Array.isArray(obj.files)) { + window.zddc.toast('Import failed — expected a classifier dataset with a "files" list.', 'error'); return; } var c = app.modules.classify; var hasData = c.getTrackingTree().length || c.getTransmittalTree().length || Object.keys(c.serialize().assignments || {}).length; if (hasData && !confirm('Replace the current classification with the imported dataset?')) return; - c.load(obj); // reads trackingTree/transmittalTree/assignments/outputName; ignores the rest - window.zddc.toast('Dataset imported.', 'success'); + c.reset(); + var ok = 0, bad = 0; + obj.files.forEach(function (rec) { + if (!rec || !rec.source) return; + var key = rec.source; + if (rec.excluded) { c.setExcluded([key], true); ok++; return; } + if (rec.filename) { + var p = window.zddc.parseFilename(String(rec.filename).trim()); + if (p && p.valid) { + var stem = p.trackingNumber + '_' + p.revision + ' (' + p.status + ')'; + c.place([key], c.addTrackingPath(null, c.parseFolderLevels(stem)), 'tracking'); + if (p.title != null) c.setTitleOverride(key, p.title); + ok++; + } else { bad++; } + } + if (rec.transmittal && rec.transmittal.party) { + var t = rec.transmittal; + var pid = c.findOrAddParty(t.party); + var bid = c.findOrAddTransmittalBin(pid, t.slot || 'received', { + date: t.date, type: t.type || 'TRN', seq: t.seq, status: t.status, title: t.title, + }); + if (bid) c.place([key], bid, 'transmittal'); + } + }); + window.zddc.toast('Imported ' + ok + ' file' + (ok === 1 ? '' : 's') + + (bad ? (' — ' + bad + ' had an unparseable filename') : '') + '.', bad ? 'warning' : 'success'); }; reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); }; reader.readAsText(file); diff --git a/classifier/js/classify.js b/classifier/js/classify.js index c7f9a15..c386d14 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -503,6 +503,40 @@ return trackingChain(info).join(' / '); } + // ── filename-based export/import helpers ───────────────────────────────── + // A flat, AI-friendly transmittal record for a placed file (export side). + function transmittalRecord(binId) { + var info = infoFor(binId); + if (!info || info.kind !== 'transmittal') return null; + var slot = info.parent ? infoFor(info.parent.id) : null; + var party = slot && slot.parent ? infoFor(slot.parent.id) : null; + var m = info.node.meta || {}; + return { + party: party ? party.node.name : '', + slot: slot ? slot.node.slot : '', + date: m.date || '', type: m.type || 'TRN', seq: m.seq || '', + status: m.status || '', title: m.title || '', + }; + } + // Find-or-create a party by name (import side — reuse so shared transmittals + // don't duplicate the party). + function findOrAddParty(name) { + var existing = (state.transmittalTree || []).filter(function (p) { return p.name === name; })[0]; + return existing ? existing.id : addParty(name); + } + // Find-or-create a transmittal bin under party/slot matching meta (import). + function findOrAddTransmittalBin(partyId, slot, meta) { + var pinfo = infoFor(partyId); + if (!pinfo || pinfo.kind !== 'party') return null; + var wantName = transmittalFolderName(pinfo.node.name, meta); + var slotNode = (pinfo.node.children || []).filter(function (s) { return s.slot === slot; })[0]; + if (slotNode) { + var existing = (slotNode.children || []).filter(function (b) { return b.name === wantName; })[0]; + if (existing) return existing.id; + } + return addTransmittalBin(partyId, slot, meta); + } + // ── mode ───────────────────────────────────────────────────────────────── function setEnabled(on) { state.enabled = !!on; notify(); } function isEnabled() { return state.enabled; } @@ -524,6 +558,8 @@ expandFolderPattern: expandFolderPattern, parseFolderLevels: parseFolderLevels, addTrackingPath: addTrackingPath, trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel, + transmittalRecord: transmittalRecord, + findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin, getNode: getNode, getTrackingTree: function () { return state.trackingTree; }, getTransmittalTree: function () { return state.transmittalTree; }, // derive + reverse diff --git a/tests/classify.spec.js b/tests/classify.spec.js index d489c98..ac7a9b4 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -661,28 +661,43 @@ test('editing a placed file’s filename re-files it onto the parsed tracking pa expect(r.title).toBe('New Title'); }); -test('dataset round-trip: serialize → JSON → load preserves trees + assignments', async ({ page }) => { +test('dataset (filename-based): import reconstruction rebuilds tracking + shared transmittals', async ({ page }) => { const r = await page.evaluate(() => { const c = window.app.modules.classify; + const z = window.zddc; c.reset(); - const leaf = c.addTrackingNode(c.addTrackingNode(null, 'CPO'), 'A (IFR)'); - c.addTrackingNode(null, 'EMPTY-BRANCH'); // a node with no files (must survive) - const file = { folderPath: 'Root', originalFilename: 'doc', extension: 'pdf' }; - const key = c.srcKeyForFile(file); - c.place([key], leaf, 'tracking'); - // Emulate export wrapper (extra keys load() must ignore) → JSON → load. - const exported = { zddcClassifierDataset: 1, exportedAt: 'x', sourceFiles: [{ key }], ...c.serialize() }; - const json = JSON.stringify(exported); - c.reset(); - c.load(JSON.parse(json)); - const tree = c.getTrackingTree(); + // Mirrors app.importDataset's per-record reconstruction: two docs sharing + // one transmittal package, plus an excluded junk file. + const recs = [ + { source: 'a.pdf', filename: 'CPO-0001_0 (IFU) - Doc A.pdf', excluded: false, + transmittal: { party: 'Acme', slot: 'received', date: '2025-10-31', type: 'TRN', seq: '0043', status: 'IFC', title: 'Pkg' } }, + { source: 'b.pdf', filename: 'CPO-0002_0 (IFU) - Doc B.pdf', excluded: false, + transmittal: { party: 'Acme', slot: 'received', date: '2025-10-31', type: 'TRN', seq: '0043', status: 'IFC', title: 'Pkg' } }, + { source: 'junk.tmp', filename: '', excluded: true }, + ]; + recs.forEach((rec) => { + if (rec.excluded) { c.setExcluded([rec.source], true); return; } + const p = z.parseFilename(rec.filename); + c.place([rec.source], c.addTrackingPath(null, c.parseFolderLevels(p.trackingNumber + '_' + p.revision + ' (' + p.status + ')')), 'tracking'); + c.setTitleOverride(rec.source, p.title); + const t = rec.transmittal; + const bid = c.findOrAddTransmittalBin(c.findOrAddParty(t.party), t.slot, t); + c.place([rec.source], bid, 'transmittal'); + }); + const da = c.deriveTarget({ folderPath: '', originalFilename: 'a', extension: 'pdf' }); // key 'a.pdf' + const tree = c.getTransmittalTree(); return { - names: tree.map((n) => n.name).sort(), - leaf: tree.find((n) => n.name === 'CPO').children[0].name, - assigned: !!c.getAssignment(key), + tracking: da.tracking, rev: da.revision, status: da.status, title: da.title, + parties: tree.length, + bins: tree[0] ? tree[0].children.filter((s) => s.slot === 'received')[0].children.length : -1, + excluded: c.getAssignment('junk.tmp').excluded, }; }); - expect(r.names).toEqual(['CPO', 'EMPTY-BRANCH']); // empty branch preserved - expect(r.leaf).toBe('A (IFR)'); - expect(r.assigned).toBe(true); + expect(r.tracking).toBe('CPO-0001'); + expect(r.rev).toBe('0'); + expect(r.status).toBe('IFU'); + expect(r.title).toBe('Doc A'); + expect(r.parties).toBe(1); // one Acme party + expect(r.bins).toBe(1); // shared transmittal → single bin (dedup) + expect(r.excluded).toBe(true); });