981 lines
35 KiB
React
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,
|
|
});
|