/** * 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'), importBtn: document.getElementById('importWorkspaceBtn'), importInput: document.getElementById('importWorkspaceInput'), }; 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); if (els.importBtn) els.importBtn.addEventListener('click', function () { els.importInput.click(); }); if (els.importInput) els.importInput.addEventListener('change', function () { if (this.files && this.files[0]) importWorkspace(this.files[0]); this.value = ''; }); // 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'; var titles = { export: 'Export this whole workspace (scanned snapshot + classifications) to transfer to another browser or back up', delete: 'Delete this workspace — your source files are untouched', }; [['open', 'Open'], ['rename', 'Rename'], ['export', 'Export'], ['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]; if (titles[a[0]]) b.title = titles[a[0]]; 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 === 'export') exportWorkspace(id); else if (btn.dataset.act === 'delete') deleteWorkspace(id); } // ── transfer (export / import a whole workspace as JSON) ───────────────── // The scan is the expensive part (minutes on cloud mounts), so a workspace // is portable: its snapshot + classify map travel as one JSON. The source // directory HANDLE can't cross browsers, so an imported workspace has none — // "Connect directory" re-attaches the folder once, without re-scanning. async function exportWorkspace(id) { var rows = await P().listWorkspaces(); var meta = rows.filter(function (r) { return r.id === id; })[0]; var rec = await P().getWorkspace(id); if (!meta || !rec) { window.zddc.toast('Could not load that workspace to export.', 'error'); return; } var payload = { zddcWorkspace: 1, exportedAt: new Date().toISOString(), meta: { name: meta.name, rootName: meta.rootName, createdAt: meta.createdAt, updatedAt: meta.updatedAt, summary: meta.summary }, tree: rec.tree || [], // the scanned snapshot (no re-scan on the other side) classify: rec.classify || {}, // assignments + target trees }; var blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = String(meta.name || 'workspace').replace(/[^\w.-]+/g, '_') + '.zddc-workspace.json'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); window.zddc.toast('Exported “' + meta.name + '”. Import it in another browser to skip the re-scan.', 'success'); } function importWorkspace(file) { return new Promise(function (resolve) { var reader = new FileReader(); reader.onload = function () { var obj; try { obj = JSON.parse(reader.result); } catch (e) { window.zddc.toast('Import failed — not valid JSON.', 'error'); resolve(null); return; } if (!obj || !obj.zddcWorkspace) { window.zddc.toast(obj && obj.zddcClassifierFiles ? 'That’s a classification dataset, not a workspace — open a workspace and use Import in the toolbar.' : 'Import failed — not a ZDDC workspace export.', 'error'); resolve(null); return; } var m = obj.meta || {}; var id = uid(); var meta = { id: id, name: m.name || 'Imported workspace', rootName: m.rootName || '', createdAt: m.createdAt || now(), updatedAt: now(), summary: m.summary || { files: 0, done: 0, excluded: 0 }, }; // No rootHandle — it can't be serialized across browsers; the // user re-attaches via "Connect directory" after opening. P().putWorkspace(meta, { id: id, rootHandle: null, tree: obj.tree || [], classify: obj.classify || {} }) .then(function () { renderList(); window.zddc.toast('Imported “' + meta.name + '”. Open it, then “Connect directory” to re-attach the source folder for preview/copy.', 'success', { durationMs: 8000 }); resolve(id); }); }; reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); resolve(null); }; reader.readAsText(file); }); } // ── 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, exportWorkspace: exportWorkspace, importWorkspace: importWorkspace, onRescanned: onRescanned, renderList: renderList, activeId: function () { return activeId; }, activeName: function () { return activeMeta ? activeMeta.name : null; }, }; })();