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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) ----
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
+756
-27
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user