384 lines
18 KiB
JavaScript
384 lines
18 KiB
JavaScript
/**
|
||
* 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; },
|
||
};
|
||
})();
|