95a628249d
The RichTextEditor was not updating its content when switching from one item to another because initializedRef was only reset when value became null. Now uses prevValueRef to properly detect value changes.
734 lines
26 KiB
React
734 lines
26 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;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
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}`);
|
|
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 || '',
|
|
};
|
|
} catch { return {}; }
|
|
},
|
|
};
|
|
|
|
// ---- 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();
|
|
return { masterSize: f.size, statsSize: 0, dirName: this.dirHandle?.name ?? null };
|
|
} catch { return { masterSize: 0, statsSize: 0, dirName: this.dirHandle?.name ?? null }; }
|
|
},
|
|
};
|
|
|
|
// ---- 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 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,
|
|
};
|
|
} catch {
|
|
return { masterSize: 0, statsSize: 0, browserStorage: true, persisted: false };
|
|
}
|
|
},
|
|
};
|
|
|
|
// ---- 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?.() ?? {}; },
|
|
};
|
|
|
|
// ---- 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,
|
|
});
|