feat(classifier): export/import a whole workspace; fix short-viewport welcome
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 <name>.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) <noreply@anthropic.com>
This commit is contained in:
parent
589d804716
commit
533b830d2c
4 changed files with 116 additions and 3 deletions
|
|
@ -287,7 +287,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Welcome screen (intro + tutorial) ─────────────────────────────────── */
|
/* ── 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 { overflow-y: auto; }
|
||||||
|
.empty-state--overlay > .empty-state__inner { margin: auto; }
|
||||||
.welcome { max-width: 900px; padding: 1.5rem 0.5rem 2.5rem; }
|
.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__title { font-size: 2.6rem; line-height: 1.1; margin: 0 0 0.6rem; }
|
||||||
.welcome__lede {
|
.welcome__lede {
|
||||||
|
|
@ -322,7 +327,8 @@
|
||||||
|
|
||||||
/* ── Workspaces (welcome manager) ──────────────────────────────────────── */
|
/* ── Workspaces (welcome manager) ──────────────────────────────────────── */
|
||||||
.workspaces { text-align: left; margin: 1.5rem 0 0.5rem; }
|
.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-head h2 { margin: 0; font-size: 1.4rem; }
|
||||||
.ws-list { display: flex; flex-direction: column; gap: 0.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); }
|
.ws-empty { color: var(--text-muted); font-size: 0.85rem; padding: 0.75rem; border: 1px dashed var(--border); border-radius: var(--radius); }
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@
|
||||||
newBtn: document.getElementById('newWorkspaceBtn'),
|
newBtn: document.getElementById('newWorkspaceBtn'),
|
||||||
wsBtn: document.getElementById('workspacesBtn'),
|
wsBtn: document.getElementById('workspacesBtn'),
|
||||||
connectBtn: document.getElementById('connectDirBtn'),
|
connectBtn: document.getElementById('connectDirBtn'),
|
||||||
|
importBtn: document.getElementById('importWorkspaceBtn'),
|
||||||
|
importInput: document.getElementById('importWorkspaceInput'),
|
||||||
};
|
};
|
||||||
if (!P() || !P().available) {
|
if (!P() || !P().available) {
|
||||||
// No IndexedDB → hide the workspace UI; legacy rename path still works.
|
// 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.wsBtn) els.wsBtn.addEventListener('click', showWelcome);
|
||||||
if (els.connectBtn) els.connectBtn.addEventListener('click', function () { tryReconnect(false); });
|
if (els.connectBtn) els.connectBtn.addEventListener('click', function () { tryReconnect(false); });
|
||||||
if (els.list) els.list.addEventListener('click', onListClick);
|
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.
|
// Autosave the active workspace whenever the map changes.
|
||||||
C().on(scheduleAutosave);
|
C().on(scheduleAutosave);
|
||||||
|
|
@ -101,7 +108,7 @@
|
||||||
|
|
||||||
var actions = document.createElement('div');
|
var actions = document.createElement('div');
|
||||||
actions.className = 'ws-row__actions';
|
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');
|
var b = document.createElement('button');
|
||||||
b.className = 'btn btn-sm ' + (a[0] === 'open' ? 'btn-primary' : 'btn-secondary');
|
b.className = 'btn btn-sm ' + (a[0] === 'open' ? 'btn-primary' : 'btn-secondary');
|
||||||
b.dataset.act = a[0]; b.textContent = a[1];
|
b.dataset.act = a[0]; b.textContent = a[1];
|
||||||
|
|
@ -118,9 +125,74 @@
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
if (btn.dataset.act === 'open') openWorkspace(id);
|
if (btn.dataset.act === 'open') openWorkspace(id);
|
||||||
else if (btn.dataset.act === 'rename') renameWorkspace(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);
|
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 ───────────────────────────────────────────────────────────
|
// ── summary ───────────────────────────────────────────────────────────
|
||||||
function allFiles() {
|
function allFiles() {
|
||||||
var out = [];
|
var out = [];
|
||||||
|
|
@ -297,6 +369,8 @@
|
||||||
showWelcome: showWelcome,
|
showWelcome: showWelcome,
|
||||||
newWorkspace: newWorkspace,
|
newWorkspace: newWorkspace,
|
||||||
openWorkspace: openWorkspace,
|
openWorkspace: openWorkspace,
|
||||||
|
exportWorkspace: exportWorkspace,
|
||||||
|
importWorkspace: importWorkspace,
|
||||||
onRescanned: onRescanned,
|
onRescanned: onRescanned,
|
||||||
renderList: renderList,
|
renderList: renderList,
|
||||||
activeId: function () { return activeId; },
|
activeId: function () { return activeId; },
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,11 @@
|
||||||
<section id="workspacesSection" class="workspaces">
|
<section id="workspacesSection" class="workspaces">
|
||||||
<div class="ws-head">
|
<div class="ws-head">
|
||||||
<h2>Your workspaces</h2>
|
<h2>Your workspaces</h2>
|
||||||
<button id="newWorkspaceBtn" class="btn btn-primary">+ New workspace</button>
|
<div class="ws-head__actions">
|
||||||
|
<button id="importWorkspaceBtn" class="btn btn-secondary" title="Import a workspace exported from another browser (.json) — restores the scanned snapshot so you don't re-scan">⭱ Import</button>
|
||||||
|
<button id="newWorkspaceBtn" class="btn btn-primary">+ New workspace</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="importWorkspaceInput" accept="application/json,.json" hidden>
|
||||||
</div>
|
</div>
|
||||||
<div id="workspaceList" class="ws-list"><!-- rendered --></div>
|
<div id="workspaceList" class="ws-list"><!-- rendered --></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -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.wrote).toBe(true);
|
||||||
expect(res.content).toBe('ZIPBYTES');
|
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)
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue