ZDDC/classifier/js/persist.js
ZDDC 1d09abdc8b feat(classifier): workspaces — scan-once, resume from snapshot (phase 6)
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>
2026-06-09 15:07:40 -05:00

128 lines
5.8 KiB
JavaScript

/**
* ZDDC Classifier — workspace persistence (IndexedDB).
*
* A "workspace" is one classification project: the picked source directory
* HANDLE, a SNAPSHOT of its completed scan (folder/file structure — names and
* paths only, no contents), and the Classify & Copy map (assignments + target
* trees). Scan once, resume instantly across sessions without re-walking the
* (often cloud-backed, high-latency) source.
*
* Two object stores so the welcome list stays cheap:
* - 'index' (tiny): { id, name, rootName, createdAt, updatedAt, summary }
* - 'data' (large): { id, rootHandle, tree, classify }
*
* A FileSystemDirectoryHandle is structured-cloneable, so IndexedDB can hold
* it; on reuse we re-request permission (one click). It's only needed at COPY
* time — opening a workspace runs entirely from the snapshot.
*/
(function () {
'use strict';
var DB_NAME = 'zddc-classifier';
var DB_VERSION = 2;
var IDX = 'index';
var DATA = 'data';
var available = typeof indexedDB !== 'undefined';
function openDB() {
return new Promise(function (resolve, reject) {
if (!available) { reject(new Error('IndexedDB unavailable')); return; }
var req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = function () {
var db = req.result;
// 'kv' (v1, single implicit map) is intentionally left behind.
if (!db.objectStoreNames.contains(IDX)) db.createObjectStore(IDX, { keyPath: 'id' });
if (!db.objectStoreNames.contains(DATA)) db.createObjectStore(DATA, { keyPath: 'id' });
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
}
function reqP(req) {
return new Promise(function (resolve, reject) {
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
}
// ── public API ─────────────────────────────────────────────────────────
// Light metadata for every workspace (for the welcome list). Sorted newest
// first. Never loads the big snapshot.
function listWorkspaces() {
return openDB().then(function (db) {
return reqP(db.transaction(IDX, 'readonly').objectStore(IDX).getAll());
}).then(function (rows) {
(rows || []).sort(function (a, b) { return (b.updatedAt || 0) - (a.updatedAt || 0); });
return rows || [];
}).catch(function (e) { console.warn('persist.list', e); return []; });
}
// Full data record for one workspace: { id, rootHandle, tree, classify }.
function getWorkspace(id) {
return openDB().then(function (db) {
return reqP(db.transaction(DATA, 'readonly').objectStore(DATA).get(id));
}).catch(function (e) { console.warn('persist.get', e); return null; });
}
// Save (create or update). meta = {id,name,rootName,createdAt,updatedAt,summary};
// data = {id, rootHandle, tree, classify}. tree may be omitted on a classify-
// only autosave (the snapshot rarely changes) — then we preserve the stored one.
function putWorkspace(meta, data) {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
var t = db.transaction([IDX, DATA], 'readwrite');
t.oncomplete = function () { resolve(); };
t.onerror = function () { reject(t.error); };
t.objectStore(IDX).put(meta);
var ds = t.objectStore(DATA);
if (data && typeof data.tree !== 'undefined') {
ds.put(data);
} else if (data) {
// Merge classify/rootHandle without clobbering the snapshot.
var g = ds.get(meta.id);
g.onsuccess = function () {
var existing = g.result || { id: meta.id };
if (typeof data.rootHandle !== 'undefined') existing.rootHandle = data.rootHandle;
if (typeof data.classify !== 'undefined') existing.classify = data.classify;
existing.id = meta.id;
ds.put(existing);
};
}
});
}).catch(function (e) { console.warn('persist.put', e); });
}
function deleteWorkspace(id) {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
var t = db.transaction([IDX, DATA], 'readwrite');
t.oncomplete = function () { resolve(); };
t.onerror = function () { reject(t.error); };
t.objectStore(IDX).delete(id);
t.objectStore(DATA).delete(id);
});
}).catch(function (e) { console.warn('persist.delete', e); });
}
// Re-acquire read permission on a stored handle (one click). true if usable.
function verifyPermission(handle, write) {
if (!handle || typeof handle.queryPermission !== 'function') return Promise.resolve(false);
var opts = { mode: write ? 'readwrite' : 'read' };
return handle.queryPermission(opts).then(function (p) {
if (p === 'granted') return true;
return handle.requestPermission(opts).then(function (p2) { return p2 === 'granted'; });
}).catch(function () { return false; });
}
window.app.modules.persist = {
available: available,
listWorkspaces: listWorkspaces,
getWorkspace: getWorkspace,
putWorkspace: putWorkspace,
deleteWorkspace: deleteWorkspace,
verifyPermission: verifyPermission,
};
})();