ZDDC/classifier/js/persist.js
ZDDC a8f116734d feat(classifier): Classify & Copy state model + persistence (phase 1)
Foundation for the non-destructive map+copy workflow: source stays read-only,
files are mapped onto two orthogonal target trees, a later step copies renamed
copies to a separate output dir.

- classify.js: the single source of truth. assignments map keyed by
  source-relative path (survives re-pick); tracking tree (positional: ancestors
  joined '-' = tracking number, immediate parent 'REV (STATUS)' leaf = rev+status,
  title from original name) and transmittal tree (<party>/{received,issued}/<bin>).
  deriveTarget() computes filename + output path + validation purely; pub/sub +
  debounced autosave; node CRUD with dangling-placement cleanup.
- persist.js: IndexedDB store of the serialized map + the source
  FileSystemDirectoryHandle, with queryPermission/requestPermission re-grant on
  reload and a re-pick fallback.
- tests/classify.spec.js: 9 in-page unit tests for the derive/assignment logic
  (no FS Access needed) — tracking join, leaf REV (STATUS) parse incl. invalid
  status, title derivation/override, transmittal path composition, exclude,
  cascade delete.

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

98 lines
4.4 KiB
JavaScript

/**
* ZDDC Classifier — persistence for the Classify & Copy map.
*
* The assignment map and target trees, plus the picked source directory
* HANDLE, are stored in IndexedDB (localStorage can't hold a
* FileSystemDirectoryHandle; the handle is structured-cloneable, so IndexedDB
* can). On reload we re-request permission on the stored handle — a single
* click re-grants access, no re-navigation. If that fails (permission denied,
* or a different machine), the caller falls back to a fresh pick and the map
* re-attaches by relative path.
*
* NOTE: a stored handle is only valid in the same browser profile on the same
* machine. The map keys on source-relative paths, so re-picking the same tree
* elsewhere still re-attaches — that's the warning shown to the user on save.
*/
(function () {
'use strict';
var DB_NAME = 'zddc-classifier';
var STORE = 'kv';
var K_STATE = 'classify-state';
var K_HANDLE = 'source-handle';
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, 1);
req.onupgradeneeded = function () {
var db = req.result;
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
}
function tx(mode, fn) {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
var t = db.transaction(STORE, mode);
var store = t.objectStore(STORE);
var out = fn(store);
t.oncomplete = function () { resolve(out && out.result !== undefined ? out.result : out); };
t.onerror = function () { reject(t.error); };
t.onabort = function () { reject(t.error); };
});
});
}
function put(key, value) { return tx('readwrite', function (s) { return s.put(value, key); }); }
function getValue(key) {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
var t = db.transaction(STORE, 'readonly');
var req = t.objectStore(STORE).get(key);
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
});
}
// ── public API ─────────────────────────────────────────────────────────
function saveState(obj) { return put(K_STATE, obj).catch(function (e) { console.warn('persist.saveState', e); }); }
function loadState() { return getValue(K_STATE).catch(function () { return null; }); }
function saveSourceHandle(handle) {
// Real FileSystemDirectoryHandle only; the HTTP polyfill handle is not
// worth persisting (server mode re-detects the root on load).
if (!handle || handle.isHttp) return Promise.resolve();
return put(K_HANDLE, handle).catch(function (e) { console.warn('persist.saveHandle', e); });
}
function loadSourceHandle() { return getValue(K_HANDLE).catch(function () { return null; }); }
function clearAll() {
return tx('readwrite', function (s) { s.delete(K_STATE); s.delete(K_HANDLE); })
.catch(function (e) { console.warn('persist.clear', e); });
}
// Re-acquire read permission on a stored handle. Returns true if usable.
function verifyPermission(handle) {
if (!handle || typeof handle.queryPermission !== 'function') return Promise.resolve(false);
var opts = { mode: '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,
saveState: saveState, loadState: loadState,
saveSourceHandle: saveSourceHandle, loadSourceHandle: loadSourceHandle,
verifyPermission: verifyPermission, clearAll: clearAll,
};
})();