diff --git a/classifier/build.sh b/classifier/build.sh index 04b7fac..179e91c 100755 --- a/classifier/build.sh +++ b/classifier/build.sh @@ -53,6 +53,7 @@ concat_files \ "js/store.js" \ "js/persist.js" \ "js/classify.js" \ + "js/workspace.js" \ "js/dnd.js" \ "js/validator.js" \ "js/scanner.js" \ diff --git a/classifier/css/layout.css b/classifier/css/layout.css index c9fd8b2..159c9d4 100644 --- a/classifier/css/layout.css +++ b/classifier/css/layout.css @@ -265,6 +265,24 @@ 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) ─────────────────────────────────────── */ .mode-switch { display: inline-flex; diff --git a/classifier/js/app.js b/classifier/js/app.js index b01b3f8..329ff80 100644 --- a/classifier/js/app.js +++ b/classifier/js/app.js @@ -33,22 +33,8 @@ cacheDOMElements(); setupEventListeners(); - // Restore a saved Classify & Copy map (placements + target trees). It - // keys on source-relative paths, so it re-attaches once the SAME source - // 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 }); - } - }); - } + // Workspace manager (renders the welcome list, owns new/open/autosave). + if (app.modules.workspace) app.modules.workspace.init(); // Browser-compatibility branch: // HTTP mode (served by zddc-server) — works everywhere; the @@ -368,31 +354,32 @@ /** * Open a directory handle and initialize the application */ - async function openDirectory(dirHandle) { - app.rootHandle = dirHandle; - // Remember the source handle so a later session can re-grant access in - // one click (the map re-attaches by relative path either way). - if (app.modules.persist) app.modules.persist.saveSourceHandle(dirHandle); - - // Hide welcome screen and show main UI + // Show the main UI and initialize the per-tool modules ONCE. Shared by the + // legacy rename open and the workspace open/new flows (the latter scan or + // load a snapshot themselves). + var shellInited = false; + function enterAppShell() { hideWelcomeScreen(); showMainUI(); - - // Initialize modules BEFORE scanning (so they're ready for store updates) - 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 (!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'); + } + async function openDirectory(dirHandle) { + app.rootHandle = dirHandle; + enterAppShell(); // Now scan directory (this will trigger store updates and renders) 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,17 +392,33 @@ } 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 app.folderTree = []; app.selectedFolders.clear(); app.lastSelectedFolderPath = null; - + // Reset store app.modules.store.reset(); // Rescan directory (modules already initialized, just rescan) 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) { console.error('Error refreshing directory:', err); alert('Error refreshing directory: ' + err.message); @@ -551,7 +554,9 @@ // Export functions for use by other modules app.modules.app = { - updateStats + updateStats, + setMode, + enterAppShell }; // Initialize when DOM is ready diff --git a/classifier/js/classify.js b/classifier/js/classify.js index dc90808..824636e 100644 --- a/classifier/js/classify.js +++ b/classifier/js/classify.js @@ -47,6 +47,9 @@ var notifyScheduled = false; function notify() { // 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; notifyScheduled = true; Promise.resolve().then(function () { @@ -54,21 +57,9 @@ for (var i = 0; i < listeners.length; i++) { 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 ─────────────────────────────────────── function stripRoot(p) { var i = (p || '').indexOf('/'); @@ -429,6 +420,6 @@ // persistence serialize: serialize, load: load, reset: reset, getOutputName: function () { return state.outputName; }, - setOutputName: function (n) { state.outputName = n || null; scheduleSave(); }, + setOutputName: function (n) { state.outputName = n || null; notify(); }, }; })(); diff --git a/classifier/js/copy.js b/classifier/js/copy.js index 9522325..7331589 100644 --- a/classifier/js/copy.js +++ b/classifier/js/copy.js @@ -90,13 +90,21 @@ async function sameContent(existingHandle, srcFileObj) { 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; var a = await window.zddc.crypto.sha256File(ef); var b = await window.zddc.crypto.sha256File(sf); 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). async function copyOne(out, p) { var dir = await ensureDir(out, p.d.outPath); @@ -105,7 +113,7 @@ if (existing) { 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 w = await fh.createWritable(); await w.write(srcFile); @@ -131,6 +139,17 @@ } 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(); if (!out) return; if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\nThe source directory is not modified.')) return; diff --git a/classifier/js/persist.js b/classifier/js/persist.js index 34dfe19..a4fa5b3 100644 --- a/classifier/js/persist.js +++ b/classifier/js/persist.js @@ -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 - * 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. + * 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. * - * 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. + * 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 STORE = 'kv'; - var K_STATE = 'classify-state'; - var K_HANDLE = 'source-handle'; + 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, 1); + var req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = function () { 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.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); }; - }); + function reqP(req) { + return new Promise(function (resolve, reject) { + 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); }); + // 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 []; }); } - // Re-acquire read permission on a stored handle. Returns true if usable. - function verifyPermission(handle) { + // 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: 'read' }; + 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'; }); @@ -91,8 +119,10 @@ window.app.modules.persist = { available: available, - saveState: saveState, loadState: loadState, - saveSourceHandle: saveSourceHandle, loadSourceHandle: loadSourceHandle, - verifyPermission: verifyPermission, clearAll: clearAll, + listWorkspaces: listWorkspaces, + getWorkspace: getWorkspace, + putWorkspace: putWorkspace, + deleteWorkspace: deleteWorkspace, + verifyPermission: verifyPermission, }; })(); diff --git a/classifier/js/scanner.js b/classifier/js/scanner.js index ded1227..e011501 100644 --- a/classifier/js/scanner.js +++ b/classifier/js/scanner.js @@ -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 window.app.modules.scanner = { scanDirectory, ensureScanned, getZipCache, - extractZip + extractZip, + snapshotTree, + loadSnapshot, + resolveFileHandle, + resolveDirHandle }; })(); diff --git a/classifier/js/workspace.js b/classifier/js/workspace.js new file mode 100644 index 0000000..43fe11a --- /dev/null +++ b/classifier/js/workspace.js @@ -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; }, + }; +})(); diff --git a/classifier/template.html b/classifier/template.html index 7c977db..36f3843 100644 --- a/classifier/template.html +++ b/classifier/template.html @@ -34,6 +34,7 @@ +
@@ -178,8 +179,17 @@ workflow alongside file navigation. This standalone build remains available for offline use and air-gapped environments.

-

Rename a folder of files to ZDDC format using a spreadsheet interface.

-

Open a directory, fill in tracking number, revision, status, and title for each file, then save — the files are renamed on disk.

+ +
+
+

Workspaces

+ +
+

Scan a folder once, 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.

+
+
+ +

— or — rename a folder of files in place: click Use Local Directory above (quick, no workspace).