544 lines
20 KiB
React
544 lines
20 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 }];
|
|
|
|
for (const line of entriesText.split('\n')) {
|
|
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: [],
|
|
};
|
|
stack[stack.length - 1].children.push(item);
|
|
stack.push({ children: item.children, depth, level: item.level });
|
|
}
|
|
|
|
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}`);
|
|
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 }; }
|
|
},
|
|
};
|
|
|
|
// ---- 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';
|
|
}
|
|
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)'
|
|
: '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,
|
|
Storage,
|
|
SyncPoller,
|
|
buildDataFromStorage,
|
|
});
|