The classifier re-scanned the source on every session; on cloud-backed mounts (OneDrive/Samba) that's minutes of per-op latency. Workspaces fix it: scan a folder ONCE, snapshot the completed tree, and resume instantly — all classification runs on the data model; the filesystem is only touched at copy. - persist.js v2: multi-workspace IndexedDB (tiny 'index' store for the welcome list + 'data' store holding the source handle, tree snapshot, and map). DB v2. - scanner.js: snapshotTree()/loadSnapshot() (compact, handle-less, marked done, totals recomputed) + lazy resolveFileHandle/resolveDirHandle from the root. - workspace.js: welcome manager (new/open/rename/delete), debounced autosave of the active workspace, 'Refresh from disk' (re-scan → re-snapshot, path-keyed map carries over). New workspace = the one slow full scan; reopen = instant. - copy.js: resolves snapshot files' handles from the workspace root with a one-click read permission re-grant; missing-on-disk files surface as errors. - app.js: enterAppShell() shared by rename/workspace flows; exposes setMode; classify.js decoupled from persistence. - template/css: welcome workspace list + header 'Workspaces' button. - tests: snapshot round-trip, persist CRUD + classify-only-preserves-tree, copy-from-snapshot via mock root handle (28 classify/classifier tests green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
230 lines
9.7 KiB
JavaScript
230 lines
9.7 KiB
JavaScript
/**
|
|
* 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}
|
|
|
|
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'),
|
|
};
|
|
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.list) els.list.addEventListener('click', onListClick);
|
|
|
|
// 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';
|
|
[['open', 'Open'], ['rename', 'Rename'], ['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];
|
|
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 === 'delete') deleteWorkspace(id);
|
|
}
|
|
|
|
// ── 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; }
|
|
|
|
window.app.rootHandle = dir;
|
|
window.app.modules.app.enterAppShell();
|
|
// The one slow pass: a full scan (then never again for this workspace).
|
|
await window.app.modules.scanner.scanDirectory(dir);
|
|
|
|
var name = prompt('Name this workspace:', dir.name);
|
|
if (name === null) name = dir.name;
|
|
name = name.trim() || dir.name;
|
|
|
|
activeId = uid();
|
|
activeMeta = { id: activeId, name: name, rootName: dir.name, createdAt: now(), updatedAt: now(), summary: summary() };
|
|
await P().putWorkspace(activeMeta, {
|
|
id: activeId, rootHandle: dir,
|
|
tree: window.app.modules.scanner.snapshotTree(),
|
|
classify: C().serialize(),
|
|
});
|
|
window.app.modules.app.setMode('classify');
|
|
}
|
|
|
|
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;
|
|
window.app.rootHandle = rec.rootHandle || null;
|
|
window.app.modules.app.enterAppShell();
|
|
window.app.modules.scanner.loadSnapshot(rec.tree || []);
|
|
C().load(rec.classify || {});
|
|
window.app.modules.app.setMode('classify');
|
|
hideWelcome();
|
|
}
|
|
|
|
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,
|
|
onRescanned: onRescanned,
|
|
renderList: renderList,
|
|
activeId: function () { return activeId; },
|
|
};
|
|
})();
|