Add cold archive feature for done/cancelled items

- Separate archive.md file for archived items (append-only)
- Archive manager in Admin page with filters and search
- Global search shows archive results with count in header
- Restore items to original location via restore-path metadata
- Read-only view dialog for archived items (double-click to open)
- Python API endpoints: GET/POST /api/archive, POST /api/archive/restore
- Support in all storage backends (API, Direct, Browser)
This commit is contained in:
Ilia Sharin
2026-08-20 13:54:06 -04:00
parent 95a628249d
commit 2bcedda2c2
9 changed files with 1552 additions and 73 deletions
+20 -2
View File
@@ -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. 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
<!-- SECTION: ENTRIES -->
- [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 ### Features
- **4-level nesting** — Area → Project → Task → Sub-task - **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 - **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 - **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 - **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 - **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 - **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 - **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 │ ├── architecture.md # High-level architecture
│ └── tdd.md # Technical Design Document │ └── tdd.md # Technical Design Document
├── server/ ├── 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) │ ├── backlog.md # Master data file (created on first run)
│ ├── archive.md # Archived items (created on first archive)
│ ├── backups/ # Automatic timestamped backups │ ├── backups/ # Automatic timestamped backups
│ └── stats.jsonl # Append-only analytics log │ └── stats.jsonl # Append-only analytics log
├── web/ # V2 source code (React 18 + JSX) + build tooling ├── web/ # V2 source code (React 18 + JSX) + build tooling
+65
View File
@@ -23,6 +23,7 @@ class Config:
self.dir = directory.resolve() self.dir = directory.resolve()
self.port = port self.port = port
self.master = self.dir / "backlog.md" self.master = self.dir / "backlog.md"
self.archive = self.dir / "archive.md"
self.backups_dir = self.dir / "backups" self.backups_dir = self.dir / "backups"
self.stats_file = self.dir / "stats.jsonl" self.stats_file = self.dir / "stats.jsonl"
self.web_dir = web_dir.resolve() if web_dir else Path(__file__).parent.parent / "webapp" 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 ""} 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(): def rotate_backups():
files = sorted(CONFIG.backups_dir.glob("backlog_*.md"), key=lambda p: p.stat().st_mtime) files = sorted(CONFIG.backups_dir.glob("backlog_*.md"), key=lambda p: p.stat().st_mtime)
if not files: if not files:
@@ -285,6 +306,7 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/health": if path == "/api/health":
info = read_master() info = read_master()
archive_info = read_archive()
backups = list_backups() backups = list_backups()
self._json_response({ self._json_response({
"status": "ok", "status": "ok",
@@ -294,6 +316,9 @@ class Handler(BaseHTTPRequestHandler):
"backupCount": len(backups), "backupCount": len(backups),
"masterPath": str(CONFIG.master), "masterPath": str(CONFIG.master),
"backupsPath": str(CONFIG.backups_dir), "backupsPath": str(CONFIG.backups_dir),
"archiveExists": archive_info["exists"],
"archiveSize": archive_info["size"],
"archivePath": str(CONFIG.archive),
}) })
return return
@@ -308,6 +333,20 @@ class Handler(BaseHTTPRequestHandler):
self._json_response({"content": info["content"], "checksum": info["checksum"]}) self._json_response({"content": info["content"], "checksum": info["checksum"]})
return 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": if path == "/api/backups":
self._json_response({"backups": list_backups()}) self._json_response({"backups": list_backups()})
return return
@@ -342,6 +381,32 @@ class Handler(BaseHTTPRequestHandler):
self._json_response(result) self._json_response(result)
return 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": if path == "/api/export":
body = self._read_json_body() body = self._read_json_body()
fmt = body.get("format", "json") fmt = body.get("format", "json")
+162 -1
View File
@@ -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 [previewBackup, setPreviewBackup] = useState(null);
const counts = countByStatus(data.entries); const counts = countByStatus(data.entries);
@@ -650,6 +650,15 @@ function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onFo
)} )}
</section> </section>
{/* Archive manager */}
<ArchiveCard
entries={data.entries}
archiveData={archiveData}
onArchiveItems={onArchiveItems}
onRestoreItems={onRestoreItems}
onViewItem={onViewItem}
/>
{/* Recent history */} {/* Recent history */}
<section className="card span-3"> <section className="card span-3">
<div className="card-head"> <div className="card-head">
@@ -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 (
<section className="card span-3">
<div className="card-head">
<h3>Archive manager</h3>
<span className="card-sub">
{archivableItems.length} archivable · {archiveEntries.length} archived
</span>
</div>
{/* Archivable items */}
<div className="archive-section">
<div className="archive-toolbar">
<span className="archive-label">Archivable items</span>
<select value={filter} onChange={e => setFilter(e.target.value)} className="archive-filter">
<option value="done">Done only</option>
<option value="cancelled">Cancelled only</option>
<option value="all">Done + Cancelled</option>
</select>
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="archive-search"
/>
<button className="btn-secondary btn-sm" onClick={selectAll} disabled={filteredItems.length === 0}>
Select all
</button>
<button className="btn-primary btn-sm" onClick={handleArchive} disabled={selectedIds.length === 0}>
Archive {selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</button>
</div>
{filteredItems.length === 0 ? (
<div className="archive-empty">No archivable items found</div>
) : (
<div className="archive-list">
{filteredItems.slice(0, 20).map(it => (
<label key={it.id} className="archive-item">
<input
type="checkbox"
checked={selectedIds.includes(it.id)}
onChange={() => toggleSelect(it.id)}
/>
<StatusIcon status={it.status} size={14}/>
<span className="archive-item-title">{it.title}</span>
{it.children?.length > 0 && (
<span className="archive-item-children">+{it.children.length} children</span>
)}
</label>
))}
{filteredItems.length > 20 && (
<div className="archive-more">...and {filteredItems.length - 20} more</div>
)}
</div>
)}
</div>
{/* Archive browser */}
<div className="archive-section">
<div className="archive-toolbar">
<span className="archive-label">Archived items ({archiveEntries.length})</span>
<button className="btn-secondary btn-sm" onClick={() => setExpandedArchive(!expandedArchive)}>
{expandedArchive ? 'Collapse' : 'Expand'}
</button>
{expandedArchive && (
<input
type="text"
placeholder="Search archive..."
value={archiveSearchTerm}
onChange={e => setArchiveSearchTerm(e.target.value)}
className="archive-search"
/>
)}
</div>
{expandedArchive && (
filteredArchiveItems.length === 0 ? (
<div className="archive-empty">
{archiveEntries.length === 0 ? 'Archive is empty' : 'No matches found'}
</div>
) : (
<div className="archive-list">
{filteredArchiveItems.slice(0, 30).map(it => (
<div key={it.id} className="archive-item archived">
<StatusIcon status={it.status} size={14}/>
<span className="archive-item-title" onDoubleClick={() => onViewItem?.(it)}>{it.title}</span>
{it.archived && (
<span className="archive-item-date">archived {it.archived}</span>
)}
<button className="btn-secondary btn-xs" onClick={() => onRestoreItems([it.id])}>Restore</button>
</div>
))}
{filteredArchiveItems.length > 30 && (
<div className="archive-more">...and {filteredArchiveItems.length - 30} more</div>
)}
</div>
)
)}
</div>
</section>
);
}
window.AdminPage = AdminPage; window.AdminPage = AdminPage;
window.ArchiveCard = ArchiveCard;
+230 -1
View File
@@ -56,6 +56,7 @@ function App() {
const [itemDialog, setItemDialog] = useStateMain(null); const [itemDialog, setItemDialog] = useStateMain(null);
const [confirm, setConfirm] = useStateMain(null); const [confirm, setConfirm] = useStateMain(null);
const [needsConnect, setNeedsConnect] = useStateMain(false); 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 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 ?? {}) }); const [tweaks, setTweak] = useTweaks({ ...TWEAK_DEFAULTS, ...(loadLocalState()?.tweaks ?? {}) });
@@ -441,6 +442,19 @@ function App() {
const filteredCount = useMemoMain(() => countAll(filtered), [filtered]); const filteredCount = useMemoMain(() => countAll(filtered), [filtered]);
const totalCount = useMemoMain(() => countAll(data.entries), [data]); 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 ---- // ---- Keyboard shortcuts ----
useEffectMain(() => { useEffectMain(() => {
@@ -508,6 +522,142 @@ function App() {
triggerSave('Imported'); 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…' const saveLabel = saveState.status === 'saving' ? 'Saving…'
: saveState.status === 'error' ? 'Save failed' : saveState.status === 'error' ? 'Save failed'
: `Saved · ${fmtTimestamp(saveState.lastSaved)}`; : `Saved · ${fmtTimestamp(saveState.lastSaved)}`;
@@ -584,7 +734,14 @@ function App() {
<div> <div>
<div className="eyebrow">Backlog</div> <div className="eyebrow">Backlog</div>
<h1 className="content-title"> <h1 className="content-title">
{hasFilters ? <>{filteredCount} of {totalCount} items</> : <>{totalCount} items</>} {hasFilters ? (
<>
{filteredCount} of {totalCount} items
{archiveMatchCount > 0 && <> · {archiveMatchCount} in archive</>}
</>
) : (
<>{totalCount} items</>
)}
</h1> </h1>
</div> </div>
<div className="content-head-actions"> <div className="content-head-actions">
@@ -635,6 +792,15 @@ function App() {
manualOrder={tweaks.sort_mode === 'manual'} manualOrder={tweaks.sort_mode === 'manual'}
/> />
)} )}
{filters.text?.trim() && (
<ArchiveSearchResults
query={filters.text.trim()}
archiveEntries={archiveData.entries}
onRestore={handleRestoreItems}
onLoadArchive={loadArchive}
onView={(item) => setItemDialog({ mode: 'view', initial: item })}
/>
)}
</div> </div>
</section> </section>
</div> </div>
@@ -664,6 +830,10 @@ function App() {
showToast(`${name.slice(0, 28)}… — download not available in direct mode`); 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 (
<div className="archive-search-section">
<div className="archive-search-header">Archive — loading...</div>
</div>
);
}
if (matches.length === 0) return null;
return (
<div className="archive-search-section">
<div className="archive-search-header">Archive ({matches.length})</div>
<div className="archive-search-results">
{matches.slice(0, 10).map(item => (
<div key={item.id} className="archive-search-item">
<StatusIcon status={item.status} size={14}/>
<span className="archive-search-title" onDoubleClick={() => onView(item)}>{item.title}</span>
{item.archived && <span className="archive-search-date">{item.archived}</span>}
<button className="btn-secondary btn-xs" onClick={() => onRestore([item.id])}>Restore</button>
</div>
))}
{matches.length > 10 && (
<div className="archive-search-more">
...and {matches.length - 10} more (see Admin → Archive)
</div>
)}
</div>
</div>
);
}
window.App = App; window.App = App;
+60 -33
View File
@@ -305,8 +305,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
<form onSubmit={submit}> <form onSubmit={submit}>
<DialogHeader <DialogHeader
id="dlg-item-title" id="dlg-item-title"
eyebrow={mode === "edit" ? "Edit item" : (mode === "add-child" ? "Add sub-item" : "New item")} eyebrow={mode === "view" ? "Archived item" : mode === "edit" ? "Edit item" : (mode === "add-child" ? "Add sub-item" : "New item")}
title={mode === "edit" ? "Edit details" : "Create item"} title={mode === "view" ? "View details (read-only)" : mode === "edit" ? "Edit details" : "Create item"}
onClose={onClose} onClose={onClose}
/> />
@@ -320,17 +320,22 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to happen?" placeholder="What needs to happen?"
required required
readOnly={mode === "view"}
/> />
</div> </div>
<div className="field"> <div className="field">
<label className="field-label">Details <span className="field-hint">rich text, supports markdown</span></label> <label className="field-label">Details <span className="field-hint">{mode === "view" ? "read-only" : "rich text, supports markdown"}</span></label>
<RichTextEditor {mode === "view" ? (
value={body} <div className="rte-readonly" dangerouslySetInnerHTML={{ __html: MarkdownUtils.toHtml(body || '') }} />
onChange={setBody} ) : (
placeholder="Add notes, context, or details..." <RichTextEditor
collapsed={!body} value={body}
/> onChange={setBody}
placeholder="Add notes, context, or details..."
collapsed={!body}
/>
)}
</div> </div>
<div className="field-row"> <div className="field-row">
@@ -340,7 +345,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
{PRIORITIES.map(p => ( {PRIORITIES.map(p => (
<button type="button" key={p} <button type="button" key={p}
className={`seg-btn pri-${p} ${priority === p ? "active" : ""}`} className={`seg-btn pri-${p} ${priority === p ? "active" : ""}`}
onClick={() => setPriority(p)}> onClick={() => mode !== "view" && setPriority(p)}
disabled={mode === "view"}>
{p === "P0" && <span className="p0-dot"/>} {p === "P0" && <span className="p0-dot"/>}
{p} {p}
</button> </button>
@@ -349,7 +355,7 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
</div> </div>
<div className="field"> <div className="field">
<label className="field-label">Due</label> <label className="field-label">Due</label>
<input className="text-input" type="date" value={due} onChange={(e) => setDue(e.target.value)}/> <input className="text-input" type="date" value={due} onChange={(e) => setDue(e.target.value)} readOnly={mode === "view"}/>
</div> </div>
</div> </div>
@@ -359,7 +365,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
{STATUSES.map(s => ( {STATUSES.map(s => (
<button type="button" key={s.key} <button type="button" key={s.key}
className={`status-card ${status === s.key ? "active" : ""}`} className={`status-card ${status === s.key ? "active" : ""}`}
onClick={() => setStatus(s.key)}> onClick={() => mode !== "view" && setStatus(s.key)}
disabled={mode === "view"}>
<span className={`status-card-icon status-${s.key}`}> <span className={`status-card-icon status-${s.key}`}>
<StatusIcon status={s.key} size={16}/> <StatusIcon status={s.key} size={16}/>
</span> </span>
@@ -374,18 +381,18 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
<label className="field-label"> <label className="field-label">
Completion Completion
<span className="field-hint"> <span className="field-hint">
{status === "done" {mode === "view" ? "read-only" : status === "done"
? "locked at 100% while marked done" ? "locked at 100% while marked done"
: "rough estimate — set 100% to mark done"} : "rough estimate — set 100% to mark done"}
</span> </span>
</label> </label>
<div className="progress-row"> <div className="progress-row">
<div className={`seg progress-seg ${status === "done" ? "locked" : ""}`}> <div className={`seg progress-seg ${status === "done" || mode === "view" ? "locked" : ""}`}>
{PROGRESS_STEPS.map(p => ( {PROGRESS_STEPS.map(p => (
<button type="button" key={p} <button type="button" key={p}
className={`seg-btn ${progress === p ? "active" : ""}`} className={`seg-btn ${progress === p ? "active" : ""}`}
onClick={() => setProgress(p)} onClick={() => setProgress(p)}
disabled={status === "done"}> disabled={status === "done" || mode === "view"}>
{p}% {p}%
</button> </button>
))} ))}
@@ -400,32 +407,52 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
<label className="field-label">Block reason</label> <label className="field-label">Block reason</label>
<input className="text-input" value={reason} <input className="text-input" value={reason}
onChange={(e) => setReason(e.target.value)} onChange={(e) => setReason(e.target.value)}
placeholder="Waiting for…"/> placeholder="Waiting for…"
readOnly={mode === "view"}/>
</div> </div>
)} )}
<div className="field"> <div className="field">
<label className="field-label">Tags <span className="field-hint">type a name, press Enter or comma to add</span></label> <label className="field-label">Tags {mode !== "view" && <span className="field-hint">type a name, press Enter or comma to add</span>}</label>
<TagAutocompleteInput {mode === "view" ? (
tags={tags} <div className="tag-list-readonly">
setTags={setTags} {tags.length === 0 ? <span className="muted">No tags</span> : tags.map(t => (
tagInput={tagInput} <span key={t} className="tag">{t}</span>
setTagInput={setTagInput} ))}
addTag={addTag} </div>
removeTag={removeTag} ) : (
allTags={recentTags} <TagAutocompleteInput
/> tags={tags}
setTags={setTags}
tagInput={tagInput}
setTagInput={setTagInput}
addTag={addTag}
removeTag={removeTag}
allTags={recentTags}
/>
)}
</div> </div>
</div> </div>
<div className="dlg-foot"> <div className="dlg-foot">
<span className="dlg-hint">⌘↩ to save · Esc to cancel</span> {mode === "view" ? (
<div className="dlg-foot-actions"> <>
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button> <span className="dlg-hint">Read-only · archived {initial?.archived || ''}</span>
<button type="submit" className="btn-primary" disabled={!title.trim()}> <div className="dlg-foot-actions">
{mode === "edit" ? "Save changes" : "Create item"} <button type="button" className="btn-secondary" onClick={onClose}>Close</button>
</button> </div>
</div> </>
) : (
<>
<span className="dlg-hint">⌘↩ to save · Esc to cancel</span>
<div className="dlg-foot-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
<button type="submit" className="btn-primary" disabled={!title.trim()}>
{mode === "edit" ? "Save changes" : "Create item"}
</button>
</div>
</>
)}
</div> </div>
</form> </form>
</Dialog> </Dialog>
+61 -1
View File
@@ -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, { Object.assign(window, {
STATUSES, STATUS_BY_KEY, STATUS_ORDER, PRIORITIES, STATUSES, STATUS_BY_KEY, STATUS_ORDER, PRIORITIES,
parseDate, daysBetween, isOverdue, dueRelative, fmtTimestamp, fmtBytes, fmtRelative, fmtShortDate, parseDate, daysBetween, isOverdue, dueRelative, fmtTimestamp, fmtBytes, fmtRelative, fmtShortDate,
walkTree, findItem, allTags, countAll, countByStatus, filterTree, walkTree, findItem, allTags, countAll, countByStatus, filterTree,
Icon, StatusIcon, ColorStatusIcon, STATUS_COLORS, STATUS_EMOJI, StatusStyleContext, Icon, StatusIcon, ColorStatusIcon, STATUS_COLORS, STATUS_EMOJI, StatusStyleContext,
PROGRESS_STEPS, snapProgress, progressFor, shouldShowProgress, cycleProgress, ProgressGauge, PROGRESS_STEPS, snapProgress, progressFor, shouldShowProgress, cycleProgress, ProgressGauge,
MarkdownUtils, ClipboardUtils MarkdownUtils, ClipboardUtils,
getRestorePath, removeItems, insertAtPath
}); });
+102 -8
View File
@@ -103,16 +103,19 @@ const Parser = {
const raw = m[3].trim(); const raw = m[3].trim();
let title = raw, due = null, reason = null, tags = [], priority = 'P1', progress = 0; let title = raw, due = null, reason = null, tags = [], priority = 'P1', progress = 0;
let archived = null, restorePath = null;
const metaM = raw.match(/^(.*?)\s*\*\((.*)\)\*\s*$/); const metaM = raw.match(/^(.*?)\s*\*\((.*)\)\*\s*$/);
if (metaM) { if (metaM) {
title = metaM[1].trim(); title = metaM[1].trim();
for (const p of metaM[2].split(',').map(s => s.trim())) { for (const p of metaM[2].split(',').map(s => s.trim())) {
if (p.startsWith('due:')) due = p.slice(4).trim(); if (p.startsWith('due:')) due = p.slice(4).trim();
else if (p.startsWith('reason:')) reason = p.slice(7).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('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('priority:')) priority = p.slice(9).trim().toUpperCase();
else if (p.startsWith('progress:')) progress = parseInt(p.slice(9).trim(), 10) || 0; 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, collapsed: false,
children: [], children: [],
body: null, body: null,
archived: archived || null,
restorePath: restorePath || null,
}; };
stack[stack.length - 1].children.push(item); stack[stack.length - 1].children.push(item);
stack.push({ children: item.children, depth, level: item.level, 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.tags?.length) meta.push(`tags: ${it.tags.join(' ')}`);
if (it.priority && it.priority !== 'P1') meta.push(`priority: ${it.priority}`); if (it.priority && it.priority !== 'P1') meta.push(`priority: ${it.priority}`);
if ((it.progress ?? 0) > 0) meta.push(`progress: ${it.progress}`); 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 metaStr = meta.length ? ` *(${meta.join(', ')})*` : '';
const prefix = it.priority ? `[${it.priority}] ` : ''; const prefix = it.priority ? `[${it.priority}] ` : '';
lines.push(`${indent}- [${GLYPH[it.status] || ' '}] ${prefix}${it.title}${metaStr}`); lines.push(`${indent}- [${GLYPH[it.status] || ' '}] ${prefix}${it.title}${metaStr}`);
@@ -249,9 +256,38 @@ const ApiBackend = {
statsSize: 0, statsSize: 0,
masterPath: j.masterPath || '', masterPath: j.masterPath || '',
backupsPath: j.backupsPath || '', backupsPath: j.backupsPath || '',
archiveExists: j.archiveExists || false,
archiveSize: j.archiveSize || 0,
archivePath: j.archivePath || '',
}; };
} catch { return {}; } } 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) ---- // ---- Direct File Backend (File System Access API) ----
@@ -376,8 +412,31 @@ const DirectBackend = {
async getHealthInfo() { async getHealthInfo() {
try { try {
const f = await (await this.dirHandle.getFileHandle('backlog.md')).getFile(); const f = await (await this.dirHandle.getFileHandle('backlog.md')).getFile();
return { masterSize: f.size, statsSize: 0, dirName: this.dirHandle?.name ?? null }; let archiveExists = false, archiveSize = 0;
} catch { return { masterSize: 0, statsSize: 0, dirName: this.dirHandle?.name ?? null }; } 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() { async getHealthInfo() {
try { try {
const content = await this._get(this._CONTENT_KEY) || ''; const content = await this._get(this._CONTENT_KEY) || '';
const archiveContent = await this._get('archive') || '';
const estimate = await navigator.storage?.estimate?.() || {}; const estimate = await navigator.storage?.estimate?.() || {};
const persisted = await navigator.storage?.persisted?.() || false; const persisted = await navigator.storage?.persisted?.() || false;
return { return {
@@ -527,9 +587,40 @@ const BrowserStorageBackend = {
persisted, persisted,
quota: estimate.quota, quota: estimate.quota,
usage: estimate.usage, usage: estimate.usage,
archiveExists: !!archiveContent,
archiveSize: new TextEncoder().encode(archiveContent).length,
}; };
} catch { } 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 listBackups() { return this.backend?.listBackups() ?? []; },
async restoreBackup(name) { return this.backend?.restoreBackup(name) ?? { ok: false }; }, async restoreBackup(name) { return this.backend?.restoreBackup(name) ?? { ok: false }; },
async getHealthInfo() { return this.backend?.getHealthInfo?.() ?? {}; }, 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) ---- // ---- SyncPoller — detect external file changes (5s interval) ----
+96
View File
@@ -1304,3 +1304,99 @@ input { font: inherit; color: inherit; }
.field-row { grid-template-columns: 1fr; } .field-row { grid-template-columns: 1fr; }
.status-grid { grid-template-columns: repeat(2, 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; }
File diff suppressed because it is too large Load Diff