From 533b830d2c272ef4e091939294d663a789b8ee56 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 10 Jun 2026 13:50:58 -0500 Subject: [PATCH] feat(classifier): export/import a whole workspace; fix short-viewport welcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan is the expensive part (minutes on cloud mounts), so a workspace is now portable between browsers/machines without re-scanning: - Per-workspace "Export" downloads the snapshot + classify map as one .zddc-workspace.json (the source-directory handle is omitted — it can't be serialized across browsers). - Landing-page "Import" recreates the workspace from that JSON; the user clicks "Connect directory" once on the new browser to re-attach the folder (no re-scan — the snapshot carries the 2-hour walk). A classification-dataset JSON is rejected with a pointer to the in-app Import. Also fix the welcome screen clipping its top on short viewports: the base .empty-state centers with align-items:center, which overflows symmetrically and puts the card's top out of scroll reach. Center the inner card with auto margins instead — they collapse when it's taller than the viewport, keeping the top reachable. Test: workspace import recreates a transferable record (snapshot + map, no handle); 46 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- classifier/css/layout.css | 8 +++- classifier/js/workspace.js | 76 +++++++++++++++++++++++++++++++++++++- classifier/template.html | 6 ++- tests/classify.spec.js | 29 +++++++++++++++ 4 files changed, 116 insertions(+), 3 deletions(-) diff --git a/classifier/css/layout.css b/classifier/css/layout.css index 598cbab..bb62a90 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -287,7 +287,12 @@ } /* ── Welcome screen (intro + tutorial) ─────────────────────────────────── */ +/* Scroll when the viewport is short. The inner card uses auto margins instead + of the base .empty-state's align-items:center so it centers when it fits but + collapses to the top when taller than the viewport — otherwise centering + clips the top of the card and it can't be scrolled into view. */ .empty-state--overlay { overflow-y: auto; } +.empty-state--overlay > .empty-state__inner { margin: auto; } .welcome { max-width: 900px; padding: 1.5rem 0.5rem 2.5rem; } .welcome__title { font-size: 2.6rem; line-height: 1.1; margin: 0 0 0.6rem; } .welcome__lede { @@ -322,7 +327,8 @@ /* ── Workspaces (welcome manager) ──────────────────────────────────────── */ .workspaces { text-align: left; margin: 1.5rem 0 0.5rem; } -.ws-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.ws-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.ws-head__actions { display: flex; gap: 0.5rem; } .ws-head h2 { margin: 0; font-size: 1.4rem; } .ws-list { display: flex; flex-direction: column; gap: 0.4rem; } .ws-empty { color: var(--text-muted); font-size: 0.85rem; padding: 0.75rem; border: 1px dashed var(--border); border-radius: var(--radius); } diff --git a/classifier/js/workspace.js b/classifier/js/workspace.js index b088557..107d141 100644 --- a/classifier/js/workspace.js +++ b/classifier/js/workspace.js @@ -35,6 +35,8 @@ newBtn: document.getElementById('newWorkspaceBtn'), wsBtn: document.getElementById('workspacesBtn'), connectBtn: document.getElementById('connectDirBtn'), + importBtn: document.getElementById('importWorkspaceBtn'), + importInput: document.getElementById('importWorkspaceInput'), }; if (!P() || !P().available) { // No IndexedDB → hide the workspace UI; legacy rename path still works. @@ -46,6 +48,11 @@ if (els.wsBtn) els.wsBtn.addEventListener('click', showWelcome); if (els.connectBtn) els.connectBtn.addEventListener('click', function () { tryReconnect(false); }); if (els.list) els.list.addEventListener('click', onListClick); + if (els.importBtn) els.importBtn.addEventListener('click', function () { els.importInput.click(); }); + if (els.importInput) els.importInput.addEventListener('change', function () { + if (this.files && this.files[0]) importWorkspace(this.files[0]); + this.value = ''; + }); // Autosave the active workspace whenever the map changes. C().on(scheduleAutosave); @@ -101,7 +108,7 @@ var actions = document.createElement('div'); actions.className = 'ws-row__actions'; - [['open', 'Open'], ['rename', 'Rename'], ['delete', 'Delete']].forEach(function (a) { + [['open', 'Open'], ['rename', 'Rename'], ['export', 'Export'], ['delete', 'Delete']].forEach(function (a) { var b = document.createElement('button'); b.className = 'btn btn-sm ' + (a[0] === 'open' ? 'btn-primary' : 'btn-secondary'); b.dataset.act = a[0]; b.textContent = a[1]; @@ -118,9 +125,74 @@ if (!id) return; if (btn.dataset.act === 'open') openWorkspace(id); else if (btn.dataset.act === 'rename') renameWorkspace(id); + else if (btn.dataset.act === 'export') exportWorkspace(id); else if (btn.dataset.act === 'delete') deleteWorkspace(id); } + // ── transfer (export / import a whole workspace as JSON) ───────────────── + // The scan is the expensive part (minutes on cloud mounts), so a workspace + // is portable: its snapshot + classify map travel as one JSON. The source + // directory HANDLE can't cross browsers, so an imported workspace has none — + // "Connect directory" re-attaches the folder once, without re-scanning. + async function exportWorkspace(id) { + var rows = await P().listWorkspaces(); + var meta = rows.filter(function (r) { return r.id === id; })[0]; + var rec = await P().getWorkspace(id); + if (!meta || !rec) { window.zddc.toast('Could not load that workspace to export.', 'error'); return; } + var payload = { + zddcWorkspace: 1, + exportedAt: new Date().toISOString(), + meta: { name: meta.name, rootName: meta.rootName, createdAt: meta.createdAt, updatedAt: meta.updatedAt, summary: meta.summary }, + tree: rec.tree || [], // the scanned snapshot (no re-scan on the other side) + classify: rec.classify || {}, // assignments + target trees + }; + var blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = String(meta.name || 'workspace').replace(/[^\w.-]+/g, '_') + '.zddc-workspace.json'; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + window.zddc.toast('Exported “' + meta.name + '”. Import it in another browser to skip the re-scan.', 'success'); + } + + function importWorkspace(file) { + return new Promise(function (resolve) { + 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'); resolve(null); return; } + if (!obj || !obj.zddcWorkspace) { + window.zddc.toast(obj && obj.zddcClassifierFiles + ? 'That’s a classification dataset, not a workspace — open a workspace and use Import in the toolbar.' + : 'Import failed — not a ZDDC workspace export.', 'error'); + resolve(null); return; + } + var m = obj.meta || {}; + var id = uid(); + var meta = { + id: id, + name: m.name || 'Imported workspace', + rootName: m.rootName || '', + createdAt: m.createdAt || now(), + updatedAt: now(), + summary: m.summary || { files: 0, done: 0, excluded: 0 }, + }; + // No rootHandle — it can't be serialized across browsers; the + // user re-attaches via "Connect directory" after opening. + P().putWorkspace(meta, { id: id, rootHandle: null, tree: obj.tree || [], classify: obj.classify || {} }) + .then(function () { + renderList(); + window.zddc.toast('Imported “' + meta.name + '”. Open it, then “Connect directory” to re-attach the source folder for preview/copy.', 'success', { durationMs: 8000 }); + resolve(id); + }); + }; + reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); resolve(null); }; + reader.readAsText(file); + }); + } + // ── summary ─────────────────────────────────────────────────────────── function allFiles() { var out = []; @@ -297,6 +369,8 @@ showWelcome: showWelcome, newWorkspace: newWorkspace, openWorkspace: openWorkspace, + exportWorkspace: exportWorkspace, + importWorkspace: importWorkspace, onRescanned: onRescanned, renderList: renderList, activeId: function () { return activeId; }, diff --git a/classifier/template.html b/classifier/template.html index c44442f..f653e37 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -208,7 +208,11 @@

Your workspaces

- +
+ + +
+
diff --git a/tests/classify.spec.js b/tests/classify.spec.js index f0c5e40..209df5d 100644 --- a/tests/classify.spec.js +++ b/tests/classify.spec.js @@ -883,3 +883,32 @@ test('copy: a zip member is extracted from its archive and written out', async ( expect(res.wrote).toBe(true); expect(res.content).toBe('ZIPBYTES'); }); + +test('workspace: import recreates a transferable record (snapshot + map, no handle)', async ({ page }) => { + const r = await page.evaluate(async () => { + const ws = window.app.modules.workspace, P = window.app.modules.persist; + const json = JSON.stringify({ + zddcWorkspace: 1, + meta: { name: 'Transferred', rootName: 'BigProject', summary: { files: 3, done: 1, excluded: 0 } }, + tree: [{ n: 'BigProject', p: 'BigProject', f: [{ o: 'a', e: 'pdf', p: 'BigProject' }] }], + classify: { + assignments: { 'a.pdf': { trackingNodeId: null, transmittalNodeId: null, excluded: true, titleOverride: null } }, + trackingTree: [], transmittalTree: [], + }, + }); + const file = new File([json], 'x.zddc-workspace.json', { type: 'application/json' }); + const id = await ws.importWorkspace(file); + const rec = id && await P.getWorkspace(id); + const rows = await P.listWorkspaces(); + return { + listed: rows.some((x) => x.id === id && x.name === 'Transferred' && x.rootName === 'BigProject'), + hasTree: !!(rec && rec.tree && rec.tree.length), + excluded: !!(rec && rec.classify && rec.classify.assignments['a.pdf'] && rec.classify.assignments['a.pdf'].excluded), + noHandle: rec ? (rec.rootHandle == null) : false, + }; + }); + expect(r.listed).toBe(true); // appears in the welcome list + expect(r.hasTree).toBe(true); // the expensive scan came across + expect(r.excluded).toBe(true); // classifications came across + expect(r.noHandle).toBe(true); // source handle intentionally absent (re-attach on this browser) +});