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
+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")