diff --git a/classifier/js/app.js b/classifier/js/app.js index 516219a..b1b625d 100644 --- a/classifier/js/app.js +++ b/classifier/js/app.js @@ -147,6 +147,9 @@ showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'), showAssignedCheckbox: document.getElementById('showAssignedCheckbox'), showExcludedCheckbox: document.getElementById('showExcludedCheckbox'), + exportDatasetBtn: document.getElementById('exportDatasetBtn'), + importDatasetBtn: document.getElementById('importDatasetBtn'), + importDatasetInput: document.getElementById('importDatasetInput'), // Folder tree folderTree: document.getElementById('folderTree'), @@ -201,6 +204,74 @@ 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 = []; + (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); + }); + })(app.folderTree || []); + return out; + } + function exportDataset() { + var s = app.modules.classify.serialize(); + var payload = { + zddcClassifierDataset: 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(), + }; + var name = 'classifier-dataset'; + try { + if (app.modules.workspace && typeof app.modules.workspace.activeName === 'function') { + name = app.modules.workspace.activeName() || name; + } + } catch (_) { /* ok */ } + var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = String(name).replace(/[^\w.-]+/g, '_') + '.json'; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + } + function importDataset(file) { + var reader = new FileReader(); + reader.onload = function () { + 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; + } + 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'); + }; + reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); }; + reader.readAsText(file); + } + /** * Set up event listeners */ @@ -245,6 +316,14 @@ if (app.dom.modeRenameBtn) app.dom.modeRenameBtn.addEventListener('click', function () { setMode('rename'); }); 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(); }); + + // Dataset export / import (round-trip the classification through a JSON file). + if (app.dom.exportDatasetBtn) app.dom.exportDatasetBtn.addEventListener('click', exportDataset); + if (app.dom.importDatasetBtn) app.dom.importDatasetBtn.addEventListener('click', function () { app.dom.importDatasetInput.click(); }); + if (app.dom.importDatasetInput) app.dom.importDatasetInput.addEventListener('change', function () { + if (this.files && this.files[0]) importDataset(this.files[0]); + this.value = ''; // allow re-importing the same file + }); // Keyboard shortcuts document.addEventListener('keydown', handleKeyDown); diff --git a/classifier/template.html b/classifier/template.html index 5e5141e..8952361 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -156,6 +156,9 @@
| + + +
diff --git a/tests/classify.spec.js b/tests/classify.spec.js index 01caa32..d489c98 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -660,3 +660,29 @@ test('editing a placed file’s filename re-files it onto the parsed tracking pa expect(r.status).toBe('IFU'); expect(r.title).toBe('New Title'); }); + +test('dataset round-trip: serialize → JSON → load preserves trees + assignments', async ({ page }) => { + const r = await page.evaluate(() => { + const c = window.app.modules.classify; + 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(); + return { + names: tree.map((n) => n.name).sort(), + leaf: tree.find((n) => n.name === 'CPO').children[0].name, + assigned: !!c.getAssignment(key), + }; + }); + expect(r.names).toEqual(['CPO', 'EMPTY-BRANCH']); // empty branch preserved + expect(r.leaf).toBe('A (IFR)'); + expect(r.assigned).toBe(true); +});