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.
### 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
- **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
+65
View File
@@ -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")
+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 counts = countByStatus(data.entries);
@@ -650,6 +650,15 @@ function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onFo
)}
</section>
{/* Archive manager */}
<ArchiveCard
entries={data.entries}
archiveData={archiveData}
onArchiveItems={onArchiveItems}
onRestoreItems={onRestoreItems}
onViewItem={onViewItem}
/>
{/* Recent history */}
<section className="card span-3">
<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.ArchiveCard = ArchiveCard;
+230 -1
View File
@@ -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() {
<div>
<div className="eyebrow">Backlog</div>
<h1 className="content-title">
{hasFilters ? <>{filteredCount} of {totalCount} items</> : <>{totalCount} items</>}
{hasFilters ? (
<>
{filteredCount} of {totalCount} items
{archiveMatchCount > 0 && <> · {archiveMatchCount} in archive</>}
</>
) : (
<>{totalCount} items</>
)}
</h1>
</div>
<div className="content-head-actions">
@@ -635,6 +792,15 @@ function App() {
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>
</section>
</div>
@@ -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 (
<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;
+60 -33
View File
@@ -305,8 +305,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
<form onSubmit={submit}>
<DialogHeader
id="dlg-item-title"
eyebrow={mode === "edit" ? "Edit item" : (mode === "add-child" ? "Add sub-item" : "New item")}
title={mode === "edit" ? "Edit details" : "Create item"}
eyebrow={mode === "view" ? "Archived item" : mode === "edit" ? "Edit item" : (mode === "add-child" ? "Add sub-item" : "New item")}
title={mode === "view" ? "View details (read-only)" : mode === "edit" ? "Edit details" : "Create item"}
onClose={onClose}
/>
@@ -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"}
/>
</div>
<div className="field">
<label className="field-label">Details <span className="field-hint">rich text, supports markdown</span></label>
<RichTextEditor
value={body}
onChange={setBody}
placeholder="Add notes, context, or details..."
collapsed={!body}
/>
<label className="field-label">Details <span className="field-hint">{mode === "view" ? "read-only" : "rich text, supports markdown"}</span></label>
{mode === "view" ? (
<div className="rte-readonly" dangerouslySetInnerHTML={{ __html: MarkdownUtils.toHtml(body || '') }} />
) : (
<RichTextEditor
value={body}
onChange={setBody}
placeholder="Add notes, context, or details..."
collapsed={!body}
/>
)}
</div>
<div className="field-row">
@@ -340,7 +345,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
{PRIORITIES.map(p => (
<button type="button" key={p}
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}
</button>
@@ -349,7 +355,7 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
</div>
<div className="field">
<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>
@@ -359,7 +365,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
{STATUSES.map(s => (
<button type="button" key={s.key}
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}`}>
<StatusIcon status={s.key} size={16}/>
</span>
@@ -374,18 +381,18 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
<label className="field-label">
Completion
<span className="field-hint">
{status === "done"
{mode === "view" ? "read-only" : status === "done"
? "locked at 100% while marked done"
: "rough estimate — set 100% to mark done"}
</span>
</label>
<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 => (
<button type="button" key={p}
className={`seg-btn ${progress === p ? "active" : ""}`}
onClick={() => setProgress(p)}
disabled={status === "done"}>
disabled={status === "done" || mode === "view"}>
{p}%
</button>
))}
@@ -400,32 +407,52 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
<label className="field-label">Block reason</label>
<input className="text-input" value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Waiting for…"/>
placeholder="Waiting for…"
readOnly={mode === "view"}/>
</div>
)}
<div className="field">
<label className="field-label">Tags <span className="field-hint">type a name, press Enter or comma to add</span></label>
<TagAutocompleteInput
tags={tags}
setTags={setTags}
tagInput={tagInput}
setTagInput={setTagInput}
addTag={addTag}
removeTag={removeTag}
allTags={recentTags}
/>
<label className="field-label">Tags {mode !== "view" && <span className="field-hint">type a name, press Enter or comma to add</span>}</label>
{mode === "view" ? (
<div className="tag-list-readonly">
{tags.length === 0 ? <span className="muted">No tags</span> : tags.map(t => (
<span key={t} className="tag">{t}</span>
))}
</div>
) : (
<TagAutocompleteInput
tags={tags}
setTags={setTags}
tagInput={tagInput}
setTagInput={setTagInput}
addTag={addTag}
removeTag={removeTag}
allTags={recentTags}
/>
)}
</div>
</div>
<div className="dlg-foot">
<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>
{mode === "view" ? (
<>
<span className="dlg-hint">Read-only · archived {initial?.archived || ''}</span>
<div className="dlg-foot-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Close</button>
</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>
</form>
</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, {
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
});
+102 -8
View File
@@ -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) ----
+96
View File
@@ -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; }
File diff suppressed because it is too large Load Diff