/** * ZDDC Classifier — workspace manager (Classify & Copy). * * A workspace = one classification project: a source directory handle, a * snapshot of its completed scan, and the Classify & Copy map. The welcome * screen lists them; opening one resumes instantly from the snapshot (no * re-scan), and the map autosaves as you work. Only Copy needs the live * filesystem (a one-click permission re-grant). */ (function () { 'use strict'; var els = {}; var initialized = false; var activeId = null; var activeMeta = null; // {id,name,rootName,createdAt,updatedAt,summary} var activeStoredHandle = null; // the workspace's persisted source dir handle function P() { return window.app.modules.persist; } function C() { return window.app.modules.classify; } function now() { return Date.now(); } function uid() { if (window.crypto && typeof window.crypto.randomUUID === 'function') { try { return window.crypto.randomUUID(); } catch (_) { /* non-secure ctx */ } } return 'w' + now().toString(36) + Math.floor(Math.random() * 1e9).toString(36); } function init() { if (initialized) return; initialized = true; els = { welcome: document.getElementById('welcomeScreen'), list: document.getElementById('workspaceList'), newBtn: document.getElementById('newWorkspaceBtn'), wsBtn: document.getElementById('workspacesBtn'), connectBtn: document.getElementById('connectDirBtn'), }; if (!P() || !P().available) { // No IndexedDB → hide the workspace UI; legacy rename path still works. var wrap = document.getElementById('workspacesSection'); if (wrap) wrap.style.display = 'none'; return; } if (els.newBtn) els.newBtn.addEventListener('click', newWorkspace); if (els.wsBtn) els.wsBtn.addEventListener('click', showWelcome); if (els.connectBtn) els.connectBtn.addEventListener('click', function () { tryReconnect(false); }); if (els.list) els.list.addEventListener('click', onListClick); // Autosave the active workspace whenever the map changes. C().on(scheduleAutosave); renderList(); } // ── welcome list ──────────────────────────────────────────────────────── function showWelcome() { if (els.welcome) els.welcome.classList.remove('hidden'); renderList(); } function hideWelcome() { if (els.welcome) els.welcome.classList.add('hidden'); } function relTime(ts) { if (!ts) return ''; var s = Math.max(0, Math.round((now() - ts) / 1000)); if (s < 60) return 'just now'; var m = Math.round(s / 60); if (m < 60) return m + 'm ago'; var h = Math.round(m / 60); if (h < 24) return h + 'h ago'; var d = Math.round(h / 24); return d + 'd ago'; } function renderList() { if (!els.list) return; P().listWorkspaces().then(function (rows) { els.list.textContent = ''; if (!rows.length) { var empty = document.createElement('div'); empty.className = 'ws-empty'; empty.textContent = 'No workspaces yet. “+ New workspace” scans a folder once and saves it here.'; els.list.appendChild(empty); return; } rows.forEach(function (r) { els.list.appendChild(rowEl(r)); }); }); } function rowEl(r) { var s = r.summary || { files: 0, done: 0, excluded: 0 }; var row = document.createElement('div'); row.className = 'ws-row'; row.dataset.id = r.id; var main = document.createElement('div'); main.className = 'ws-row__main'; var nm = document.createElement('div'); nm.className = 'ws-row__name'; nm.textContent = r.name; var meta = document.createElement('div'); meta.className = 'ws-row__meta'; meta.textContent = (r.rootName || '') + ' · ' + s.done + '/' + s.files + ' classified' + (s.excluded ? (' · ' + s.excluded + ' excluded') : '') + ' · updated ' + relTime(r.updatedAt); main.appendChild(nm); main.appendChild(meta); var actions = document.createElement('div'); actions.className = 'ws-row__actions'; [['open', 'Open'], ['rename', 'Rename'], ['delete', 'Delete']].forEach(function (a) { var b = document.createElement('button'); b.className = 'btn btn-sm ' + (a[0] === 'open' ? 'btn-primary' : 'btn-secondary'); b.dataset.act = a[0]; b.textContent = a[1]; actions.appendChild(b); }); row.appendChild(main); row.appendChild(actions); return row; } function onListClick(e) { var btn = e.target.closest('[data-act]'); if (!btn) return; var row = btn.closest('.ws-row'); var id = row && row.dataset.id; if (!id) return; if (btn.dataset.act === 'open') openWorkspace(id); else if (btn.dataset.act === 'rename') renameWorkspace(id); else if (btn.dataset.act === 'delete') deleteWorkspace(id); } // ── summary ─────────────────────────────────────────────────────────── function allFiles() { var out = []; (function walk(ns) { (ns || []).forEach(function (n) { (n.files || []).forEach(function (f) { out.push(f); }); walk(n.children); }); })(window.app.folderTree || []); return out; } function summary() { var s = C().stats(allFiles()); return { files: s.total, done: s.done, excluded: s.excluded }; } // ── create / open / rename / delete ───────────────────────────────────── async function newWorkspace() { if (!window.showDirectoryPicker) { window.zddc.toast('Workspaces need the File System Access API (use Chromium).', 'error'); return; } var dir; try { dir = await window.showDirectoryPicker(); } catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open folder — ' + (e.message || e), 'error'); return; } var name = prompt('Name this workspace:', dir.name); if (name === null) name = dir.name; name = name.trim() || dir.name; window.app.rootHandle = dir; activeStoredHandle = dir; window.app.modules.app.enterAppShell(); window.app.modules.app.setMode('classify'); hideWelcome(); activeId = uid(); activeMeta = { id: activeId, name: name, rootName: dir.name, createdAt: now(), updatedAt: now(), summary: { files: 0, done: 0, excluded: 0 } }; // Create the record UP FRONT so an interrupted scan survives and resumes. await saveSnapshotFull(); updateConnectUI(); // Periodically persist the partial snapshot during the (slow) scan, so an // interruption resumes from where it left off instead of starting over. var iv = setInterval(saveSnapshotFull, 5000); try { await window.app.modules.scanner.scanDirectory(dir); } finally { clearInterval(iv); saveSnapshotFull(); } } async function openWorkspace(id) { var rec = await P().getWorkspace(id); var rows = await P().listWorkspaces(); var meta = rows.filter(function (r) { return r.id === id; })[0]; if (!rec || !meta) { window.zddc.toast('Could not load that workspace.', 'error'); return; } activeId = id; activeMeta = meta; activeStoredHandle = rec.rootHandle || null; window.app.rootHandle = null; // not connected until reconnect window.app.modules.app.enterAppShell(); window.app.modules.scanner.loadSnapshot(rec.tree || []); C().load(rec.classify || {}); window.app.modules.app.setMode('classify'); hideWelcome(); // Offer to reconnect the source directory (needed to preview, copy, or // finish an interrupted scan). Silent if permission is already granted. await tryReconnect(true); updateConnectUI(); } // Persist the full workspace (meta + snapshot + map + source handle). function saveSnapshotFull() { if (!activeId || !activeMeta) return Promise.resolve(); activeMeta.updatedAt = now(); activeMeta.summary = summary(); return P().putWorkspace(activeMeta, { id: activeId, rootHandle: window.app.rootHandle || activeStoredHandle || null, tree: window.app.modules.scanner.snapshotTree(), classify: C().serialize(), }); } // Connect (or reconnect) the source directory. silentOnly=true never shows a // permission prompt or picker — it only adopts an already-granted handle and // otherwise nudges the user to click "Connect directory". async function tryReconnect(silentOnly) { var h = activeStoredHandle; if (h && typeof h.queryPermission === 'function') { var p = 'denied'; try { p = await h.queryPermission({ mode: 'read' }); } catch (_) { /* ignore */ } if (p === 'granted') { window.app.rootHandle = h; return afterConnect(); } if (!silentOnly) { var p2 = 'denied'; try { p2 = await h.requestPermission({ mode: 'read' }); } catch (_) { /* ignore */ } if (p2 === 'granted') { window.app.rootHandle = h; return afterConnect(); } } } if (silentOnly) { if (!window.app.rootHandle && activeId) { window.zddc.toast('This workspace’s source directory isn’t connected — click “Connect directory” to preview, copy, or finish scanning.', 'info', { durationMs: 8000 }); } return false; } // Explicit: no usable stored handle (or permission denied) → let the user pick. if (!window.showDirectoryPicker) { window.zddc.toast('Connecting a directory needs the File System Access API.', 'error'); return false; } try { var picked = await window.showDirectoryPicker(); window.app.rootHandle = picked; activeStoredHandle = picked; return afterConnect(); } catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not connect directory — ' + (e.message || e), 'error'); return false; } } async function afterConnect() { updateConnectUI(); // Resume any still-pending folders now that we have the handle. var did = await window.app.modules.scanner.resumeScan(window.app.rootHandle); saveSnapshotFull(); // persist refreshed snapshot + the (re-granted) handle return true; } function updateConnectUI() { if (!els.connectBtn) return; var show = !!activeId && !window.app.rootHandle; els.connectBtn.hidden = !show; } function renameWorkspace(id) { P().listWorkspaces().then(function (rows) { var meta = rows.filter(function (r) { return r.id === id; })[0]; if (!meta) return; var name = prompt('Rename workspace:', meta.name); if (!name || !name.trim()) return; meta.name = name.trim(); meta.updatedAt = now(); if (activeMeta && activeMeta.id === id) activeMeta.name = meta.name; P().putWorkspace(meta, null).then(renderList); }); } function deleteWorkspace(id) { if (!confirm('Delete this workspace? The map and snapshot are removed — your source files are untouched.')) return; if (activeId === id) { activeId = null; activeMeta = null; } P().deleteWorkspace(id).then(renderList); } // ── autosave (debounced) ──────────────────────────────────────────────── var saveTimer = null; function scheduleAutosave() { if (!activeId || !activeMeta) return; if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(function () { saveTimer = null; activeMeta.updatedAt = now(); activeMeta.summary = summary(); // classify-only put: tree omitted → the stored snapshot is preserved. P().putWorkspace(activeMeta, { id: activeId, classify: C().serialize() }); }, 500); } // Called after a "Refresh from disk" rescan — re-persist the snapshot for // the active workspace (the path-keyed map carries over automatically). function onRescanned() { if (!activeId || !activeMeta) return; activeMeta.updatedAt = now(); activeMeta.summary = summary(); P().putWorkspace(activeMeta, { id: activeId, tree: window.app.modules.scanner.snapshotTree(), classify: C().serialize(), }); } window.app.modules.workspace = { init: init, showWelcome: showWelcome, newWorkspace: newWorkspace, openWorkspace: openWorkspace, onRescanned: onRescanned, renderList: renderList, activeId: function () { return activeId; }, }; })();