Files

981 lines
35 KiB
React

// Storage layer: Parser, ApiBackend, DirectBackend, Storage detection, SyncPoller.
// Compatible with web/index.html and server/server.py file format.
//
// File format (backlog.md):
// # Backlog
// <!-- SECTION: ENTRIES -->
// - [ ] [P1] Title *(due: ..., reason: ..., tags: tag1 tag2, progress: 50)*
// - [x] [P0] Child *(progress: 100)*
// <!-- SECTION: HISTORY -->
// | Timestamp | Item ID | Action | Details |
// <!-- SECTION: INTEGRITY -->
// <!-- saved: ... | checksum: sha256:... | entries: N | history: N -->
// ---- Parser ----
const Parser = {
async parse(text) {
if (!text || !text.trim()) {
return { entries: [], history: [], meta: null, checksumOk: false };
}
const entStart = text.indexOf('<!-- SECTION: ENTRIES -->');
const histStart = text.indexOf('<!-- SECTION: HISTORY -->');
const intStart = text.indexOf('<!-- SECTION: INTEGRITY -->');
const entriesText = entStart !== -1
? text.slice(entStart, histStart !== -1 ? histStart : intStart !== -1 ? intStart : text.length)
: text;
const historyText = histStart !== -1
? text.slice(histStart, intStart !== -1 ? intStart : text.length)
: '';
// Parse integrity marker
let meta = null;
let checksumOk = false;
if (intStart !== -1) {
const m = text.slice(intStart).match(
/saved:\s*([^|]+?)\s*\|\s*checksum:\s*([^|]+?)\s*\|\s*entries:\s*(\d+)\s*\|\s*history:\s*(\d+)/
);
if (m) {
meta = {
saved: m[1].trim(),
checksum: m[2].trim(),
entryCount: +m[3],
historyCount: +m[4],
};
// Strip section-marker prefix + surrounding whitespace to get raw content,
// matching server.py's compute_checksum() which uses raw (marker-free) payload.
const rawEnt = entriesText.slice('<!-- SECTION: ENTRIES -->'.length).trim();
const rawHist = historyText.slice('<!-- SECTION: HISTORY -->'.length).trim();
const payload = rawEnt + '\n' + rawHist;
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
const hash = 'sha256:' + Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
checksumOk = hash === meta.checksum;
}
}
// Parse history table
const history = [];
for (const line of historyText.split('\n')) {
if (!line.trim().startsWith('|')) continue;
const parts = line.split('|').map(s => s.trim()).filter(Boolean);
if (parts.length >= 4 && parts[0] !== 'Timestamp' && !parts[0].match(/^-+$/)) {
history.push({ timestamp: parts[0], itemId: parts[1], action: parts[2], details: parts[3] });
}
}
// Parse entries (indented markdown list)
const GLYPH_MAP = { ' ': 'open', 'x': 'done', '!': 'blocked', '>': 'postponed', '/': 'in-progress', '-': 'cancelled' };
let idSeq = Date.now();
const uid = () => 'i-' + (++idSeq).toString(36);
const entries = [];
const stack = [{ children: entries, depth: -1, level: 0, item: null }];
const lines = entriesText.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check for body line (starts with whitespace + "> ")
const bodyMatch = line.match(/^(\s*)>\s?(.*)$/);
if (bodyMatch) {
// Body line - attach to the most recent item in the stack
// Body indent is one level deeper than its parent item (e.g., item at depth 0 has body at depth 1)
const bodyIndent = bodyMatch[1].length / 2;
const bodyLine = bodyMatch[2];
// Find the item whose depth is bodyIndent - 1 (body is indented under its item)
for (let j = stack.length - 1; j >= 0; j--) {
if (stack[j].item && stack[j].depth === bodyIndent - 1) {
if (!stack[j].item.body) stack[j].item.body = '';
else stack[j].item.body += '\n';
stack[j].item.body += bodyLine;
break;
}
}
continue;
}
const m = line.match(/^(\s*)[-*]\s*\[([ x!>/\-])\]\s*(.*)$/);
if (!m) continue;
const depth = m[1].length / 2;
const status = GLYPH_MAP[m[2]] || 'open';
const raw = m[3].trim();
let title = raw, due = null, reason = null, tags = [], priority = 'P1', progress = 0;
let archived = null, restorePath = null;
const metaM = raw.match(/^(.*?)\s*\*\((.*)\)\*\s*$/);
if (metaM) {
title = metaM[1].trim();
for (const p of metaM[2].split(',').map(s => s.trim())) {
if (p.startsWith('due:')) due = p.slice(4).trim();
else if (p.startsWith('reason:')) reason = p.slice(7).trim();
else if (p.startsWith('tags:')) tags = p.slice(5).trim().split(/\s+/).filter(Boolean);
else if (p.startsWith('priority:')) priority = p.slice(9).trim().toUpperCase();
else if (p.startsWith('progress:')) progress = parseInt(p.slice(9).trim(), 10) || 0;
else if (p.startsWith('archived:')) archived = p.slice(9).trim();
else if (p.startsWith('restore-path:')) restorePath = p.slice(13).trim();
}
}
// Priority prefix [P0] embedded in title
const pm = title.match(/^\[(P\d)\]\s*/);
if (pm) { priority = pm[1]; title = title.slice(pm[0].length); }
while (stack.length > 1 && stack[stack.length - 1].depth >= depth) stack.pop();
const parentLevel = stack[stack.length - 1].level;
const item = {
id: uid(),
level: parentLevel + 1,
title, status, priority, due, reason, tags,
progress: status === 'done' ? 100 : progress,
collapsed: false,
children: [],
body: null,
archived: archived || null,
restorePath: restorePath || null,
};
stack[stack.length - 1].children.push(item);
stack.push({ children: item.children, depth, level: item.level, item });
}
return { entries, history, meta, checksumOk };
},
async serialize(data) {
const entriesText = this._serializeEntries(data.entries);
const historyText = this._serializeHistory(data.history);
const payload = entriesText + '\n' + historyText;
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
const hash = 'sha256:' + Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
const saved = new Date().toISOString().slice(0, 19) + 'Z';
const nEntries = this._countItems(data.entries);
const nHistory = data.history.length;
const marker = `<!-- saved: ${saved} | checksum: ${hash} | entries: ${nEntries} | history: ${nHistory} -->`;
return `# Backlog\n\n<!-- SECTION: ENTRIES -->\n\n${entriesText}\n\n<!-- SECTION: HISTORY -->\n\n${historyText}\n\n<!-- SECTION: INTEGRITY -->\n\n${marker}\n`;
},
_serializeEntries(items, depth = 0) {
const indent = ' '.repeat(depth);
const GLYPH = { open: ' ', 'in-progress': '/', blocked: '!', postponed: '>', done: 'x', cancelled: '-' };
const lines = [];
for (const it of items) {
const meta = [];
if (it.due) meta.push(`due: ${it.due}`);
if (it.reason) meta.push(`reason: ${it.reason}`);
if (it.tags?.length) meta.push(`tags: ${it.tags.join(' ')}`);
if (it.priority && it.priority !== 'P1') meta.push(`priority: ${it.priority}`);
if ((it.progress ?? 0) > 0) meta.push(`progress: ${it.progress}`);
if (it.archived) meta.push(`archived: ${it.archived}`);
if (it.restorePath) meta.push(`restore-path: ${it.restorePath}`);
const metaStr = meta.length ? ` *(${meta.join(', ')})*` : '';
const prefix = it.priority ? `[${it.priority}] ` : '';
lines.push(`${indent}- [${GLYPH[it.status] || ' '}] ${prefix}${it.title}${metaStr}`);
// Serialize body as indented blockquote lines (one level deeper than item)
if (it.body) {
const bodyIndent = ' '.repeat(depth + 1);
for (const bodyLine of it.body.split('\n')) {
lines.push(`${bodyIndent}> ${bodyLine}`);
}
}
if (it.children?.length) {
lines.push(...this._serializeEntries(it.children, depth + 1).split('\n'));
}
}
return lines.join('\n');
},
_serializeHistory(rows) {
const lines = ['| Timestamp | Item ID | Action | Details |', '|-----------|---------|--------|---------|'];
for (const r of rows) lines.push(`| ${r.timestamp} | ${r.itemId} | ${r.action} | ${r.details} |`);
return lines.join('\n');
},
_countItems(items) {
let n = 0;
for (const it of items) { n++; n += this._countItems(it.children || []); }
return n;
},
};
// ---- API Backend (python server.py) ----
const ApiBackend = {
async detect() {
if (location.protocol === 'file:') return false; // no server in file:// context
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 800);
try {
const r = await fetch('/api/health', { signal: ctrl.signal });
clearTimeout(t);
return r.ok;
} catch { clearTimeout(t); return false; }
},
async load() {
const r = await fetch('/api/backlog');
if (!r.ok) throw new Error('API load failed: ' + r.status);
return r.json(); // { content, checksum }
},
async save(content) {
const r = await fetch('/api/backlog', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (!r.ok) throw new Error('API save failed: ' + r.status);
return r.json(); // { ok, checksum, saved }
},
async listBackups() {
try {
const r = await fetch('/api/backups');
if (!r.ok) return [];
return (await r.json()).backups || [];
} catch { return []; }
},
async restoreBackup(name) {
const r = await fetch('/api/backups/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
return r.ok ? r.json() : { ok: false };
},
async getHealthInfo() {
try {
const r = await fetch('/api/health');
if (!r.ok) return {};
const j = await r.json();
return {
masterSize: j.masterSize || 0,
statsSize: 0,
masterPath: j.masterPath || '',
backupsPath: j.backupsPath || '',
archiveExists: j.archiveExists || false,
archiveSize: j.archiveSize || 0,
archivePath: j.archivePath || '',
};
} catch { return {}; }
},
async loadArchive(project = null) {
const url = project ? `/api/projects/${encodeURIComponent(project)}/archive` : '/api/archive';
const r = await fetch(url);
if (!r.ok) throw new Error('API archive load failed: ' + r.status);
return r.json(); // { content, checksum, exists }
},
async saveArchive(backlogContent, archiveContent, project = null) {
const url = project ? `/api/projects/${encodeURIComponent(project)}/archive` : '/api/archive';
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backlog: backlogContent, archive: archiveContent }),
});
if (!r.ok) throw new Error('API archive save failed: ' + r.status);
return r.json();
},
async restoreFromArchive(backlogContent, archiveContent, project = null) {
const url = project ? `/api/projects/${encodeURIComponent(project)}/archive/restore` : '/api/archive/restore';
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backlog: backlogContent, archive: archiveContent }),
});
if (!r.ok) throw new Error('API archive restore failed: ' + r.status);
return r.json();
},
// ---- Project-scoped methods (multi-project server API) ----
async listProjects() {
const r = await fetch('/api/projects');
if (!r.ok) throw new Error('API projects failed: ' + r.status);
return (await r.json()).projects || [];
},
async registerProject(path, name = null) {
const r = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, name }),
});
return r.json(); // { ok, name, path }
},
async unregisterProject(name, deleteFile = false) {
const r = await fetch(`/api/projects/${encodeURIComponent(name)}${deleteFile ? '?delete_file=true' : ''}`, {
method: 'DELETE',
});
return r.json(); // { ok, name, deleted, warning? }
},
async renameProject(name, newName) {
const r = await fetch(`/api/projects/${encodeURIComponent(name)}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newName }),
});
return r.json(); // { ok, name }
},
async loadProject(name) {
const r = await fetch(`/api/projects/${encodeURIComponent(name)}/backlog`);
if (!r.ok) throw new Error('API project load failed: ' + r.status);
return r.json(); // { content, checksum }
},
async saveProject(name, content) {
const r = await fetch(`/api/projects/${encodeURIComponent(name)}/backlog`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (!r.ok) throw new Error('API project save failed: ' + r.status);
return r.json(); // { ok, checksum, saved }
},
async loadProjectBackups(name) {
try {
const r = await fetch(`/api/projects/${encodeURIComponent(name)}/backups`);
if (!r.ok) return [];
return (await r.json()).backups || [];
} catch { return []; }
},
async loadAllProjects() {
const list = await this.listProjects();
return Promise.all(list.map(async p => {
if (p.missing) {
return { id: p.name, name: p.name, path: p.path, entries: [], history: [], meta: null, checksumOk: true, missing: true };
}
try {
const raw = await this.loadProject(p.name);
const parsed = await Parser.parse(raw.content);
return {
id: p.name,
name: p.name,
path: p.path,
entries: parsed.entries,
history: parsed.history,
meta: parsed.meta,
checksumOk: parsed.checksumOk,
};
} catch (e) {
return { id: p.name, name: p.name, path: p.path, entries: [], history: [], meta: null, checksumOk: false, error: String(e.message || e) };
}
}));
},
};
// ---- Direct File Backend (File System Access API) ----
const DirectBackend = {
dirHandle: null,
_DB_NAME: 'pb-storage-v2',
_STORE: 'handles',
_KEY: 'root',
async detect() {
return typeof window.showDirectoryPicker === 'function';
},
// Silent reconnect — no user gesture. Returns true if the stored handle
// is still accessible and permission is already granted.
async tryAutoConnect() {
this.dirHandle = await this._loadHandle();
if (!this.dirHandle) return false;
try {
return (await this.dirHandle.queryPermission({ mode: 'readwrite' })) === 'granted';
} catch { return false; }
},
// Explicit connect — requires a user gesture (button click).
// Shows the directory picker on first use; re-prompts for permission on subsequent uses.
async connect() {
let handle = await this._loadHandle();
if (!handle) {
handle = await window.showDirectoryPicker({ mode: 'readwrite' });
await this._saveHandle(handle);
}
const perm = await handle.requestPermission({ mode: 'readwrite' });
if (perm !== 'granted') throw new Error('Permission denied');
this.dirHandle = handle;
},
_loadHandle() {
return new Promise(res => {
const req = indexedDB.open(this._DB_NAME, 1);
req.onupgradeneeded = e => e.target.result.createObjectStore(this._STORE);
req.onsuccess = e => {
const get = e.target.result.transaction(this._STORE, 'readonly').objectStore(this._STORE).get(this._KEY);
get.onsuccess = () => res(get.result || null);
get.onerror = () => res(null);
};
req.onerror = () => res(null);
});
},
_saveHandle(handle) {
return new Promise((res, rej) => {
const req = indexedDB.open(this._DB_NAME, 1);
req.onsuccess = e => {
const tx = e.target.result.transaction(this._STORE, 'readwrite');
tx.objectStore(this._STORE).put(handle, this._KEY);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
};
});
},
async _readFile(name) {
try {
const h = await this.dirHandle.getFileHandle(name);
return (await h.getFile()).text();
} catch { return null; }
},
async _writeFile(name, content, dirHandle) {
const dir = dirHandle || this.dirHandle;
const h = await dir.getFileHandle(name, { create: true });
const w = await h.createWritable();
await w.write(content);
await w.close();
},
async load() {
const content = await this._readFile('backlog.md');
return { content: content || '', checksum: '' };
},
async save(content) {
await this._writeFile('backlog.md', content);
// Create backup
try {
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
const dir = await this.dirHandle.getDirectoryHandle('backups', { create: true });
await this._writeFile(`backlog_${ts}.md`, content, dir);
} catch { /* backup failure is non-fatal */ }
return { ok: true };
},
async listBackups() {
try {
const dir = await this.dirHandle.getDirectoryHandle('backups', { create: true });
const backups = [];
for await (const [name, h] of dir.entries()) {
if (!name.startsWith('backlog_') || !name.endsWith('.md')) continue;
const f = await h.getFile();
const text = await f.text();
backups.push({
name,
size: f.size,
timestamp: new Date(f.lastModified).toISOString(),
valid: text.includes('<!-- SECTION: INTEGRITY -->'),
});
}
return backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
} catch { return []; }
},
async restoreBackup(name) {
try {
const dir = await this.dirHandle.getDirectoryHandle('backups');
const h = await dir.getFileHandle(name);
const text = await (await h.getFile()).text();
await this._writeFile('backlog.md', text);
return { ok: true };
} catch (e) { return { ok: false, error: e.message }; }
},
async getHealthInfo() {
try {
const f = await (await this.dirHandle.getFileHandle('backlog.md')).getFile();
let archiveExists = false, archiveSize = 0;
try {
const af = await (await this.dirHandle.getFileHandle('archive.md')).getFile();
archiveExists = true;
archiveSize = af.size;
} catch {}
return { masterSize: f.size, statsSize: 0, dirName: this.dirHandle?.name ?? null, archiveExists, archiveSize };
} catch { return { masterSize: 0, statsSize: 0, dirName: this.dirHandle?.name ?? null, archiveExists: false, archiveSize: 0 }; }
},
async loadArchive() {
const content = await this._readFile('archive.md');
return { content: content || '', checksum: '', exists: !!content };
},
async saveArchive(backlogContent, archiveContent) {
await this._writeFile('backlog.md', backlogContent);
await this._writeFile('archive.md', archiveContent);
return { ok: true };
},
async restoreFromArchive(backlogContent, archiveContent) {
await this._writeFile('backlog.md', backlogContent);
await this._writeFile('archive.md', archiveContent);
return { ok: true };
},
};
// ---- Browser Storage Backend (IndexedDB + Storage Persistence API) ----
// Fallback for browsers without File System Access API (Firefox, Safari)
const BrowserStorageBackend = {
_DB_NAME: 'pb-browser-storage',
_STORE: 'data',
_CONTENT_KEY: 'backlog',
_BACKUPS_KEY: 'backups',
_MAX_BACKUPS: 10,
_persistenceRequested: false,
_persistenceGranted: null,
async detect() {
return typeof indexedDB !== 'undefined';
},
_openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(this._DB_NAME, 1);
req.onupgradeneeded = e => e.target.result.createObjectStore(this._STORE);
req.onsuccess = e => resolve(e.target.result);
req.onerror = () => reject(req.error);
});
},
async _get(key) {
const db = await this._openDB();
return new Promise(resolve => {
const tx = db.transaction(this._STORE, 'readonly');
const get = tx.objectStore(this._STORE).get(key);
get.onsuccess = () => resolve(get.result);
get.onerror = () => resolve(null);
});
},
async _set(key, value) {
const db = await this._openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(this._STORE, 'readwrite');
tx.objectStore(this._STORE).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
async load() {
try {
const content = await this._get(this._CONTENT_KEY) || '';
return { content, checksum: '' };
} catch {
return { content: '', checksum: '' };
}
},
async save(content) {
// Request persistence on first save (triggered by user action = valid gesture)
if (!this._persistenceRequested) {
this._persistenceRequested = true;
this._requestPersistence();
}
try {
// Create backup before saving
const oldContent = await this._get(this._CONTENT_KEY);
if (oldContent) {
await this._addBackup(oldContent);
}
await this._set(this._CONTENT_KEY, content);
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
},
async _requestPersistence() {
if (navigator.storage?.persist) {
try {
const alreadyPersisted = await navigator.storage.persisted();
if (alreadyPersisted) {
this._persistenceGranted = true;
return;
}
// This will prompt the user in Firefox
this._persistenceGranted = await navigator.storage.persist();
} catch {
this._persistenceGranted = false;
}
}
},
async _addBackup(content) {
try {
const backups = (await this._get(this._BACKUPS_KEY)) || [];
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
backups.unshift({
name: `backlog_${ts}.md`,
content,
timestamp: new Date().toISOString(),
size: new TextEncoder().encode(content).length,
});
// Keep only last N backups
if (backups.length > this._MAX_BACKUPS) {
backups.length = this._MAX_BACKUPS;
}
await this._set(this._BACKUPS_KEY, backups);
} catch { /* backup failure is non-fatal */ }
},
async listBackups() {
try {
const backups = (await this._get(this._BACKUPS_KEY)) || [];
return backups.map(b => ({
name: b.name,
size: b.size,
timestamp: b.timestamp,
valid: b.content?.includes('<!-- SECTION: INTEGRITY -->'),
}));
} catch {
return [];
}
},
async restoreBackup(name) {
try {
const backups = (await this._get(this._BACKUPS_KEY)) || [];
const backup = backups.find(b => b.name === name);
if (!backup) return { ok: false, error: 'Backup not found' };
await this._set(this._CONTENT_KEY, backup.content);
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
},
async getHealthInfo() {
try {
const content = await this._get(this._CONTENT_KEY) || '';
const archiveContent = await this._get('archive') || '';
const estimate = await navigator.storage?.estimate?.() || {};
const persisted = await navigator.storage?.persisted?.() || false;
return {
masterSize: new TextEncoder().encode(content).length,
statsSize: 0,
browserStorage: true,
persisted,
quota: estimate.quota,
usage: estimate.usage,
archiveExists: !!archiveContent,
archiveSize: new TextEncoder().encode(archiveContent).length,
};
} catch {
return { masterSize: 0, statsSize: 0, browserStorage: true, persisted: false, archiveExists: false, archiveSize: 0 };
}
},
async loadArchive() {
try {
const content = await this._get('archive') || '';
return { content, checksum: '', exists: !!content };
} catch {
return { content: '', checksum: '', exists: false };
}
},
async saveArchive(backlogContent, archiveContent) {
try {
await this._set(this._CONTENT_KEY, backlogContent);
await this._set('archive', archiveContent);
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
},
async restoreFromArchive(backlogContent, archiveContent) {
try {
await this._set(this._CONTENT_KEY, backlogContent);
await this._set('archive', archiveContent);
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
},
};
// ---- Storage — detect backend and delegate ----
const Storage = {
backend: null,
mode: 'local', // 'api' | 'direct' | 'local'
async detect() {
if (await ApiBackend.detect()) {
this.backend = ApiBackend;
this.mode = 'api';
return 'api';
}
if (await DirectBackend.detect()) {
this.backend = DirectBackend;
this.mode = 'direct';
return 'direct';
}
if (await BrowserStorageBackend.detect()) {
this.backend = BrowserStorageBackend;
this.mode = 'browser';
return 'browser';
}
this.mode = 'local';
return 'local';
},
// Try to reconnect silently (no user gesture). Returns true if connected.
// For API mode this is always true; for Direct it checks the stored handle.
async tryAutoConnect() {
if (this.backend === DirectBackend) return DirectBackend.tryAutoConnect();
return true;
},
// Explicit connect — call from a user-gesture handler (button click).
async connect() {
if (this.backend === DirectBackend) await DirectBackend.connect();
},
// True only when a backend is detected AND fully initialised (dirHandle set for Direct).
isConnected() {
if (!this.backend) return false;
if (this.backend === DirectBackend) return !!DirectBackend.dirHandle;
return true;
},
async load() { return this.backend?.load() ?? null; },
async save(content) { return this.backend?.save(content) ?? null; },
async listBackups() { return this.backend?.listBackups() ?? []; },
async restoreBackup(name) { return this.backend?.restoreBackup(name) ?? { ok: false }; },
async getHealthInfo() { return this.backend?.getHealthInfo?.() ?? {}; },
async loadArchive() { return this.backend?.loadArchive?.() ?? { content: '', exists: false }; },
async saveArchive(backlog, archive) { return this.backend?.saveArchive?.(backlog, archive) ?? { ok: false }; },
async restoreFromArchive(backlog, archive) { return this.backend?.restoreFromArchive?.(backlog, archive) ?? { ok: false }; },
};
// ---- SyncPoller — detect external file changes (5s interval) ----
const SyncPoller = {
_timer: null,
lastChecksum: '',
lastChecksums: {}, // multi-project mode: { projectName: checksum }
_onExternalChange: null,
_isDirty: null,
_multi: false,
start({ onExternalChange, isDirty }) {
this._onExternalChange = onExternalChange;
this._isDirty = isDirty;
this._multi = false;
this._timer = setInterval(() => this._tick(), 5000);
},
// Multi-project mode (api only): poll GET /api/projects — ONE round trip for
// all checksums. A changed project is reloaded individually; a changed
// project SET (registered/renamed/unregistered externally) triggers a full
// reload via the 'set-changed' callback.
startMulti({ onExternalChange, isDirty }) {
this._onExternalChange = onExternalChange;
this._isDirty = isDirty;
this._multi = true;
this.lastChecksums = {};
this.syncChecksums(); // baseline; a baseline never fires callbacks
this._timer = setInterval(() => this._tickMulti(), 5000);
},
stop() {
if (this._timer) { clearInterval(this._timer); this._timer = null; }
this._multi = false;
},
// Re-baseline checksums after our own writes that bypass saveProject (e.g.
// per-project archive saves), so the next poll does not mistake them for
// external changes.
async syncChecksums() {
if (!this._multi) return;
try {
const projects = await ApiBackend.listProjects();
for (const p of projects) this.lastChecksums[p.name] = p.checksum || '';
} catch { /* silent on poll errors */ }
},
async _tick() {
if (Storage.mode === 'local' || !Storage.backend) return;
try {
const raw = await Storage.load();
if (!raw?.content) return;
const parsed = await Parser.parse(raw.content);
const cs = parsed.meta?.checksum || '';
if (!cs || cs === this.lastChecksum) return;
this.lastChecksum = cs;
if (this._isDirty?.()) {
this._onExternalChange?.('warn', null);
} else {
this._onExternalChange?.('reload', parsed);
}
} catch { /* silent on poll errors */ }
},
async _tickMulti() {
if (!this._multi) return;
let projects;
try { projects = await ApiBackend.listProjects(); }
catch { return; }
if (!projects?.length) return;
const prev = this.lastChecksums;
const known = Object.keys(prev);
const names = projects.map(p => p.name);
if (known.length > 0 && (names.length !== known.length || names.some(n => !known.includes(n)))) {
if (this._isDirty?.()) {
// Leave the baseline stale so the next tick retries after the
// pending save lands — reloading now would clobber unsaved edits.
this._onExternalChange?.('warn', null);
} else {
this.lastChecksums = Object.fromEntries(projects.map(p => [p.name, p.checksum || '']));
this._onExternalChange?.('set-changed', null);
}
return;
}
for (const p of projects) {
const cs = p.checksum || '';
if (!(p.name in prev)) { prev[p.name] = cs; continue; } // baseline a latecomer silently
if (!cs || cs === prev[p.name]) continue;
prev[p.name] = cs;
if (this._isDirty?.()) {
// Checksum already stored — the warn fires exactly once per change.
this._onExternalChange?.('warn', null);
} else {
try {
const raw = await ApiBackend.loadProject(p.name);
const parsed = await Parser.parse(raw.content);
walkTree(parsed.entries, e => { e._projectId = p.name; });
for (const h of parsed.history) h._projectId = p.name;
this._onExternalChange?.('project-reloaded', { name: p.name, parsed });
} catch { /* unreadable project — retry on the next checksum change */ }
}
}
},
};
// ---- buildDataFromStorage — assemble the full App data object from parsed content ----
async function buildDataFromStorage(parsed, backups, storageMode, sizeInfo = {}) {
const { entries, history, meta, checksumOk } = parsed;
// Assign level field (derived from tree depth, not stored in file)
function setLevels(items, level) {
for (const it of items) {
it.level = level;
if (it.children?.length) setLevels(it.children, level + 1);
}
}
setLevels(entries, 1);
// Migrate: ensure progress field is always a number
const progressDefaults = { done: 100, cancelled: 0, 'in-progress': 50, blocked: 25, postponed: 25, open: 0 };
walkTree(entries, it => {
if (typeof it.progress !== 'number') it.progress = progressDefaults[it.status] ?? 0;
else if (it.status === 'done') it.progress = 100;
});
// Status counts
const statusMix = { open: 0, 'in-progress': 0, blocked: 0, postponed: 0, done: 0, cancelled: 0 };
walkTree(entries, it => { statusMix[it.status] = (statusMix[it.status] || 0) + 1; });
// Most active item by history event count
const actCounts = {};
walkTree(entries, it => { actCounts[it.id] = 0; });
for (const h of history) { if (h.itemId in actCounts) actCounts[h.itemId]++; }
const topId = Object.entries(actCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
const topItem = topId ? findItem(entries, topId) : null;
const mostActiveProject = topItem?.title || entries[0]?.title || '—';
// Recent activity (last 7 days)
const sevenAgo = new Date(Date.now() - 7 * 86400000).toISOString();
const recentH = history.filter(h => h.timestamp >= sevenAgo);
const createdThisWeek = recentH.filter(h => h.action === 'item_created').length;
const completedThisWeek = recentH.filter(h => h.action === 'status_changed' && h.details.includes('→ done')).length;
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const completionByDay = dayNames.map(day => ({ day, count: 0 }));
const createdByDay = dayNames.map(day => ({ day, count: 0 }));
for (const h of recentH) {
const dow = (new Date(h.timestamp).getDay() + 6) % 7; // 0=Mon
if (h.action === 'status_changed' && h.details.includes('→ done')) completionByDay[dow].count++;
if (h.action === 'item_created') createdByDay[dow].count++;
}
// Average days spent in-progress: pair "→ in-progress" with next "in-progress →" per item.
const ipStart = {}; // itemId → start timestamp ms
const ipDurations = [];
for (const h of [...history].reverse()) { // chronological order
if (h.action !== 'status_changed') continue;
if (h.details.includes('→ in-progress')) ipStart[h.itemId] = new Date(h.timestamp).getTime();
else if (h.details.startsWith('in-progress →') && ipStart[h.itemId]) {
const days = (new Date(h.timestamp).getTime() - ipStart[h.itemId]) / 86400000;
if (days >= 0 && days < 365) ipDurations.push(days);
delete ipStart[h.itemId];
}
}
const avgInProgressDays = ipDurations.length > 0
? +(ipDurations.reduce((a, b) => a + b, 0) / ipDurations.length).toFixed(1)
: null;
const historyOldest = history.length > 0 ? history[history.length - 1].timestamp : null;
const backupDirSize = backups.reduce((s, b) => s + (b.size || 0), 0);
const historySize = new TextEncoder().encode(JSON.stringify(history)).length;
const modeLabel = storageMode === 'api' ? 'API server'
: storageMode === 'direct' ? 'Direct (File System API)'
: storageMode === 'browser' ? 'Browser storage (IndexedDB)'
: 'localStorage only';
return {
entries,
history,
meta: meta || { saved: null, checksum: '—', entryCount: countAll(entries), historyCount: history.length },
health: {
integrityOk: checksumOk,
lastSave: meta?.saved || null,
lastBackup: backups[0]?.timestamp || null,
masterSize: sizeInfo.masterSize || 0,
backupDirSize,
backupCount: backups.length,
statsSize: sizeInfo.statsSize || 0,
historySize,
historyOldest,
mode: modeLabel,
masterPath: sizeInfo.masterPath || (sizeInfo.dirName ? `${sizeInfo.dirName}/backlog.md` : null),
backupsPath: sizeInfo.backupsPath || (sizeInfo.dirName ? `${sizeInfo.dirName}/backups/` : null),
},
stats: {
createdThisWeek,
completedThisWeek,
avgInProgressDays,
mostActiveProject,
completionByDay,
createdByDay,
statusMix,
},
backups,
};
}
Object.assign(window, {
Parser,
ApiBackend,
DirectBackend,
BrowserStorageBackend,
Storage,
SyncPoller,
buildDataFromStorage,
});