// Storage layer: Parser, ApiBackend, DirectBackend, Storage detection, SyncPoller. // Compatible with web/index.html and server/server.py file format. // // File format (backlog.md): // # Backlog // // - [ ] [P1] Title *(due: ..., reason: ..., tags: tag1 tag2, progress: 50)* // - [x] [P0] Child *(progress: 100)* // // | Timestamp | Item ID | Action | Details | // // // ---- Parser ---- const Parser = { async parse(text) { if (!text || !text.trim()) { return { entries: [], history: [], meta: null, checksumOk: false }; } const entStart = text.indexOf(''); const histStart = text.indexOf(''); const intStart = text.indexOf(''); 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(''.length).trim(); const rawHist = historyText.slice(''.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 = ``; return `# Backlog\n\n\n\n${entriesText}\n\n\n\n${historyText}\n\n\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() { const r = await fetch('/api/archive'); if (!r.ok) throw new Error('API archive load failed: ' + r.status); return r.json(); // { content, checksum, exists } }, async saveArchive(backlogContent, archiveContent) { const r = await fetch('/api/archive', { 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) { const r = await fetch('/api/archive/restore', { 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(); }, }; // ---- 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(''), }); } 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(''), })); } 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: '', _onExternalChange: null, _isDirty: null, start({ onExternalChange, isDirty }) { this._onExternalChange = onExternalChange; this._isDirty = isDirty; this._timer = setInterval(() => this._tick(), 5000); }, stop() { if (this._timer) { clearInterval(this._timer); this._timer = null; } }, 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 */ } }, }; // ---- 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, });