diff --git a/README.md b/README.md index 1d86956..edc3183 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,22 @@ All your tasks live in `backlog.md`, structured as: You can edit this file in any text editor. The app detects external changes and reloads automatically. +### Archive File + +Completed tasks can be moved to `archive.md` to keep the main backlog clean. The archive uses the same format with an additional `restore-path` field: + +```markdown +# Archive + + + +- [x] [P1] Old completed task *(archived: 2025-05-01, restore-path: i-abc/i-def)* + > Original body preserved + - [x] Child task also archived +``` + +The `restore-path` stores the original parent chain, allowing items to be restored to their original location. If the parent no longer exists, items restore to root level. + ### Features - **4-level nesting** — Area → Project → Task → Sub-task @@ -108,7 +124,8 @@ You can edit this file in any text editor. The app detects external changes and - **Automatic backups** — timestamped on every save, rotating retention - **Stats & metrics** — items created/completed, avg time in-progress, most active project — all from real history data - **Import/Export** — Markdown or JSON, with checksum validation -- **Admin page** — health monitoring, backup browser, stats overview, manual actions +- **Archive** — move done/cancelled items to separate `archive.md` to reduce clutter; searchable archive with restore capability; items return to original location +- **Admin page** — health monitoring, backup browser, archive manager, stats overview, manual actions - **Dark mode** — system / light / dark, configurable in Admin → Appearance; persisted across reloads - **Icon sets** — 4 styles (Color, Flat, Emoji, ASCII), configurable in Admin → Appearance; persisted across reloads - **Chrome / Edge direct file access** — reads and writes `backlog.md` on disk directly via the File System Access API; no server needed @@ -179,8 +196,9 @@ personal-backlog/ │ ├── architecture.md # High-level architecture │ └── tdd.md # Technical Design Document ├── server/ -│ ├── server.py # Python REST API server (~420 LoC, stdlib only) +│ ├── server.py # Python REST API server (~450 LoC, stdlib only) │ ├── backlog.md # Master data file (created on first run) +│ ├── archive.md # Archived items (created on first archive) │ ├── backups/ # Automatic timestamped backups │ └── stats.jsonl # Append-only analytics log ├── web/ # V2 source code (React 18 + JSX) + build tooling diff --git a/server/server.py b/server/server.py index aa51b59..e23d295 100644 --- a/server/server.py +++ b/server/server.py @@ -23,6 +23,7 @@ class Config: self.dir = directory.resolve() self.port = port self.master = self.dir / "backlog.md" + self.archive = self.dir / "archive.md" self.backups_dir = self.dir / "backups" self.stats_file = self.dir / "stats.jsonl" self.web_dir = web_dir.resolve() if web_dir else Path(__file__).parent.parent / "webapp" @@ -129,6 +130,26 @@ def write_master(content: str) -> dict: return {"ok": True, "checksum": meta["checksum"] if meta else "", "saved": meta["saved"] if meta else ""} +def read_archive() -> dict: + """Read archive.md, return empty structure if not exists.""" + if not CONFIG.archive.exists(): + return {"content": "", "checksum": "", "size": 0, "exists": False} + text = CONFIG.archive.read_text(encoding="utf-8") + entries, history, meta = parse_markdown_sections(text) + checksum = meta["checksum"] if meta else "" + return {"content": text, "checksum": checksum, "size": len(text.encode("utf-8")), "exists": True} + + +def write_archive(content: str) -> dict: + """Write archive.md (append-only semantics enforced at API level).""" + tmp = CONFIG.archive.with_suffix(".md.tmp") + tmp.write_text(content, encoding="utf-8") + parse_markdown_sections(content) # Verify it parses + tmp.replace(CONFIG.archive) + _, _, meta = parse_markdown_sections(content) + return {"ok": True, "checksum": meta["checksum"] if meta else "", "saved": meta["saved"] if meta else ""} + + def rotate_backups(): files = sorted(CONFIG.backups_dir.glob("backlog_*.md"), key=lambda p: p.stat().st_mtime) if not files: @@ -285,6 +306,7 @@ class Handler(BaseHTTPRequestHandler): if path == "/api/health": info = read_master() + archive_info = read_archive() backups = list_backups() self._json_response({ "status": "ok", @@ -294,6 +316,9 @@ class Handler(BaseHTTPRequestHandler): "backupCount": len(backups), "masterPath": str(CONFIG.master), "backupsPath": str(CONFIG.backups_dir), + "archiveExists": archive_info["exists"], + "archiveSize": archive_info["size"], + "archivePath": str(CONFIG.archive), }) return @@ -308,6 +333,20 @@ class Handler(BaseHTTPRequestHandler): self._json_response({"content": info["content"], "checksum": info["checksum"]}) return + if path == "/api/archive": + info = read_archive() + if not info["exists"]: + self._json_response({"content": "", "checksum": "", "exists": False}) + return + client_checksum = qs.get("checksum", [None])[0] + if client_checksum and client_checksum == info["checksum"]: + self.send_response(304) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + return + self._json_response({"content": info["content"], "checksum": info["checksum"], "exists": True}) + return + if path == "/api/backups": self._json_response({"backups": list_backups()}) return @@ -342,6 +381,32 @@ class Handler(BaseHTTPRequestHandler): self._json_response(result) return + if path == "/api/archive": + # Archive items: save both backlog and archive atomically + body = self._read_json_body() + backlog_content = body.get("backlog", "") + archive_content = body.get("archive", "") + # Validate both parse correctly + parse_markdown_sections(backlog_content) + parse_markdown_sections(archive_content) + # Write archive first (append-only), then backlog + archive_result = write_archive(archive_content) + backlog_result = write_master(backlog_content) + self._json_response({"ok": True, "backlog": backlog_result, "archive": archive_result}) + return + + if path == "/api/archive/restore": + # Restore items from archive: save both backlog and archive atomically + body = self._read_json_body() + backlog_content = body.get("backlog", "") + archive_content = body.get("archive", "") + parse_markdown_sections(backlog_content) + parse_markdown_sections(archive_content) + backlog_result = write_master(backlog_content) + archive_result = write_archive(archive_content) + self._json_response({"ok": True, "backlog": backlog_result, "archive": archive_result}) + return + if path == "/api/export": body = self._read_json_body() fmt = body.get("format", "json") diff --git a/web/admin.jsx b/web/admin.jsx index 2eccc00..67f1a55 100644 --- a/web/admin.jsx +++ b/web/admin.jsx @@ -461,7 +461,7 @@ function AppearanceCard({ tweaks, setTweak }) { ); } -function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onForceBackup, onCompact, onRestore, onDownloadBackup }) { +function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onForceBackup, onCompact, onRestore, onDownloadBackup, archiveData, onArchiveItems, onRestoreItems, onViewItem }) { const [previewBackup, setPreviewBackup] = useState(null); const counts = countByStatus(data.entries); @@ -650,6 +650,15 @@ function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onFo )} + {/* Archive manager */} + + {/* Recent history */}
@@ -688,4 +697,156 @@ function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onFo ); } +// ---- Archive Manager Card ---- +function ArchiveCard({ entries, archiveData, onArchiveItems, onRestoreItems, onViewItem }) { + const [selectedIds, setSelectedIds] = useState([]); + const [filter, setFilter] = useState('done'); // 'done', 'cancelled', 'all' + const [searchTerm, setSearchTerm] = useState(''); + const [archiveSearchTerm, setArchiveSearchTerm] = useState(''); + const [expandedArchive, setExpandedArchive] = useState(false); + + // Find archivable items (done or cancelled) + const archivableItems = []; + walkTree(entries, (item) => { + if (filter === 'all' || item.status === filter) { + if (item.status === 'done' || item.status === 'cancelled') { + archivableItems.push(item); + } + } + }); + + // Filter by search + const filteredItems = archivableItems.filter(it => + !searchTerm || it.title.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + // Archive entries + const archiveEntries = archiveData?.entries || []; + const filteredArchiveItems = []; + walkTree(archiveEntries, (item) => { + if (!archiveSearchTerm || item.title.toLowerCase().includes(archiveSearchTerm.toLowerCase())) { + filteredArchiveItems.push(item); + } + }); + + const toggleSelect = (id) => { + setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); + }; + + const selectAll = () => { + setSelectedIds(filteredItems.map(it => it.id)); + }; + + const handleArchive = () => { + if (selectedIds.length > 0) { + onArchiveItems(selectedIds); + setSelectedIds([]); + } + }; + + return ( +
+
+

Archive manager

+ + {archivableItems.length} archivable · {archiveEntries.length} archived + +
+ + {/* Archivable items */} +
+
+ Archivable items + + setSearchTerm(e.target.value)} + className="archive-search" + /> + + +
+ + {filteredItems.length === 0 ? ( +
No archivable items found
+ ) : ( +
+ {filteredItems.slice(0, 20).map(it => ( + + ))} + {filteredItems.length > 20 && ( +
...and {filteredItems.length - 20} more
+ )} +
+ )} +
+ + {/* Archive browser */} +
+
+ Archived items ({archiveEntries.length}) + + {expandedArchive && ( + setArchiveSearchTerm(e.target.value)} + className="archive-search" + /> + )} +
+ + {expandedArchive && ( + filteredArchiveItems.length === 0 ? ( +
+ {archiveEntries.length === 0 ? 'Archive is empty' : 'No matches found'} +
+ ) : ( +
+ {filteredArchiveItems.slice(0, 30).map(it => ( +
+ + onViewItem?.(it)}>{it.title} + {it.archived && ( + archived {it.archived} + )} + +
+ ))} + {filteredArchiveItems.length > 30 && ( +
...and {filteredArchiveItems.length - 30} more
+ )} +
+ ) + )} +
+
+ ); +} + window.AdminPage = AdminPage; +window.ArchiveCard = ArchiveCard; diff --git a/web/app.jsx b/web/app.jsx index 39554ce..8a5d22f 100644 --- a/web/app.jsx +++ b/web/app.jsx @@ -56,6 +56,7 @@ function App() { const [itemDialog, setItemDialog] = useStateMain(null); const [confirm, setConfirm] = useStateMain(null); const [needsConnect, setNeedsConnect] = useStateMain(false); + const [archiveData, setArchiveData] = useStateMain({ entries: [], history: [] }); const TWEAK_DEFAULTS = { accent_hue: 35, density: 'comfortable', show_ids: false, paper_texture: true, status_style: 'color', sort_mode: 'priority', theme: 'system' }; const [tweaks, setTweak] = useTweaks({ ...TWEAK_DEFAULTS, ...(loadLocalState()?.tweaks ?? {}) }); @@ -441,6 +442,19 @@ function App() { const filteredCount = useMemoMain(() => countAll(filtered), [filtered]); const totalCount = useMemoMain(() => countAll(data.entries), [data]); + const archiveMatchCount = useMemoMain(() => { + if (!filters.text?.trim()) return 0; + const q = filters.text.trim().toLowerCase(); + let count = 0; + walkTree(archiveData.entries, (item) => { + if (item.title.toLowerCase().includes(q) || + item.body?.toLowerCase().includes(q) || + item.tags?.some(t => t.toLowerCase().includes(q))) { + count++; + } + }); + return count; + }, [filters.text, archiveData.entries]); // ---- Keyboard shortcuts ---- useEffectMain(() => { @@ -508,6 +522,142 @@ function App() { triggerSave('Imported'); }; + // ---- Archive functions ---- + const loadArchive = async () => { + try { + const raw = await Storage.loadArchive(); + if (raw.exists && raw.content) { + const parsed = await Parser.parse(raw.content); + setArchiveData({ entries: parsed.entries || [], history: parsed.history || [] }); + } + } catch (e) { + console.error('Failed to load archive:', e); + } + }; + + // Load archive when switching to admin view + useEffectMain(() => { + if (view === 'admin') loadArchive(); + }, [view]); + + const handleArchiveItems = async (itemIds) => { + const now = new Date().toISOString().slice(0, 10); + const toArchive = []; + + // Build items to archive with restore-path metadata + for (const id of itemIds) { + const item = findItem(data.entries, id); + if (!item) continue; + const restorePath = getRestorePath(data.entries, id); + // Deep clone and add archive metadata + const cloned = JSON.parse(JSON.stringify(item)); + cloned.archived = now; + cloned.restorePath = restorePath || null; + toArchive.push(cloned); + } + + if (toArchive.length === 0) return; + + // Remove from backlog + const { entries: newEntries } = removeItems([...data.entries], itemIds); + + // Add to archive + const newArchiveEntries = [...archiveData.entries, ...toArchive]; + + // Add history entries + const newHistory = [...data.history]; + for (const item of toArchive) { + newHistory.unshift({ + timestamp: new Date().toISOString(), + itemId: item.id, + action: 'item_archived', + details: `moved to archive.md` + }); + } + + try { + // Serialize both files + const backlogContent = await Parser.serialize({ entries: newEntries, history: newHistory }); + const archiveContent = await Parser.serialize({ entries: newArchiveEntries, history: archiveData.history }); + + // Save both atomically + const result = await Storage.saveArchive(backlogContent, archiveContent); + if (!result.ok) throw new Error('Archive failed'); + + // Update local state + setData(d => ({ ...d, entries: newEntries, history: newHistory })); + setArchiveData({ entries: newArchiveEntries, history: archiveData.history }); + isDirtyRef.current = false; + + showToast(`Archived ${toArchive.length} item${toArchive.length > 1 ? 's' : ''}`); + } catch (e) { + showToast('Archive failed: ' + e.message, 'err'); + } + }; + + const handleRestoreItems = async (itemIds) => { + const toRestore = []; + const remainingArchive = []; + + // Find items to restore + for (const item of archiveData.entries) { + if (itemIds.includes(item.id)) { + toRestore.push(item); + } else { + remainingArchive.push(item); + } + } + + if (toRestore.length === 0) return; + + // Restore to backlog + const newEntries = [...data.entries]; + const warnings = []; + for (const item of toRestore) { + const restorePath = item.restorePath; + // Clear archive metadata + delete item.archived; + delete item.restorePath; + // Try to insert at original location + const inserted = insertAtPath(newEntries, item, restorePath); + if (!inserted) { + warnings.push(item.title); + } + } + + // Add history entries + const newHistory = [...data.history]; + for (const item of toRestore) { + newHistory.unshift({ + timestamp: new Date().toISOString(), + itemId: item.id, + action: 'item_restored', + details: `restored from archive` + }); + } + + try { + // Serialize both files + const backlogContent = await Parser.serialize({ entries: newEntries, history: newHistory }); + const archiveContent = await Parser.serialize({ entries: remainingArchive, history: archiveData.history }); + + // Save both atomically + const result = await Storage.restoreFromArchive(backlogContent, archiveContent); + if (!result.ok) throw new Error('Restore failed'); + + // Update local state + setData(d => ({ ...d, entries: newEntries, history: newHistory })); + setArchiveData({ entries: remainingArchive, history: archiveData.history }); + isDirtyRef.current = false; + + let msg = `Restored ${toRestore.length} item${toRestore.length > 1 ? 's' : ''}`; + if (warnings.length) msg += ` (${warnings.length} to root - parent not found)`; + showToast(msg); + } catch (e) { + showToast('Restore failed: ' + e.message, 'err'); + } + }; + const saveLabel = saveState.status === 'saving' ? 'Saving…' : saveState.status === 'error' ? 'Save failed' : `Saved · ${fmtTimestamp(saveState.lastSaved)}`; @@ -584,7 +734,14 @@ function App() {
Backlog

- {hasFilters ? <>{filteredCount} of {totalCount} items : <>{totalCount} items} + {hasFilters ? ( + <> + {filteredCount} of {totalCount} items + {archiveMatchCount > 0 && <> · {archiveMatchCount} in archive} + + ) : ( + <>{totalCount} items + )}

@@ -635,6 +792,15 @@ function App() { manualOrder={tweaks.sort_mode === 'manual'} /> )} + {filters.text?.trim() && ( + setItemDialog({ mode: 'view', initial: item })} + /> + )}
@@ -664,6 +830,10 @@ function App() { showToast(`${name.slice(0, 28)}… — download not available in direct mode`); } }} + archiveData={archiveData} + onArchiveItems={handleArchiveItems} + onRestoreItems={handleRestoreItems} + onViewItem={(item) => setItemDialog({ mode: 'view', initial: item })} /> )} @@ -941,4 +1111,63 @@ function ImportExportDialog({ open, data, storageMode, onClose, onImport }) { ); } +// ----- Archive Search Results ----- +function ArchiveSearchResults({ query, archiveEntries, onRestore, onLoadArchive, onView }) { + const [loading, setLoading] = useStateMain(false); + const loadedRef = useRefMain(false); + + // Auto-load archive on first render + useEffectMain(() => { + if (!loadedRef.current && archiveEntries.length === 0) { + loadedRef.current = true; + setLoading(true); + onLoadArchive().finally(() => setLoading(false)); + } + }, []); + + // Filter archive entries by query + const matches = []; + if (query) { + const q = query.toLowerCase(); + walkTree(archiveEntries, (item) => { + if (item.title.toLowerCase().includes(q) || + item.body?.toLowerCase().includes(q) || + item.tags?.some(t => t.toLowerCase().includes(q))) { + matches.push(item); + } + }); + } + + if (loading) { + return ( +
+
Archive — loading...
+
+ ); + } + + if (matches.length === 0) return null; + + return ( +
+
Archive ({matches.length})
+
+ {matches.slice(0, 10).map(item => ( +
+ + onView(item)}>{item.title} + {item.archived && {item.archived}} + +
+ ))} + {matches.length > 10 && ( +
+ ...and {matches.length - 10} more (see Admin → Archive) +
+ )} +
+
+ ); +} + window.App = App; diff --git a/web/dialogs.jsx b/web/dialogs.jsx index 512fa7a..ca2a6b4 100644 --- a/web/dialogs.jsx +++ b/web/dialogs.jsx @@ -305,8 +305,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
@@ -320,17 +320,22 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] }) onChange={(e) => setTitle(e.target.value)} placeholder="What needs to happen?" required + readOnly={mode === "view"} />
- - + + {mode === "view" ? ( +
+ ) : ( + + )}
@@ -340,7 +345,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] }) {PRIORITIES.map(p => ( @@ -349,7 +355,7 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
- setDue(e.target.value)}/> + setDue(e.target.value)} readOnly={mode === "view"}/>
@@ -359,7 +365,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] }) {STATUSES.map(s => ( ))} @@ -400,32 +407,52 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] }) setReason(e.target.value)} - placeholder="Waiting for…"/> + placeholder="Waiting for…" + readOnly={mode === "view"}/> )}
- - + + {mode === "view" ? ( +
+ {tags.length === 0 ? No tags : tags.map(t => ( + {t} + ))} +
+ ) : ( + + )}
- ⌘↩ to save · Esc to cancel -
- - -
+ {mode === "view" ? ( + <> + Read-only · archived {initial?.archived || ''} +
+ +
+ + ) : ( + <> + ⌘↩ to save · Esc to cancel +
+ + +
+ + )}
diff --git a/web/helpers.jsx b/web/helpers.jsx index 1a42d32..6fd3894 100644 --- a/web/helpers.jsx +++ b/web/helpers.jsx @@ -520,11 +520,71 @@ const ClipboardUtils = { } }; +// ---- Archive Helpers ---- +function getRestorePath(entries, itemId) { + // Build path of parent IDs from root to item's parent + const path = []; + function find(items, targetId, currentPath) { + for (const it of items) { + if (it.id === targetId) return currentPath; + if (it.children?.length) { + const result = find(it.children, targetId, [...currentPath, it.id]); + if (result) return result; + } + } + return null; + } + return find(entries, itemId, [])?.join('/') || ''; +} + +function removeItems(entries, itemIds) { + // Remove items by ID, returns removed items with their children + const removed = []; + const idsSet = new Set(itemIds); + function remove(items) { + const kept = []; + for (const it of items) { + if (idsSet.has(it.id)) { + removed.push(it); + } else { + if (it.children?.length) it.children = remove(it.children); + kept.push(it); + } + } + return kept; + } + const newEntries = remove(entries); + return { entries: newEntries, removed }; +} + +function insertAtPath(entries, item, restorePath) { + // Insert item at the path specified by parent IDs + if (!restorePath) { + entries.push(item); + return true; + } + const pathIds = restorePath.split('/').filter(Boolean); + let current = entries; + for (const pid of pathIds) { + const parent = current.find(it => it.id === pid); + if (!parent) { + // Parent not found - insert at root with warning + entries.push(item); + return false; + } + if (!parent.children) parent.children = []; + current = parent.children; + } + current.push(item); + return true; +} + Object.assign(window, { STATUSES, STATUS_BY_KEY, STATUS_ORDER, PRIORITIES, parseDate, daysBetween, isOverdue, dueRelative, fmtTimestamp, fmtBytes, fmtRelative, fmtShortDate, walkTree, findItem, allTags, countAll, countByStatus, filterTree, Icon, StatusIcon, ColorStatusIcon, STATUS_COLORS, STATUS_EMOJI, StatusStyleContext, PROGRESS_STEPS, snapProgress, progressFor, shouldShowProgress, cycleProgress, ProgressGauge, - MarkdownUtils, ClipboardUtils + MarkdownUtils, ClipboardUtils, + getRestorePath, removeItems, insertAtPath }); diff --git a/web/storage.jsx b/web/storage.jsx index c09ea28..eb3b1d1 100644 --- a/web/storage.jsx +++ b/web/storage.jsx @@ -103,16 +103,19 @@ const Parser = { 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; + 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(); } } @@ -131,6 +134,8 @@ const Parser = { 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 }); @@ -163,6 +168,8 @@ const Parser = { 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}`); @@ -249,9 +256,38 @@ const ApiBackend = { 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) ---- @@ -376,8 +412,31 @@ const DirectBackend = { 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 }; } + 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 }; }, }; @@ -518,6 +577,7 @@ const BrowserStorageBackend = { 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 { @@ -527,9 +587,40 @@ const BrowserStorageBackend = { 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 }; + 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 }; } }, }; @@ -583,6 +674,9 @@ const Storage = { 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) ---- diff --git a/web/styles.css b/web/styles.css index 130834c..78747ac 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1304,3 +1304,99 @@ input { font: inherit; color: inherit; } .field-row { grid-template-columns: 1fr; } .status-grid { grid-template-columns: repeat(2, 1fr); } } + +/* ---------- Archive Manager ---------- */ +.archive-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--rule-2); } +.archive-section:first-of-type { margin-top: 8px; padding-top: 0; border-top: 0; } +.archive-toolbar { + display: flex; align-items: center; gap: 10px; + margin-bottom: 12px; flex-wrap: wrap; +} +.archive-label { font-size: 12px; font-weight: 600; color: var(--ink-2); } +.archive-filter { + padding: 4px 8px; font-size: 12px; + border: 1px solid var(--rule); border-radius: var(--radius); + background: var(--paper); color: var(--ink); +} +.archive-search { + padding: 4px 10px; font-size: 12px; + border: 1px solid var(--rule); border-radius: var(--radius); + background: var(--paper); color: var(--ink); + width: 160px; +} +.archive-search::placeholder { color: var(--ink-3); } +.archive-empty { + padding: 20px; text-align: center; + color: var(--ink-3); font-size: 13px; +} +.archive-list { + display: flex; flex-direction: column; gap: 4px; + max-height: 280px; overflow-y: auto; +} +.archive-item { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; background: var(--bg); border-radius: var(--radius); + font-size: 13px; cursor: pointer; + transition: background 0.12s; +} +.archive-item:hover { background: var(--bg-2); } +.archive-item.archived { cursor: default; } +.archive-item input[type="checkbox"] { flex-shrink: 0; } +.archive-item .glyph { flex-shrink: 0; width: 18px; text-align: center; } +.archive-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.archive-item-children { + font-size: 11px; color: var(--ink-3); + padding: 2px 6px; background: var(--rule-2); border-radius: 3px; +} +.archive-item-date { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); } +.archive-more { padding: 8px; text-align: center; font-size: 12px; color: var(--ink-3); } +.btn-sm { padding: 4px 10px; font-size: 12px; } +.btn-xs { padding: 2px 8px; font-size: 11px; } + +/* ---------- Archive Search Results ---------- */ +.archive-search-section { + margin-top: 16px; padding-top: 16px; + border-top: 1px dashed var(--rule); +} +.archive-search-toggle { + display: flex; align-items: center; gap: 6px; + padding: 6px 10px; + font-size: 12px; font-weight: 500; color: var(--ink-2); + background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius); + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.archive-search-toggle:hover { background: var(--bg-2); color: var(--ink); } +.archive-loading { color: var(--ink-3); font-style: italic; } +.archive-count { color: var(--ink-3); } +.archive-search-results { + margin-top: 8px; padding: 8px; + background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius); +} +.archive-search-empty { + padding: 12px; text-align: center; + color: var(--ink-3); font-size: 13px; +} +.archive-search-item { + display: flex; align-items: center; gap: 8px; + padding: 6px 8px; + border-radius: var(--radius); + transition: background 0.12s; +} +.archive-search-item:hover { background: var(--bg-2); } +.archive-search-item .glyph { flex-shrink: 0; width: 18px; text-align: center; } +.archive-search-title { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.archive-search-date { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); } +.archive-search-more { padding: 8px; text-align: center; font-size: 12px; color: var(--ink-3); } +.archive-search-header { + font-size: 12px; font-weight: 600; color: var(--ink-2); + padding: 4px 0; margin-bottom: 8px; +} +.archive-match-hint { font-size: 14px; color: var(--ink-3); font-weight: 400; } +.rte-readonly { + padding: 10px 12px; background: var(--bg); border: 1px solid var(--rule); + border-radius: var(--radius); font-size: 13px; line-height: 1.5; color: var(--ink-2); + min-height: 60px; max-height: 200px; overflow-y: auto; +} +.rte-readonly:empty::before { content: 'No details'; color: var(--ink-3); } +.tag-list-readonly { display: flex; flex-wrap: wrap; gap: 6px; } diff --git a/webapp/index-style-v2.html b/webapp/index-style-v2.html index 5329c4c..1467ef9 100644 --- a/webapp/index-style-v2.html +++ b/webapp/index-style-v2.html @@ -1317,6 +1317,102 @@ input { font: inherit; color: inherit; } .status-grid { grid-template-columns: repeat(2, 1fr); } } +/* ---------- Archive Manager ---------- */ +.archive-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--rule-2); } +.archive-section:first-of-type { margin-top: 8px; padding-top: 0; border-top: 0; } +.archive-toolbar { + display: flex; align-items: center; gap: 10px; + margin-bottom: 12px; flex-wrap: wrap; +} +.archive-label { font-size: 12px; font-weight: 600; color: var(--ink-2); } +.archive-filter { + padding: 4px 8px; font-size: 12px; + border: 1px solid var(--rule); border-radius: var(--radius); + background: var(--paper); color: var(--ink); +} +.archive-search { + padding: 4px 10px; font-size: 12px; + border: 1px solid var(--rule); border-radius: var(--radius); + background: var(--paper); color: var(--ink); + width: 160px; +} +.archive-search::placeholder { color: var(--ink-3); } +.archive-empty { + padding: 20px; text-align: center; + color: var(--ink-3); font-size: 13px; +} +.archive-list { + display: flex; flex-direction: column; gap: 4px; + max-height: 280px; overflow-y: auto; +} +.archive-item { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px; background: var(--bg); border-radius: var(--radius); + font-size: 13px; cursor: pointer; + transition: background 0.12s; +} +.archive-item:hover { background: var(--bg-2); } +.archive-item.archived { cursor: default; } +.archive-item input[type="checkbox"] { flex-shrink: 0; } +.archive-item .glyph { flex-shrink: 0; width: 18px; text-align: center; } +.archive-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.archive-item-children { + font-size: 11px; color: var(--ink-3); + padding: 2px 6px; background: var(--rule-2); border-radius: 3px; +} +.archive-item-date { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); } +.archive-more { padding: 8px; text-align: center; font-size: 12px; color: var(--ink-3); } +.btn-sm { padding: 4px 10px; font-size: 12px; } +.btn-xs { padding: 2px 8px; font-size: 11px; } + +/* ---------- Archive Search Results ---------- */ +.archive-search-section { + margin-top: 16px; padding-top: 16px; + border-top: 1px dashed var(--rule); +} +.archive-search-toggle { + display: flex; align-items: center; gap: 6px; + padding: 6px 10px; + font-size: 12px; font-weight: 500; color: var(--ink-2); + background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius); + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.archive-search-toggle:hover { background: var(--bg-2); color: var(--ink); } +.archive-loading { color: var(--ink-3); font-style: italic; } +.archive-count { color: var(--ink-3); } +.archive-search-results { + margin-top: 8px; padding: 8px; + background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius); +} +.archive-search-empty { + padding: 12px; text-align: center; + color: var(--ink-3); font-size: 13px; +} +.archive-search-item { + display: flex; align-items: center; gap: 8px; + padding: 6px 8px; + border-radius: var(--radius); + transition: background 0.12s; +} +.archive-search-item:hover { background: var(--bg-2); } +.archive-search-item .glyph { flex-shrink: 0; width: 18px; text-align: center; } +.archive-search-title { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.archive-search-date { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); } +.archive-search-more { padding: 8px; text-align: center; font-size: 12px; color: var(--ink-3); } +.archive-search-header { + font-size: 12px; font-weight: 600; color: var(--ink-2); + padding: 4px 0; margin-bottom: 8px; +} +.archive-match-hint { font-size: 14px; color: var(--ink-3); font-weight: 400; } +.rte-readonly { + padding: 10px 12px; background: var(--bg); border: 1px solid var(--rule); + border-radius: var(--radius); font-size: 13px; line-height: 1.5; color: var(--ink-2); + min-height: 60px; max-height: 200px; overflow-y: auto; +} +.rte-readonly:empty::before { content: 'No details'; color: var(--ink-3); } +.tag-list-readonly { display: flex; flex-wrap: wrap; gap: 6px; } + @@ -2673,6 +2769,66 @@ const ClipboardUtils = { return true; } }; + +// ---- Archive Helpers ---- +function getRestorePath(entries, itemId) { + // Build path of parent IDs from root to item's parent + const path = []; + function find(items, targetId, currentPath) { + for (const it of items) { + if (it.id === targetId) return currentPath; + if (it.children?.length) { + const result = find(it.children, targetId, [...currentPath, it.id]); + if (result) return result; + } + } + return null; + } + return find(entries, itemId, [])?.join('/') || ''; +} +function removeItems(entries, itemIds) { + // Remove items by ID, returns removed items with their children + const removed = []; + const idsSet = new Set(itemIds); + function remove(items) { + const kept = []; + for (const it of items) { + if (idsSet.has(it.id)) { + removed.push(it); + } else { + if (it.children?.length) it.children = remove(it.children); + kept.push(it); + } + } + return kept; + } + const newEntries = remove(entries); + return { + entries: newEntries, + removed + }; +} +function insertAtPath(entries, item, restorePath) { + // Insert item at the path specified by parent IDs + if (!restorePath) { + entries.push(item); + return true; + } + const pathIds = restorePath.split('/').filter(Boolean); + let current = entries; + for (const pid of pathIds) { + const parent = current.find(it => it.id === pid); + if (!parent) { + // Parent not found - insert at root with warning + entries.push(item); + return false; + } + if (!parent.children) parent.children = []; + current = parent.children; + } + current.push(item); + return true; +} Object.assign(window, { STATUSES, STATUS_BY_KEY, @@ -2705,7 +2861,10 @@ Object.assign(window, { cycleProgress, ProgressGauge, MarkdownUtils, - ClipboardUtils + ClipboardUtils, + getRestorePath, + removeItems, + insertAtPath });