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:
parent
139171481e
commit
4425a599f0
3 changed files with 108 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
<div class="pane-header-right">
|
||||
<span id="classifyStats" class="file-stats"></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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue