Compare commits

..

No commits in common. "8368cc81b811df1fb16a9084008a9fe086be6529" and "589d804716a8bb8a5961b2a5b8f7c6620bc135ed" have entirely different histories.

12 changed files with 25 additions and 234 deletions

View file

@ -287,12 +287,7 @@
} }
/* ── 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 {
@ -327,8 +322,7 @@
/* ── 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; flex-wrap: wrap; } .ws-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.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); }

View file

@ -259,7 +259,7 @@
var url = URL.createObjectURL(blob); var url = URL.createObjectURL(blob);
var a = document.createElement('a'); var a = document.createElement('a');
a.href = url; a.href = url;
a.download = String(name).replace(/[^\w.-]+/g, '_') + '.zddc-classification.json'; a.download = String(name).replace(/[^\w.-]+/g, '_') + '.json';
document.body.appendChild(a); a.click(); a.remove(); document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }

View file

@ -35,8 +35,6 @@
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.
@ -48,11 +46,6 @@
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);
@ -108,15 +101,10 @@
var actions = document.createElement('div'); var actions = document.createElement('div');
actions.className = 'ws-row__actions'; actions.className = 'ws-row__actions';
var titles = { [['open', 'Open'], ['rename', 'Rename'], ['delete', 'Delete']].forEach(function (a) {
export: 'Export this whole workspace (scanned snapshot + classifications) to transfer to another browser or back up',
delete: 'Delete this workspace — your source files are untouched',
};
[['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];
if (titles[a[0]]) b.title = titles[a[0]];
actions.appendChild(b); actions.appendChild(b);
}); });
row.appendChild(main); row.appendChild(actions); row.appendChild(main); row.appendChild(actions);
@ -130,74 +118,9 @@
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
? 'Thats 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 = [];
@ -374,11 +297,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; },
activeName: function () { return activeMeta ? activeMeta.name : null; },
}; };
})(); })();

View file

@ -162,8 +162,8 @@
<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="Download the classifications as a filename-per-file JSON to edit (e.g. with an AI), then re-import here. NOT a workspace — no scanned tree.">Export for editing</button> <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="Load an edited classification JSON back in — replaces the current classifications. (To move a whole scanned workspace between browsers, use “Import workspace” on the welcome screen.)">Import edits</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> <input type="file" id="importDatasetInput" accept="application/json,.json" hidden>
<button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button> <button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button>
<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>
@ -208,12 +208,8 @@
<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>
<div class="ws-head__actions">
<button id="importWorkspaceBtn" class="btn btn-secondary" title="Import a whole workspace (.zddc-workspace.json) exported from another browser — restores the scanned snapshot so you don't re-scan">⭱ Import workspace</button>
<button id="newWorkspaceBtn" class="btn btn-primary">+ New workspace</button> <button id="newWorkspaceBtn" class="btn btn-primary">+ New workspace</button>
</div> </div>
<input type="file" id="importWorkspaceInput" accept="application/json,.json" hidden>
</div>
<div id="workspaceList" class="ws-list"><!-- rendered --></div> <div id="workspaceList" class="ws-list"><!-- rendered --></div>
</section> </section>

View file

@ -883,32 +883,3 @@ 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)
});

View file

@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:32:03 · 203674e</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:32:03 · 203674e</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>

View file

@ -1457,12 +1457,7 @@ body.is-elevated::after {
} }
/* ── 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 {
@ -1497,8 +1492,7 @@ body.is-elevated::after {
/* ── 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; flex-wrap: wrap; } .ws-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.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); }
@ -2239,7 +2233,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:32:03 · 203674e</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
@ -2375,8 +2369,8 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
<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="Download the classifications as a filename-per-file JSON to edit (e.g. with an AI), then re-import here. NOT a workspace — no scanned tree.">Export for editing</button> <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="Load an edited classification JSON back in — replaces the current classifications. (To move a whole scanned workspace between browsers, use “Import workspace” on the welcome screen.)">Import edits</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> <input type="file" id="importDatasetInput" accept="application/json,.json" hidden>
<button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button> <button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button>
<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>
@ -2421,12 +2415,8 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
<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>
<div class="ws-head__actions">
<button id="importWorkspaceBtn" class="btn btn-secondary" title="Import a whole workspace (.zddc-workspace.json) exported from another browser — restores the scanned snapshot so you don't re-scan">⭱ Import workspace</button>
<button id="newWorkspaceBtn" class="btn btn-primary">+ New workspace</button> <button id="newWorkspaceBtn" class="btn btn-primary">+ New workspace</button>
</div> </div>
<input type="file" id="importWorkspaceInput" accept="application/json,.json" hidden>
</div>
<div id="workspaceList" class="ws-list"><!-- rendered --></div> <div id="workspaceList" class="ws-list"><!-- rendered --></div>
</section> </section>
@ -5893,7 +5883,7 @@ X.B(E,Y);return E}return J}())
var url = URL.createObjectURL(blob); var url = URL.createObjectURL(blob);
var a = document.createElement('a'); var a = document.createElement('a');
a.href = url; a.href = url;
a.download = String(name).replace(/[^\w.-]+/g, '_') + '.zddc-classification.json'; a.download = String(name).replace(/[^\w.-]+/g, '_') + '.json';
document.body.appendChild(a); a.click(); a.remove(); document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
@ -7806,8 +7796,6 @@ X.B(E,Y);return E}return J}())
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.
@ -7819,11 +7807,6 @@ X.B(E,Y);return E}return J}())
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);
@ -7879,15 +7862,10 @@ X.B(E,Y);return E}return J}())
var actions = document.createElement('div'); var actions = document.createElement('div');
actions.className = 'ws-row__actions'; actions.className = 'ws-row__actions';
var titles = { [['open', 'Open'], ['rename', 'Rename'], ['delete', 'Delete']].forEach(function (a) {
export: 'Export this whole workspace (scanned snapshot + classifications) to transfer to another browser or back up',
delete: 'Delete this workspace — your source files are untouched',
};
[['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];
if (titles[a[0]]) b.title = titles[a[0]];
actions.appendChild(b); actions.appendChild(b);
}); });
row.appendChild(main); row.appendChild(actions); row.appendChild(main); row.appendChild(actions);
@ -7901,74 +7879,9 @@ X.B(E,Y);return E}return J}())
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
? 'Thats 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 = [];
@ -8145,12 +8058,9 @@ X.B(E,Y);return E}return J}())
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; },
activeName: function () { return activeMeta ? activeMeta.name : null; },
}; };
})(); })();

View file

@ -1671,7 +1671,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:32:03 · 203674e</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -2770,7 +2770,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:43 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:32:03 · 203674e</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 archive=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
transmittal=v0.0.27-beta · 2026-06-10 18:57:43 · e2c2d15 transmittal=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
classifier=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 classifier=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
landing=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 landing=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
form=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 form=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
tables=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 tables=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e
browse=v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15 browse=v0.0.27-beta · 2026-06-10 18:32:03 · 203674e

View file

@ -1722,7 +1722,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:57:44 · e2c2d15</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 18:32:03 · 203674e</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">