feat(classifier): export/import the classification dataset as JSON

Adds Export / Import buttons to the Classify & Copy header so the full dataset
(tracking + transmittal trees, per-file assignments, output name) round-trips
through a JSON file — export it, edit externally (e.g. with an AI), re-import.

- Export downloads a self-documenting JSON (canonical classify.serialize() state
  + an informational sourceFiles inventory + a _format note). Lossless: empty
  tree branches and unassigned state survive.
- Import validates, confirms before replacing a non-empty current dataset, and
  loads via classify.load() (ignores the wrapper/_format/sourceFiles keys).

Test: serialize → JSON → load preserves trees (incl. an empty branch) +
assignments (classify.spec.js -> 34 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-10 11:18:34 -05:00
parent 139171481e
commit 4425a599f0
3 changed files with 108 additions and 0 deletions

View file

@ -147,6 +147,9 @@
showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'), showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'),
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'), showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'), showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
exportDatasetBtn: document.getElementById('exportDatasetBtn'),
importDatasetBtn: document.getElementById('importDatasetBtn'),
importDatasetInput: document.getElementById('importDatasetInput'),
// Folder tree // Folder tree
folderTree: document.getElementById('folderTree'), folderTree: document.getElementById('folderTree'),
@ -201,6 +204,74 @@
if (app.modules.tree) app.modules.tree.render(); 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 * Set up event listeners
*/ */
@ -246,6 +317,14 @@
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(); });
// 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 // Keyboard shortcuts
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);

View file

@ -156,6 +156,9 @@
<div class="pane-header-right"> <div class="pane-header-right">
<span id="classifyStats" class="file-stats"></span> <span id="classifyStats" class="file-stats"></span>
<span class="header-divider">|</span> <span class="header-divider">|</span>
<button id="exportDatasetBtn" class="btn btn-secondary btn-sm" title="Export the classification dataset (trees + assignments) as JSON">Export</button>
<button id="importDatasetBtn" class="btn btn-secondary btn-sm" title="Import a classification dataset JSON (replaces the current one)">Import</button>
<input type="file" id="importDatasetInput" accept="application/json,.json" hidden>
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to an output directory (source untouched)">Copy…</button> <button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to an output directory (source untouched)">Copy…</button>
</div> </div>
</div> </div>

View file

@ -660,3 +660,29 @@ test('editing a placed files filename re-files it onto the parsed tracking pa
expect(r.status).toBe('IFU'); expect(r.status).toBe('IFU');
expect(r.title).toBe('New Title'); 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);
});