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>
This commit is contained in:
parent
05fc3b69dd
commit
1d09abdc8b
10 changed files with 611 additions and 114 deletions
|
|
@ -53,6 +53,7 @@ concat_files \
|
||||||
"js/store.js" \
|
"js/store.js" \
|
||||||
"js/persist.js" \
|
"js/persist.js" \
|
||||||
"js/classify.js" \
|
"js/classify.js" \
|
||||||
|
"js/workspace.js" \
|
||||||
"js/dnd.js" \
|
"js/dnd.js" \
|
||||||
"js/validator.js" \
|
"js/validator.js" \
|
||||||
"js/scanner.js" \
|
"js/scanner.js" \
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,24 @@
|
||||||
margin-left: 1.5rem;
|
margin-left: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Workspaces (welcome manager) ──────────────────────────────────────── */
|
||||||
|
.workspaces { text-align: left; margin: 1rem 0; }
|
||||||
|
.ws-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||||
|
.ws-head h3 { margin: 0; font-size: 1.05rem; }
|
||||||
|
.ws-intro { font-size: 0.85rem; color: var(--text-muted); margin: 0.4rem 0 0.75rem; }
|
||||||
|
.ws-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
.ws-empty { color: var(--text-muted); font-size: 0.85rem; padding: 0.75rem; border: 1px dashed var(--border); border-radius: var(--radius); }
|
||||||
|
.ws-row {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem;
|
||||||
|
padding: 0.6rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.ws-row__main { flex: 1; min-width: 0; }
|
||||||
|
.ws-row__name { font-weight: 600; }
|
||||||
|
.ws-row__meta { font-size: 0.78rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ws-row__actions { display: flex; gap: 0.3rem; flex-shrink: 0; }
|
||||||
|
.ws-or { font-size: 0.82rem; color: var(--text-muted); margin: 1rem 0 0.5rem; }
|
||||||
|
|
||||||
/* ── Workflow mode switch (header) ─────────────────────────────────────── */
|
/* ── Workflow mode switch (header) ─────────────────────────────────────── */
|
||||||
.mode-switch {
|
.mode-switch {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
|
||||||
|
|
@ -33,22 +33,8 @@
|
||||||
cacheDOMElements();
|
cacheDOMElements();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
|
||||||
// Restore a saved Classify & Copy map (placements + target trees). It
|
// Workspace manager (renders the welcome list, owns new/open/autosave).
|
||||||
// keys on source-relative paths, so it re-attaches once the SAME source
|
if (app.modules.workspace) app.modules.workspace.init();
|
||||||
// directory is opened again — the source handle itself can't be opened
|
|
||||||
// without a user gesture, so we remind the user to re-pick it.
|
|
||||||
if (app.modules.persist && app.modules.persist.available) {
|
|
||||||
app.modules.persist.loadState().then(function (s) {
|
|
||||||
if (!s) return;
|
|
||||||
var has = Object.keys(s.assignments || {}).length
|
|
||||||
|| (s.trackingTree || []).length || (s.transmittalTree || []).length;
|
|
||||||
if (!has) return;
|
|
||||||
app.modules.classify.load(s);
|
|
||||||
if (window.zddc && window.zddc.toast) {
|
|
||||||
window.zddc.toast('Restored your Classify & Copy map from this browser. Open the SAME source directory and switch to “Classify & Copy” to continue.', 'info', { durationMs: 9000 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser-compatibility branch:
|
// Browser-compatibility branch:
|
||||||
// HTTP mode (served by zddc-server) — works everywhere; the
|
// HTTP mode (served by zddc-server) — works everywhere; the
|
||||||
|
|
@ -368,31 +354,32 @@
|
||||||
/**
|
/**
|
||||||
* Open a directory handle and initialize the application
|
* Open a directory handle and initialize the application
|
||||||
*/
|
*/
|
||||||
async function openDirectory(dirHandle) {
|
// Show the main UI and initialize the per-tool modules ONCE. Shared by the
|
||||||
app.rootHandle = dirHandle;
|
// legacy rename open and the workspace open/new flows (the latter scan or
|
||||||
// Remember the source handle so a later session can re-grant access in
|
// load a snapshot themselves).
|
||||||
// one click (the map re-attaches by relative path either way).
|
var shellInited = false;
|
||||||
if (app.modules.persist) app.modules.persist.saveSourceHandle(dirHandle);
|
function enterAppShell() {
|
||||||
|
|
||||||
// Hide welcome screen and show main UI
|
|
||||||
hideWelcomeScreen();
|
hideWelcomeScreen();
|
||||||
showMainUI();
|
showMainUI();
|
||||||
|
if (!shellInited) {
|
||||||
|
shellInited = true;
|
||||||
|
app.modules.spreadsheet.init(); // Subscribe to store
|
||||||
|
app.modules.selection.init();
|
||||||
|
app.modules.preview.init(); // After selection so it can listen for rowfocused
|
||||||
|
app.modules.resize.init();
|
||||||
|
app.modules.filter.init();
|
||||||
|
app.modules.sort.init();
|
||||||
|
app.modules.tree.setupKeyboardShortcuts();
|
||||||
|
if (app.modules.targetTree) app.modules.targetTree.init();
|
||||||
|
}
|
||||||
|
if (app.dom.refreshHeaderBtn) app.dom.refreshHeaderBtn.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize modules BEFORE scanning (so they're ready for store updates)
|
async function openDirectory(dirHandle) {
|
||||||
app.modules.spreadsheet.init(); // Subscribe to store
|
app.rootHandle = dirHandle;
|
||||||
app.modules.selection.init();
|
enterAppShell();
|
||||||
app.modules.preview.init(); // After selection so it can listen for rowfocused
|
|
||||||
app.modules.resize.init();
|
|
||||||
app.modules.filter.init();
|
|
||||||
app.modules.sort.init();
|
|
||||||
app.modules.tree.setupKeyboardShortcuts();
|
|
||||||
if (app.modules.targetTree) app.modules.targetTree.init();
|
|
||||||
|
|
||||||
// Now scan directory (this will trigger store updates and renders)
|
// Now scan directory (this will trigger store updates and renders)
|
||||||
await app.modules.scanner.scanDirectory(dirHandle);
|
await app.modules.scanner.scanDirectory(dirHandle);
|
||||||
|
|
||||||
// Show refresh button now that a directory is loaded
|
|
||||||
if (app.dom.refreshHeaderBtn) { app.dom.refreshHeaderBtn.classList.remove('hidden'); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -405,6 +392,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// A snapshot-loaded workspace handle needs its read permission
|
||||||
|
// re-granted before we can enumerate it again.
|
||||||
|
if (app.modules.persist && app.modules.persist.verifyPermission) {
|
||||||
|
const ok = await app.modules.persist.verifyPermission(app.rootHandle, false);
|
||||||
|
if (!ok) {
|
||||||
|
if (window.zddc) window.zddc.toast('Permission to read the source directory was denied.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clear current data
|
// Clear current data
|
||||||
app.folderTree = [];
|
app.folderTree = [];
|
||||||
app.selectedFolders.clear();
|
app.selectedFolders.clear();
|
||||||
|
|
@ -416,6 +413,12 @@
|
||||||
// Rescan directory (modules already initialized, just rescan)
|
// Rescan directory (modules already initialized, just rescan)
|
||||||
await app.modules.scanner.scanDirectory(app.rootHandle);
|
await app.modules.scanner.scanDirectory(app.rootHandle);
|
||||||
|
|
||||||
|
// For a workspace, persist the refreshed snapshot (additive: the
|
||||||
|
// path-keyed map re-attaches; new files appear unassigned).
|
||||||
|
if (app.modules.workspace && app.modules.workspace.onRescanned) {
|
||||||
|
app.modules.workspace.onRescanned();
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error refreshing directory:', err);
|
console.error('Error refreshing directory:', err);
|
||||||
alert('Error refreshing directory: ' + err.message);
|
alert('Error refreshing directory: ' + err.message);
|
||||||
|
|
@ -551,7 +554,9 @@
|
||||||
|
|
||||||
// Export functions for use by other modules
|
// Export functions for use by other modules
|
||||||
app.modules.app = {
|
app.modules.app = {
|
||||||
updateStats
|
updateStats,
|
||||||
|
setMode,
|
||||||
|
enterAppShell
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
// Initialize when DOM is ready
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@
|
||||||
var notifyScheduled = false;
|
var notifyScheduled = false;
|
||||||
function notify() {
|
function notify() {
|
||||||
// Coalesce bursts (a group-drop touches many keys) into one render.
|
// Coalesce bursts (a group-drop touches many keys) into one render.
|
||||||
|
// Listeners include the target/source re-renders AND the workspace
|
||||||
|
// autosave (workspace.js subscribes) — persistence is not this
|
||||||
|
// module's concern.
|
||||||
if (notifyScheduled) return;
|
if (notifyScheduled) return;
|
||||||
notifyScheduled = true;
|
notifyScheduled = true;
|
||||||
Promise.resolve().then(function () {
|
Promise.resolve().then(function () {
|
||||||
|
|
@ -54,21 +57,9 @@
|
||||||
for (var i = 0; i < listeners.length; i++) {
|
for (var i = 0; i < listeners.length; i++) {
|
||||||
try { listeners[i](); } catch (e) { console.error('classify listener', e); }
|
try { listeners[i](); } catch (e) { console.error('classify listener', e); }
|
||||||
}
|
}
|
||||||
scheduleSave();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── persistence hook (debounced) ─────────────────────────────────────────
|
|
||||||
var saveTimer = null;
|
|
||||||
function scheduleSave() {
|
|
||||||
if (!window.app.modules.persist) return;
|
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
|
||||||
saveTimer = setTimeout(function () {
|
|
||||||
saveTimer = null;
|
|
||||||
try { window.app.modules.persist.saveState(serialize()); } catch (e) { console.warn('persist', e); }
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── source keys + title derivation ───────────────────────────────────────
|
// ── source keys + title derivation ───────────────────────────────────────
|
||||||
function stripRoot(p) {
|
function stripRoot(p) {
|
||||||
var i = (p || '').indexOf('/');
|
var i = (p || '').indexOf('/');
|
||||||
|
|
@ -429,6 +420,6 @@
|
||||||
// persistence
|
// persistence
|
||||||
serialize: serialize, load: load, reset: reset,
|
serialize: serialize, load: load, reset: reset,
|
||||||
getOutputName: function () { return state.outputName; },
|
getOutputName: function () { return state.outputName; },
|
||||||
setOutputName: function (n) { state.outputName = n || null; scheduleSave(); },
|
setOutputName: function (n) { state.outputName = n || null; notify(); },
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -90,13 +90,21 @@
|
||||||
|
|
||||||
async function sameContent(existingHandle, srcFileObj) {
|
async function sameContent(existingHandle, srcFileObj) {
|
||||||
var ef = await existingHandle.getFile();
|
var ef = await existingHandle.getFile();
|
||||||
var sf = await srcFileObj.handle.getFile();
|
var sf = await (await srcHandle(srcFileObj)).getFile();
|
||||||
if (ef.size !== sf.size) return false;
|
if (ef.size !== sf.size) return false;
|
||||||
var a = await window.zddc.crypto.sha256File(ef);
|
var a = await window.zddc.crypto.sha256File(ef);
|
||||||
var b = await window.zddc.crypto.sha256File(sf);
|
var b = await window.zddc.crypto.sha256File(sf);
|
||||||
return a === b;
|
return a === b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a source file's live handle. Fresh-scan files already carry one;
|
||||||
|
// snapshot-loaded files resolve lazily from the workspace root by path.
|
||||||
|
async function srcHandle(fileObj) {
|
||||||
|
if (fileObj.handle) return fileObj.handle;
|
||||||
|
if (!window.app.rootHandle) throw new Error('source directory not connected');
|
||||||
|
return window.app.modules.scanner.resolveFileHandle(window.app.rootHandle, fileObj);
|
||||||
|
}
|
||||||
|
|
||||||
// Copy one file. Returns 'copied' | 'skipped' (identical) | 'differ' (left alone).
|
// Copy one file. Returns 'copied' | 'skipped' (identical) | 'differ' (left alone).
|
||||||
async function copyOne(out, p) {
|
async function copyOne(out, p) {
|
||||||
var dir = await ensureDir(out, p.d.outPath);
|
var dir = await ensureDir(out, p.d.outPath);
|
||||||
|
|
@ -105,7 +113,7 @@
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return (await sameContent(existing, p.file)) ? 'skipped' : 'differ';
|
return (await sameContent(existing, p.file)) ? 'skipped' : 'differ';
|
||||||
}
|
}
|
||||||
var srcFile = await p.file.handle.getFile(); // READ source (never write it)
|
var srcFile = await (await srcHandle(p.file)).getFile(); // READ source (never write it)
|
||||||
var fh = await dir.getFileHandle(p.d.filename, { create: true });
|
var fh = await dir.getFileHandle(p.d.filename, { create: true });
|
||||||
var w = await fh.createWritable();
|
var w = await fh.createWritable();
|
||||||
await w.write(srcFile);
|
await w.write(srcFile);
|
||||||
|
|
@ -131,6 +139,17 @@
|
||||||
}
|
}
|
||||||
if (!todo.length) return;
|
if (!todo.length) return;
|
||||||
|
|
||||||
|
// Snapshot-loaded files have no live handle — re-grant read on the
|
||||||
|
// workspace source directory (one click) before copying.
|
||||||
|
if (todo.some(function (p) { return !p.file.handle; })) {
|
||||||
|
if (!window.app.rootHandle) {
|
||||||
|
toast('The source directory isn’t connected. Re-open the workspace to reconnect it.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var srcOk = await window.app.modules.persist.verifyPermission(window.app.rootHandle, false);
|
||||||
|
if (!srcOk) { toast('Permission to read the source directory was denied.', 'error'); return; }
|
||||||
|
}
|
||||||
|
|
||||||
var out = outputHandle || await chooseOutput();
|
var out = outputHandle || await chooseOutput();
|
||||||
if (!out) return;
|
if (!out) return;
|
||||||
if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\nThe source directory is not modified.')) return;
|
if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\nThe source directory is not modified.')) return;
|
||||||
|
|
|
||||||
|
|
@ -1,88 +1,116 @@
|
||||||
/**
|
/**
|
||||||
* ZDDC Classifier — persistence for the Classify & Copy map.
|
* ZDDC Classifier — workspace persistence (IndexedDB).
|
||||||
*
|
*
|
||||||
* The assignment map and target trees, plus the picked source directory
|
* A "workspace" is one classification project: the picked source directory
|
||||||
* HANDLE, are stored in IndexedDB (localStorage can't hold a
|
* HANDLE, a SNAPSHOT of its completed scan (folder/file structure — names and
|
||||||
* FileSystemDirectoryHandle; the handle is structured-cloneable, so IndexedDB
|
* paths only, no contents), and the Classify & Copy map (assignments + target
|
||||||
* can). On reload we re-request permission on the stored handle — a single
|
* trees). Scan once, resume instantly across sessions without re-walking the
|
||||||
* click re-grants access, no re-navigation. If that fails (permission denied,
|
* (often cloud-backed, high-latency) source.
|
||||||
* 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
|
* Two object stores so the welcome list stays cheap:
|
||||||
* machine. The map keys on source-relative paths, so re-picking the same tree
|
* - 'index' (tiny): { id, name, rootName, createdAt, updatedAt, summary }
|
||||||
* elsewhere still re-attaches — that's the warning shown to the user on save.
|
* - '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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var DB_NAME = 'zddc-classifier';
|
var DB_NAME = 'zddc-classifier';
|
||||||
var STORE = 'kv';
|
var DB_VERSION = 2;
|
||||||
var K_STATE = 'classify-state';
|
var IDX = 'index';
|
||||||
var K_HANDLE = 'source-handle';
|
var DATA = 'data';
|
||||||
|
|
||||||
var available = typeof indexedDB !== 'undefined';
|
var available = typeof indexedDB !== 'undefined';
|
||||||
|
|
||||||
function openDB() {
|
function openDB() {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
if (!available) { reject(new Error('IndexedDB unavailable')); return; }
|
if (!available) { reject(new Error('IndexedDB unavailable')); return; }
|
||||||
var req = indexedDB.open(DB_NAME, 1);
|
var req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
req.onupgradeneeded = function () {
|
req.onupgradeneeded = function () {
|
||||||
var db = req.result;
|
var db = req.result;
|
||||||
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
|
// '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.onsuccess = function () { resolve(req.result); };
|
||||||
req.onerror = function () { reject(req.error); };
|
req.onerror = function () { reject(req.error); };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function tx(mode, fn) {
|
function reqP(req) {
|
||||||
return openDB().then(function (db) {
|
return new Promise(function (resolve, reject) {
|
||||||
return new Promise(function (resolve, reject) {
|
req.onsuccess = function () { resolve(req.result); };
|
||||||
var t = db.transaction(STORE, mode);
|
req.onerror = function () { reject(req.error); };
|
||||||
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 ─────────────────────────────────────────────────────────
|
// ── 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) {
|
// Light metadata for every workspace (for the welcome list). Sorted newest
|
||||||
// Real FileSystemDirectoryHandle only; the HTTP polyfill handle is not
|
// first. Never loads the big snapshot.
|
||||||
// worth persisting (server mode re-detects the root on load).
|
function listWorkspaces() {
|
||||||
if (!handle || handle.isHttp) return Promise.resolve();
|
return openDB().then(function (db) {
|
||||||
return put(K_HANDLE, handle).catch(function (e) { console.warn('persist.saveHandle', e); });
|
return reqP(db.transaction(IDX, 'readonly').objectStore(IDX).getAll());
|
||||||
}
|
}).then(function (rows) {
|
||||||
function loadSourceHandle() { return getValue(K_HANDLE).catch(function () { return null; }); }
|
(rows || []).sort(function (a, b) { return (b.updatedAt || 0) - (a.updatedAt || 0); });
|
||||||
|
return rows || [];
|
||||||
function clearAll() {
|
}).catch(function (e) { console.warn('persist.list', e); return []; });
|
||||||
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.
|
// Full data record for one workspace: { id, rootHandle, tree, classify }.
|
||||||
function verifyPermission(handle) {
|
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);
|
if (!handle || typeof handle.queryPermission !== 'function') return Promise.resolve(false);
|
||||||
var opts = { mode: 'read' };
|
var opts = { mode: write ? 'readwrite' : 'read' };
|
||||||
return handle.queryPermission(opts).then(function (p) {
|
return handle.queryPermission(opts).then(function (p) {
|
||||||
if (p === 'granted') return true;
|
if (p === 'granted') return true;
|
||||||
return handle.requestPermission(opts).then(function (p2) { return p2 === 'granted'; });
|
return handle.requestPermission(opts).then(function (p2) { return p2 === 'granted'; });
|
||||||
|
|
@ -91,8 +119,10 @@
|
||||||
|
|
||||||
window.app.modules.persist = {
|
window.app.modules.persist = {
|
||||||
available: available,
|
available: available,
|
||||||
saveState: saveState, loadState: loadState,
|
listWorkspaces: listWorkspaces,
|
||||||
saveSourceHandle: saveSourceHandle, loadSourceHandle: loadSourceHandle,
|
getWorkspace: getWorkspace,
|
||||||
verifyPermission: verifyPermission, clearAll: clearAll,
|
putWorkspace: putWorkspace,
|
||||||
|
deleteWorkspace: deleteWorkspace,
|
||||||
|
verifyPermission: verifyPermission,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -751,12 +751,95 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Workspace snapshot (scan once, resume without re-walking the FS) ────
|
||||||
|
|
||||||
|
// Serialize the completed scan to compact JSON (short keys: large trees).
|
||||||
|
// Zip-root nodes are NOT preserved as expandable folders — the .zip stays a
|
||||||
|
// plain file in its parent (classifying inside archives is out of scope for
|
||||||
|
// a persisted workspace).
|
||||||
|
function snapshotTree() {
|
||||||
|
function serFile(f) { return { o: f.originalFilename, e: f.extension, p: f.folderPath }; }
|
||||||
|
function serNode(n) {
|
||||||
|
var o = { n: n.name, p: n.path };
|
||||||
|
if (n.files && n.files.length) o.f = n.files.map(serFile);
|
||||||
|
var realKids = (n.children || []).filter(function (c) { return !c.isZipRoot; });
|
||||||
|
if (realKids.length) o.c = realKids.map(serNode);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
return (window.app.folderTree || []).map(serNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild window.app.folderTree from a snapshot — handle-less nodes, marked
|
||||||
|
// 'done', subtree totals recomputed. Handles are resolved lazily from the
|
||||||
|
// workspace root handle at copy/preview time.
|
||||||
|
function loadSnapshot(snap) {
|
||||||
|
function deFile(sf) {
|
||||||
|
return {
|
||||||
|
handle: null, folderHandle: null,
|
||||||
|
originalFilename: sf.o, extension: sf.e,
|
||||||
|
size: null, lastModified: null,
|
||||||
|
trackingNumber: '', revision: '', status: '', title: '',
|
||||||
|
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null,
|
||||||
|
folderPath: sf.p,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function deNode(sn, parent) {
|
||||||
|
var node = makeNode({ name: sn.n, kind: 'directory' }, sn.p, parent);
|
||||||
|
node.handle = null;
|
||||||
|
node.scanState = 'done';
|
||||||
|
node.expanded = false;
|
||||||
|
node.files = (sn.f || []).map(deFile);
|
||||||
|
node.children = (sn.c || []).map(function (c) { return deNode(c, node); });
|
||||||
|
node.fileCount = node.files.length;
|
||||||
|
node.subdirCount = node.children.length;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
var roots = (snap || []).map(function (sn) { return deNode(sn, null); });
|
||||||
|
if (roots[0]) roots[0].expanded = true;
|
||||||
|
(function totals(nodes) {
|
||||||
|
nodes.forEach(function (n) {
|
||||||
|
totals(n.children);
|
||||||
|
var rf = n.files.length, rd = n.children.length;
|
||||||
|
n.children.forEach(function (c) { rf += c.runFiles; rd += c.runDirs; });
|
||||||
|
n.runFiles = rf; n.runDirs = rd;
|
||||||
|
});
|
||||||
|
})(roots);
|
||||||
|
window.app.folderTree = roots;
|
||||||
|
if (window.app.modules.store && window.app.modules.store.setFolderTree) {
|
||||||
|
window.app.modules.store.setFolderTree(roots);
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lazy handle resolution (snapshot files carry paths, not handles) ────
|
||||||
|
function relFromRoot(p) { var i = (p || '').indexOf('/'); return i < 0 ? '' : p.slice(i + 1); }
|
||||||
|
async function resolveDirHandle(rootHandle, relPath) {
|
||||||
|
var cur = rootHandle;
|
||||||
|
var parts = (relPath || '').split('/').filter(Boolean);
|
||||||
|
for (var i = 0; i < parts.length; i++) { cur = await cur.getDirectoryHandle(parts[i]); }
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
// Resolve (and cache) a file object's handle from the workspace root.
|
||||||
|
async function resolveFileHandle(rootHandle, fileObj) {
|
||||||
|
if (fileObj.handle) return fileObj.handle;
|
||||||
|
var dir = await resolveDirHandle(rootHandle, relFromRoot(fileObj.folderPath));
|
||||||
|
var name = zddc.joinExtension(fileObj.originalFilename, fileObj.extension);
|
||||||
|
var h = await dir.getFileHandle(name);
|
||||||
|
fileObj.handle = h;
|
||||||
|
fileObj.folderHandle = dir;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
// Export module
|
// Export module
|
||||||
window.app.modules.scanner = {
|
window.app.modules.scanner = {
|
||||||
scanDirectory,
|
scanDirectory,
|
||||||
ensureScanned,
|
ensureScanned,
|
||||||
getZipCache,
|
getZipCache,
|
||||||
extractZip
|
extractZip,
|
||||||
|
snapshotTree,
|
||||||
|
loadSnapshot,
|
||||||
|
resolveFileHandle,
|
||||||
|
resolveDirHandle
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
230
classifier/js/workspace.js
Normal file
230
classifier/js/workspace.js
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
/**
|
||||||
|
* 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; },
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
<button id="modeRenameBtn" class="mode-btn active" title="Rename files in place (edits the source)">Rename</button>
|
<button id="modeRenameBtn" class="mode-btn active" title="Rename files in place (edits the source)">Rename</button>
|
||||||
<button id="modeClassifyBtn" class="mode-btn" title="Map files onto target trees and copy renamed copies to an output directory — source is never modified">Classify & Copy</button>
|
<button id="modeClassifyBtn" class="mode-btn" title="Map files onto target trees and copy renamed copies to an output directory — source is never modified">Classify & Copy</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="workspacesBtn" class="btn btn-secondary btn-sm" title="Workspaces — open or create a classification project">≡ Workspaces</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||||
|
|
@ -178,8 +179,17 @@
|
||||||
workflow alongside file navigation. This standalone build remains
|
workflow alongside file navigation. This standalone build remains
|
||||||
available for offline use and air-gapped environments.
|
available for offline use and air-gapped environments.
|
||||||
</p>
|
</p>
|
||||||
<p>Rename a folder of files to ZDDC format using a spreadsheet interface.</p>
|
<!-- Workspaces (Classify & Copy) -->
|
||||||
<p>Open a directory, fill in tracking number, revision, status, and title for each file, then save — the files are renamed on disk.</p>
|
<section id="workspacesSection" class="workspaces">
|
||||||
|
<div class="ws-head">
|
||||||
|
<h3>Workspaces</h3>
|
||||||
|
<button id="newWorkspaceBtn" class="btn btn-primary btn-sm">+ New workspace</button>
|
||||||
|
</div>
|
||||||
|
<p class="ws-intro">Scan a folder <em>once</em>, then map files onto tracking numbers and transmittals and copy renamed copies to an output directory — the source is never modified. Workspaces save in this browser so you can resume across sessions.</p>
|
||||||
|
<div id="workspaceList" class="ws-list"><!-- rendered --></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="ws-or">— or — rename a folder of files <strong>in place</strong>: click <strong>Use Local Directory</strong> above (quick, no workspace).</p>
|
||||||
|
|
||||||
<!-- Browser Compatibility Warning -->
|
<!-- Browser Compatibility Warning -->
|
||||||
<div id="browserWarning" class="browser-warning hidden">
|
<div id="browserWarning" class="browser-warning hidden">
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,116 @@ test('copy: two sources mapping to the same output path are a conflict', async (
|
||||||
expect(conflicts).toBe(1);
|
expect(conflicts).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Workspaces: snapshot, persistence, copy-from-snapshot ──────────────────
|
||||||
|
|
||||||
|
test('snapshot: serialize + rebuild preserves structure, marks done, drops handles', async ({ page }) => {
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const sc = window.app.modules.scanner;
|
||||||
|
window.app.folderTree = [{
|
||||||
|
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
|
||||||
|
files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Root' }],
|
||||||
|
children: [{
|
||||||
|
name: 'sub', path: 'Root/sub', scanState: 'done',
|
||||||
|
files: [{ originalFilename: 'b', extension: 'txt', folderPath: 'Root/sub' }], children: [],
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
const json = JSON.stringify(sc.snapshotTree());
|
||||||
|
window.app.folderTree = [];
|
||||||
|
sc.loadSnapshot(JSON.parse(json));
|
||||||
|
const root = window.app.folderTree[0];
|
||||||
|
return {
|
||||||
|
rootName: root.name, done: root.scanState === 'done', runFiles: root.runFiles,
|
||||||
|
subFile: root.children[0].files[0].originalFilename,
|
||||||
|
subPath: root.children[0].files[0].folderPath,
|
||||||
|
handleNull: root.children[0].files[0].handle === null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(r.rootName).toBe('Root');
|
||||||
|
expect(r.done).toBe(true);
|
||||||
|
expect(r.runFiles).toBe(2);
|
||||||
|
expect(r.subFile).toBe('b');
|
||||||
|
expect(r.subPath).toBe('Root/sub');
|
||||||
|
expect(r.handleNull).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persist: workspace put / list / get / delete round-trip', async ({ page }) => {
|
||||||
|
const r = await page.evaluate(async () => {
|
||||||
|
const P = window.app.modules.persist;
|
||||||
|
const id = 'test-ws-1';
|
||||||
|
await P.putWorkspace(
|
||||||
|
{ id, name: 'WS', rootName: 'Root', createdAt: 1, updatedAt: 2, summary: { files: 3, done: 1, excluded: 0 } },
|
||||||
|
{ id, rootHandle: null, tree: [{ n: 'Root', p: 'Root' }], classify: { assignments: {}, trackingTree: [], transmittalTree: [] } });
|
||||||
|
const meta = (await P.listWorkspaces()).filter((w) => w.id === id)[0];
|
||||||
|
const data = await P.getWorkspace(id);
|
||||||
|
await P.deleteWorkspace(id);
|
||||||
|
const goneAfter = (await P.listWorkspaces()).filter((w) => w.id === id).length;
|
||||||
|
return { name: meta && meta.name, files: meta && meta.summary.files, treeLen: data && data.tree.length, goneAfter };
|
||||||
|
});
|
||||||
|
expect(r.name).toBe('WS');
|
||||||
|
expect(r.files).toBe(3);
|
||||||
|
expect(r.treeLen).toBe(1);
|
||||||
|
expect(r.goneAfter).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persist: classify-only autosave preserves the stored snapshot', async ({ page }) => {
|
||||||
|
const r = await page.evaluate(async () => {
|
||||||
|
const P = window.app.modules.persist;
|
||||||
|
const id = 'test-ws-2';
|
||||||
|
await P.putWorkspace({ id, name: 'A', rootName: 'R', createdAt: 1, updatedAt: 1, summary: { files: 1, done: 0, excluded: 0 } },
|
||||||
|
{ id, rootHandle: null, tree: [{ n: 'R', p: 'R' }], classify: {} });
|
||||||
|
await P.putWorkspace({ id, name: 'A', rootName: 'R', createdAt: 1, updatedAt: 2, summary: { files: 1, done: 1, excluded: 0 } },
|
||||||
|
{ id, classify: { assignments: { x: {} } } }); // no tree → must preserve
|
||||||
|
const data = await P.getWorkspace(id);
|
||||||
|
await P.deleteWorkspace(id);
|
||||||
|
return { treePreserved: !!(data && data.tree && data.tree.length === 1), hasClassify: !!(data && data.classify.assignments) };
|
||||||
|
});
|
||||||
|
expect(r.treePreserved).toBe(true);
|
||||||
|
expect(r.hasClassify).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy: snapshot files (no handle) resolve from the workspace root', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||||
|
const srcStore = { 'Sub/foundation.pdf': 'AAA' };
|
||||||
|
const mkSrcDir = (prefix) => ({
|
||||||
|
name: prefix || 'Root',
|
||||||
|
getDirectoryHandle: async (n) => mkSrcDir((prefix ? prefix + '/' : '') + n),
|
||||||
|
getFileHandle: async (n) => {
|
||||||
|
const full = (prefix ? prefix + '/' : '') + n;
|
||||||
|
if (!(full in srcStore)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; }
|
||||||
|
return { getFile: async () => new File([srcStore[full]], n) };
|
||||||
|
},
|
||||||
|
queryPermission: async () => 'granted', requestPermission: async () => 'granted',
|
||||||
|
});
|
||||||
|
window.app.rootHandle = mkSrcDir('');
|
||||||
|
const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root/Sub', handle: null };
|
||||||
|
window.app.folderTree = [{ name: 'Root', path: 'Root', files: [], children: [{ name: 'Sub', path: 'Root/Sub', files: [f], children: [] }] }];
|
||||||
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
||||||
|
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
c.place([c.srcKeyForFile(f)], leaf, 'tracking'); c.place([c.srcKeyForFile(f)], bin, 'transmittal');
|
||||||
|
|
||||||
|
const outStore = {};
|
||||||
|
const mkOut = (prefix) => ({
|
||||||
|
name: prefix || 'out',
|
||||||
|
getDirectoryHandle: async (n) => mkOut((prefix ? prefix + '/' : '') + n),
|
||||||
|
getFileHandle: async (n, opts) => {
|
||||||
|
const full = (prefix ? prefix + '/' : '') + n;
|
||||||
|
if (!opts || !opts.create) { if (!(full in outStore)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } }
|
||||||
|
return {
|
||||||
|
getFile: async () => new File([outStore[full] != null ? outStore[full] : ''], n),
|
||||||
|
createWritable: async () => ({ write: async (d) => { outStore[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const s = await copy.copyTo(mkOut(''), copy.plan());
|
||||||
|
return { copied: s.copied, content: Object.values(outStore)[0], wrote: Object.keys(outStore).some((k) => k.endsWith('foundation.pdf')) };
|
||||||
|
});
|
||||||
|
expect(res.copied).toBe(1);
|
||||||
|
expect(res.wrote).toBe(true);
|
||||||
|
expect(res.content).toBe('AAA');
|
||||||
|
});
|
||||||
|
|
||||||
test('deleting a tracking node clears the files placed in it', async ({ page }) => {
|
test('deleting a tracking node clears the files placed in it', async ({ page }) => {
|
||||||
const after = await page.evaluate((file) => {
|
const after = await page.evaluate((file) => {
|
||||||
const c = window.app.modules.classify;
|
const c = window.app.modules.classify;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue