ZDDC/classifier/js/workspace.js
ZDDC e2c2d1571d ux(classifier): relabel the two JSON surfaces so their purpose is clear
Two exports for two different consumers were both just "Export". Name them:
- Classification (header, AI round-trip): "Export for editing" / "Import edits"
  — a filename-per-file JSON with no scanned tree, meant to be hand/AI-edited
  and re-imported. Download suffix is now .zddc-classification.json.
- Workspace (welcome screen, transfer/backup): "Import workspace" + a row
  "Export" tooltip spelling out it carries the snapshot + classifications for
  moving between browsers. Download suffix stays .zddc-workspace.json.
Each tooltip points at the other so they're not confused.

Also wire workspace.activeName (referenced by the dataset export but never
exported), so a classification file is named after its workspace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:56:09 -05:00

384 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ZDDC Classifier — workspace manager (Classify & Copy).
*
* A workspace = one classification project: a source directory handle, a
* snapshot of its completed scan, and the Classify & Copy map. The welcome
* screen lists them; opening one resumes instantly from the snapshot (no
* re-scan), and the map autosaves as you work. Only Copy needs the live
* filesystem (a one-click permission re-grant).
*/
(function () {
'use strict';
var els = {};
var initialized = false;
var activeId = null;
var activeMeta = null; // {id,name,rootName,createdAt,updatedAt,summary}
var activeStoredHandle = null; // the workspace's persisted source dir handle
function P() { return window.app.modules.persist; }
function C() { return window.app.modules.classify; }
function now() { return Date.now(); }
function uid() {
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
try { return window.crypto.randomUUID(); } catch (_) { /* non-secure ctx */ }
}
return 'w' + now().toString(36) + Math.floor(Math.random() * 1e9).toString(36);
}
function init() {
if (initialized) return;
initialized = true;
els = {
welcome: document.getElementById('welcomeScreen'),
list: document.getElementById('workspaceList'),
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.
var wrap = document.getElementById('workspacesSection');
if (wrap) wrap.style.display = 'none';
return;
}
if (els.newBtn) els.newBtn.addEventListener('click', newWorkspace);
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);
renderList();
}
// ── welcome list ────────────────────────────────────────────────────────
function showWelcome() {
if (els.welcome) els.welcome.classList.remove('hidden');
renderList();
}
function hideWelcome() {
if (els.welcome) els.welcome.classList.add('hidden');
}
function relTime(ts) {
if (!ts) return '';
var s = Math.max(0, Math.round((now() - ts) / 1000));
if (s < 60) return 'just now';
var m = Math.round(s / 60); if (m < 60) return m + 'm ago';
var h = Math.round(m / 60); if (h < 24) return h + 'h ago';
var d = Math.round(h / 24); return d + 'd ago';
}
function renderList() {
if (!els.list) return;
P().listWorkspaces().then(function (rows) {
els.list.textContent = '';
if (!rows.length) {
var empty = document.createElement('div');
empty.className = 'ws-empty';
empty.textContent = 'No workspaces yet. “+ New workspace” scans a folder once and saves it here.';
els.list.appendChild(empty);
return;
}
rows.forEach(function (r) { els.list.appendChild(rowEl(r)); });
});
}
function rowEl(r) {
var s = r.summary || { files: 0, done: 0, excluded: 0 };
var row = document.createElement('div');
row.className = 'ws-row';
row.dataset.id = r.id;
var main = document.createElement('div');
main.className = 'ws-row__main';
var nm = document.createElement('div'); nm.className = 'ws-row__name'; nm.textContent = r.name;
var meta = document.createElement('div'); meta.className = 'ws-row__meta';
meta.textContent = (r.rootName || '') + ' · ' + s.done + '/' + s.files + ' classified'
+ (s.excluded ? (' · ' + s.excluded + ' excluded') : '') + ' · updated ' + relTime(r.updatedAt);
main.appendChild(nm); main.appendChild(meta);
var actions = document.createElement('div');
actions.className = 'ws-row__actions';
var titles = {
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');
b.className = 'btn btn-sm ' + (a[0] === 'open' ? 'btn-primary' : 'btn-secondary');
b.dataset.act = a[0]; b.textContent = a[1];
if (titles[a[0]]) b.title = titles[a[0]];
actions.appendChild(b);
});
row.appendChild(main); row.appendChild(actions);
return row;
}
function onListClick(e) {
var btn = e.target.closest('[data-act]');
if (!btn) return;
var row = btn.closest('.ws-row');
var id = row && row.dataset.id;
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
? '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 ───────────────────────────────────────────────────────────
function allFiles() {
var out = [];
(function walk(ns) { (ns || []).forEach(function (n) { (n.files || []).forEach(function (f) { out.push(f); }); walk(n.children); }); })(window.app.folderTree || []);
return out;
}
function summary() {
var s = C().stats(allFiles());
return { files: s.total, done: s.done, excluded: s.excluded };
}
// ── create / open / rename / delete ─────────────────────────────────────
async function newWorkspace() {
if (!window.showDirectoryPicker) {
window.zddc.toast('Workspaces need the File System Access API (use Chromium).', 'error');
return;
}
var dir;
try { dir = await window.showDirectoryPicker(); }
catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open folder — ' + (e.message || e), 'error'); return; }
var name = prompt('Name this workspace:', dir.name);
if (name === null) name = dir.name;
name = name.trim() || dir.name;
window.app.rootHandle = dir;
activeStoredHandle = dir;
window.app.modules.app.enterAppShell();
window.app.modules.app.setMode('classify');
hideWelcome();
activeId = uid();
activeMeta = { id: activeId, name: name, rootName: dir.name, createdAt: now(), updatedAt: now(), summary: { files: 0, done: 0, excluded: 0 } };
// Create the record UP FRONT so an interrupted scan survives and resumes.
await saveSnapshotFull();
updateConnectUI();
// Periodically persist the partial snapshot during the (slow) scan, so an
// interruption resumes from where it left off instead of starting over.
var iv = setInterval(saveSnapshotFull, 5000);
try { await window.app.modules.scanner.scanDirectory(dir); }
finally { clearInterval(iv); saveSnapshotFull(); }
}
async function openWorkspace(id) {
var rec = await P().getWorkspace(id);
var rows = await P().listWorkspaces();
var meta = rows.filter(function (r) { return r.id === id; })[0];
if (!rec || !meta) { window.zddc.toast('Could not load that workspace.', 'error'); return; }
activeId = id;
activeMeta = meta;
activeStoredHandle = rec.rootHandle || null;
window.app.rootHandle = null; // not connected until reconnect
window.app.modules.app.enterAppShell();
window.app.modules.scanner.loadSnapshot(rec.tree || []);
C().load(rec.classify || {});
window.app.modules.app.setMode('classify');
hideWelcome();
// Offer to reconnect the source directory (needed to preview, copy, or
// finish an interrupted scan). Silent if permission is already granted.
await tryReconnect(true);
updateConnectUI();
}
// Persist the full workspace (meta + snapshot + map + source handle).
function saveSnapshotFull() {
if (!activeId || !activeMeta) return Promise.resolve();
activeMeta.updatedAt = now();
activeMeta.summary = summary();
return P().putWorkspace(activeMeta, {
id: activeId,
rootHandle: window.app.rootHandle || activeStoredHandle || null,
tree: window.app.modules.scanner.snapshotTree(),
classify: C().serialize(),
});
}
// Connect (or reconnect) the source directory. silentOnly=true never shows a
// permission prompt or picker — it only adopts an already-granted handle and
// otherwise nudges the user to click "Connect directory".
async function tryReconnect(silentOnly) {
var h = activeStoredHandle;
if (h && typeof h.queryPermission === 'function') {
var p = 'denied';
try { p = await h.queryPermission({ mode: 'read' }); } catch (_) { /* ignore */ }
if (p === 'granted') { window.app.rootHandle = h; return afterConnect(); }
if (!silentOnly) {
var p2 = 'denied';
try { p2 = await h.requestPermission({ mode: 'read' }); } catch (_) { /* ignore */ }
if (p2 === 'granted') { window.app.rootHandle = h; return afterConnect(); }
}
}
if (silentOnly) {
if (!window.app.rootHandle && activeId) {
window.zddc.toast('This workspaces source directory isnt connected — click “Connect directory” to preview, copy, or finish scanning.', 'info', { durationMs: 8000 });
}
return false;
}
// Explicit: no usable stored handle (or permission denied) → let the user pick.
if (!window.showDirectoryPicker) { window.zddc.toast('Connecting a directory needs the File System Access API.', 'error'); return false; }
try {
var picked = await window.showDirectoryPicker();
window.app.rootHandle = picked;
activeStoredHandle = picked;
return afterConnect();
} catch (e) {
if (e.name !== 'AbortError') window.zddc.toast('Could not connect directory — ' + (e.message || e), 'error');
return false;
}
}
async function afterConnect() {
updateConnectUI();
// Resume any still-pending folders now that we have the handle.
var did = await window.app.modules.scanner.resumeScan(window.app.rootHandle);
saveSnapshotFull(); // persist refreshed snapshot + the (re-granted) handle
return true;
}
function updateConnectUI() {
if (!els.connectBtn) return;
var show = !!activeId && !window.app.rootHandle;
els.connectBtn.hidden = !show;
}
function renameWorkspace(id) {
P().listWorkspaces().then(function (rows) {
var meta = rows.filter(function (r) { return r.id === id; })[0];
if (!meta) return;
var name = prompt('Rename workspace:', meta.name);
if (!name || !name.trim()) return;
meta.name = name.trim(); meta.updatedAt = now();
if (activeMeta && activeMeta.id === id) activeMeta.name = meta.name;
P().putWorkspace(meta, null).then(renderList);
});
}
function deleteWorkspace(id) {
if (!confirm('Delete this workspace? The map and snapshot are removed — your source files are untouched.')) return;
if (activeId === id) { activeId = null; activeMeta = null; }
P().deleteWorkspace(id).then(renderList);
}
// ── autosave (debounced) ────────────────────────────────────────────────
var saveTimer = null;
function scheduleAutosave() {
if (!activeId || !activeMeta) return;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
saveTimer = null;
activeMeta.updatedAt = now();
activeMeta.summary = summary();
// classify-only put: tree omitted → the stored snapshot is preserved.
P().putWorkspace(activeMeta, { id: activeId, classify: C().serialize() });
}, 500);
}
// Called after a "Refresh from disk" rescan — re-persist the snapshot for
// the active workspace (the path-keyed map carries over automatically).
function onRescanned() {
if (!activeId || !activeMeta) return;
activeMeta.updatedAt = now();
activeMeta.summary = summary();
P().putWorkspace(activeMeta, {
id: activeId,
tree: window.app.modules.scanner.snapshotTree(),
classify: C().serialize(),
});
}
window.app.modules.workspace = {
init: init,
showWelcome: showWelcome,
newWorkspace: newWorkspace,
openWorkspace: openWorkspace,
exportWorkspace: exportWorkspace,
importWorkspace: importWorkspace,
onRescanned: onRescanned,
renderList: renderList,
activeId: function () { return activeId; },
activeName: function () { return activeMeta ? activeMeta.name : null; },
};
})();