ZDDC/classifier/js/workspace.js
ZDDC 01b01f8f7a feat(classifier): welcome rewrite + resumable scan + reconnect on restore
- Welcome: drop the 'absorbed into Browse' notice; bigger, inviting intro with
  a two-method tutorial (Classify & copy — recommended/non-destructive; Rename
  in place — edits files) and a OneDrive 'keep on device' tip.
- Resumable scan: the snapshot now records per-folder scan state, the workspace
  record is created up front, and the partial snapshot is persisted every 5s
  during the (slow) scan. scanner.resumeScan() resolves handles for only the
  still-pending folders and drains them — so an interrupted scan picks up where
  it left off instead of starting over.
- Reconnect on restore: opening a workspace no longer assumes the source is
  connected; a header 'Connect directory' button (and a prompt) re-grants the
  persisted handle in one click or lets you re-pick it. Until connected you can
  still edit the data model; connecting also resumes any pending scan.
- Tests: resume-scan via mock root handle (31 classify/classifier green).

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

304 lines
13 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'),
};
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);
// 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; }
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,
onRescanned: onRescanned,
renderList: renderList,
activeId: function () { return activeId; },
};
})();