Files

799 lines
32 KiB
Python

#!/usr/bin/env python3
"""Personal Backlog - lightweight file server + REST API.
Zero dependencies (Python 3 stdlib only).
Usage: python3 server.py --port 8080 --dir ./data
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import sys
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qs, urlparse, unquote
class Config:
def __init__(self, directory: Path, port: int, web_dir: Path | None = None):
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.registry_file = self.dir / "projects.json"
self.web_dir = web_dir.resolve() if web_dir else Path(__file__).parent.parent / "webapp"
def ensure_dirs(self):
self.dir.mkdir(parents=True, exist_ok=True)
self.backups_dir.mkdir(exist_ok=True)
def load_registry(self) -> dict:
if not self.registry_file.exists():
return {}
try:
return json.loads(self.registry_file.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
print(f"[registry] projects.json unreadable, starting empty: {e}")
return {}
def save_registry(self, registry: dict):
tmp = self.registry_file.with_suffix(".json.tmp")
tmp.write_text(json.dumps(registry, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(self.registry_file)
CONFIG: Config = None # type: ignore[assignment]
# ---------------------------------------------------------------------------
# Markdown integrity helpers
# ---------------------------------------------------------------------------
def compute_checksum(entries_text: str, history_text: str) -> str:
payload = entries_text + "\n" + history_text
h = hashlib.sha256(payload.encode("utf-8")).hexdigest()
return f"sha256:{h}"
def parse_markdown_sections(text: str):
"""Split markdown into entries, history, and integrity sections.
Returns (entries_text, history_text, integrity_meta dict or None).
"""
# Find section markers
entries_start = text.find("<!-- SECTION: ENTRIES -->")
history_start = text.find("<!-- SECTION: HISTORY -->")
integrity_start = text.find("<!-- SECTION: INTEGRITY -->")
if entries_start == -1:
# No sections found — treat entire text as entries
return text, "", None
entries_text = text[entries_start:history_start if history_start != -1 else len(text)]
history_text = ""
integrity_meta = None
if history_start != -1:
end = integrity_start if integrity_start != -1 else len(text)
history_text = text[history_start:end]
if integrity_start != -1:
integrity_block = text[integrity_start:]
# Parse comment like <!-- saved: ... | checksum: ... | entries: ... | history: ... -->
m = re.search(r"saved:\s*([^|]+?)\s*\|\s*checksum:\s*([^|]+?)\s*\|\s*entries:\s*(\d+)\s*\|\s*history:\s*(\d+)", integrity_block)
if m:
integrity_meta = {
"saved": m.group(1).strip(),
"checksum": m.group(2).strip(),
"entries": int(m.group(3)),
"history": int(m.group(4)),
}
return entries_text, history_text, integrity_meta
def make_integrity_marker(entries_text: str, history_text: str) -> str:
checksum = compute_checksum(entries_text, history_text)
entry_count = len(re.findall(r"^[-*] \[", entries_text, re.MULTILINE))
history_count = len([l for l in history_text.splitlines() if l.startswith("|") and "Timestamp" not in l])
saved = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
return f"<!-- saved: {saved} | checksum: {checksum} | entries: {entry_count} | history: {history_count} -->"
def build_markdown(entries_text: str, history_text: str) -> str:
marker = make_integrity_marker(entries_text, history_text)
return f"# Backlog\n\n<!-- SECTION: ENTRIES -->\n\n{entries_text}\n\n<!-- SECTION: HISTORY -->\n\n{history_text}\n\n<!-- SECTION: INTEGRITY -->\n\n{marker}\n"
# ---------------------------------------------------------------------------
# File operations
# ---------------------------------------------------------------------------
# Path-parameterized cores (*_at) serve any project; the bare-name wrappers
# keep the legacy single-project API bound to the resolved default project.
BLANK_HISTORY = "| Timestamp | Item ID | Action | Details |\n|-----------|---------|--------|---------|"
class ProjectPaths(NamedTuple):
master: Path
archive: Path
backups_dir: Path
def read_master_at(master: Path) -> dict:
if not master.exists():
return {"content": build_markdown("", BLANK_HISTORY), "checksum": "", "size": 0}
text = master.read_text(encoding="utf-8")
_, _, meta = parse_markdown_sections(text)
checksum = meta["checksum"] if meta else ""
return {"content": text, "checksum": checksum, "size": len(text.encode("utf-8"))}
def write_master_at(content: str, paths: ProjectPaths) -> dict:
"""Atomic write with backup. Creates the project's parent/backups dirs on demand."""
paths.master.parent.mkdir(parents=True, exist_ok=True)
tmp = paths.master.with_suffix(".md.tmp")
tmp.write_text(content, encoding="utf-8")
parse_markdown_sections(content) # verify it parses
# Create backup (millis to avoid collisions)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S")
millis = datetime.now(timezone.utc).strftime("%f")[:3]
backup_name = f"backlog_{timestamp}-{millis}.md"
paths.backups_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(tmp, paths.backups_dir / backup_name)
rotate_backups_at(paths.backups_dir)
# Atomic rename
tmp.replace(paths.master)
# Stats stay global (single analytics log in the data dir)
append_stats({"t": datetime.now(timezone.utc).isoformat(), "e": "save_completed", "d": {"size": len(content.encode("utf-8"))}})
_, _, meta = parse_markdown_sections(content)
return {"ok": True, "checksum": meta["checksum"] if meta else "", "saved": meta["saved"] if meta else ""}
def read_archive_at(archive: Path) -> dict:
"""Read archive.md, return empty structure if not exists."""
if not archive.exists():
return {"content": "", "checksum": "", "size": 0, "exists": False}
text = archive.read_text(encoding="utf-8")
_, _, 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_at(content: str, paths: ProjectPaths) -> dict:
"""Write archive.md (append-only semantics enforced at API level)."""
paths.archive.parent.mkdir(parents=True, exist_ok=True)
tmp = paths.archive.with_suffix(".md.tmp")
tmp.write_text(content, encoding="utf-8")
parse_markdown_sections(content) # verify it parses
tmp.replace(paths.archive)
_, _, meta = parse_markdown_sections(content)
return {"ok": True, "checksum": meta["checksum"] if meta else "", "saved": meta["saved"] if meta else ""}
def rotate_backups_at(backups_dir: Path):
files = sorted(backups_dir.glob("backlog_*.md"), key=lambda p: p.stat().st_mtime)
if not files:
return
now = datetime.now(timezone.utc)
for f in files[:-1]: # never delete the most recent
age_days = (now.timestamp() - f.stat().st_mtime) / 86400
if age_days <= 7:
continue
day = f.name.split("_")[1] # YYYY-MM-DD
same_day = [x for x in files if x.name.startswith(f"backlog_{day}")]
if f != max(same_day, key=lambda p: p.stat().st_mtime):
f.unlink()
def list_backups_at(backups_dir: Path) -> list:
result = []
for f in sorted(backups_dir.glob("backlog_*.md"), key=lambda p: p.stat().st_mtime, reverse=True):
text = f.read_text(encoding="utf-8")
_, _, meta = parse_markdown_sections(text)
result.append({
"name": f.name,
"size": f.stat().st_size,
"timestamp": datetime.fromtimestamp(f.stat().st_mtime, tz=timezone.utc).isoformat(),
"valid": meta is not None,
})
return result
def restore_backup_at(name: str, paths: ProjectPaths) -> dict:
src = paths.backups_dir / name
if not src.exists():
return {"ok": False, "error": "Backup not found"}
text = src.read_text(encoding="utf-8")
parse_markdown_sections(text) # validate readable
paths.master.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, paths.master)
return {"ok": True}
def read_master() -> dict:
return read_master_at(resolve_default_project().master)
def write_master(content: str) -> dict:
return write_master_at(content, resolve_default_project())
def read_archive() -> dict:
return read_archive_at(resolve_default_project().archive)
def write_archive(content: str) -> dict:
return write_archive_at(content, resolve_default_project())
def rotate_backups():
rotate_backups_at(resolve_default_project().backups_dir)
def list_backups() -> list:
return list_backups_at(resolve_default_project().backups_dir)
def restore_backup(name: str) -> dict:
return restore_backup_at(name, resolve_default_project())
# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------
def append_stats(event: dict):
with open(CONFIG.stats_file, "a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
def read_stats(from_iso: str = None, to_iso: str = None) -> list:
if not CONFIG.stats_file.exists():
return []
events = []
with open(CONFIG.stats_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
t = ev.get("t", "")
if from_iso and t < from_iso:
continue
if to_iso and t > to_iso:
continue
events.append(ev)
except json.JSONDecodeError:
continue
return events
# ---------------------------------------------------------------------------
# Project registry
# ---------------------------------------------------------------------------
def safe_project_name(name: str) -> str:
"""Collapse to [a-zA-Z0-9_-]; empty result means the name is invalid."""
return re.sub(r"[^a-zA-Z0-9_-]", "-", name.strip())
def get_project_path(name: str) -> Path:
registry = CONFIG.load_registry()
if name not in registry:
raise KeyError(name)
return Path(registry[name]["path"]).resolve()
def project_paths(name: str) -> ProjectPaths:
"""Paths for a registered project; KeyError when the name is unknown."""
master = get_project_path(name)
return ProjectPaths(master=master, archive=master.parent / "archive.md", backups_dir=master.parent / "backups")
def resolve_default_project_name() -> str:
"""'default' when registered, else the first registry entry (insertion order)."""
registry = CONFIG.load_registry()
if "default" in registry:
return "default"
return next(iter(registry), "default")
def resolve_default_project() -> ProjectPaths:
"""Paths legacy routes operate on; CONFIG paths when the registry is empty/corrupt."""
try:
return project_paths(resolve_default_project_name())
except KeyError:
return ProjectPaths(master=CONFIG.master, archive=CONFIG.archive, backups_dir=CONFIG.backups_dir)
def list_projects() -> list:
"""Summary entries for GET /api/projects, in registry insertion order."""
projects = []
for name, entry in CONFIG.load_registry().items():
master = Path(entry["path"]).resolve()
missing = not master.exists()
checksum = ""
size = 0
if not missing:
size = master.stat().st_size
_, _, meta = parse_markdown_sections(master.read_text(encoding="utf-8"))
if meta:
checksum = meta["checksum"]
projects.append({"name": name, "path": str(master), "size": size, "checksum": checksum, "missing": missing})
return projects
def auto_register_default():
registry = CONFIG.load_registry()
if registry:
return
if not CONFIG.master.exists():
return
path = str(CONFIG.master.resolve())
registry["default"] = {"path": path, "name": "default"}
CONFIG.save_registry(registry)
print(f"[init] Registered default project: {path}")
def register_project(raw_path: str, raw_name: str | None = None) -> tuple:
"""Register a project for raw_path. Returns (payload, status).
Paths without an .md suffix get backlog.md appended; a missing file is
created from the blank template; the name defaults to the parent dir name.
"""
path = Path(raw_path).expanduser().resolve()
if not path.name.endswith(".md"):
path = path / "backlog.md"
name = safe_project_name(raw_name or path.parent.name)
if not name:
return {"ok": False, "error": "Invalid project name"}, 400
registry = CONFIG.load_registry()
if name in registry:
return {"ok": False, "error": f"Project '{name}' already registered"}, 409
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(build_markdown("", BLANK_HISTORY), encoding="utf-8")
registry[name] = {"path": str(path), "name": name}
CONFIG.save_registry(registry)
return {"ok": True, "name": name, "path": str(path)}, 200
# ---------------------------------------------------------------------------
# HTTP Handler
# ---------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
# Suppress default logging
pass
def _json_response(self, data: dict, status: int = 200):
body = json.dumps(data).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
self.wfile.write(body)
def _text_response(self, text: str, status: int = 200, content_type: str = "text/plain"):
body = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _file_response(self, path: Path, content_type: str = "application/octet-stream"):
if not path.exists():
self._json_response({"error": "Not found"}, 404)
return
data = path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
def _read_json_body(self) -> dict:
length = int(self.headers.get("Content-Length", 0))
if length == 0:
return {}
body = self.rfile.read(length).decode("utf-8")
return json.loads(body)
def _read_json_body_or_400(self):
"""Parsed JSON body, or None after responding 400 to malformed JSON."""
try:
return self._read_json_body()
except json.JSONDecodeError as e:
self._json_response({"error": f"Invalid JSON: {e}"}, 400)
return None
def _project_route_parts(self, path: str) -> tuple:
"""'/api/projects/<name>[/<action>...]' → (url-decoded name, action)."""
rest = path[len("/api/projects/"):]
name, _, action = rest.partition("/")
return unquote(name), action
def _resolve_project_or_404(self, name: str):
"""ProjectPaths for name, or None after responding 404 (empty/unknown)."""
try:
if name:
return project_paths(name)
except KeyError:
pass
self._json_response({"error": "Project not found"}, 404)
return None
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
qs = parse_qs(parsed.query)
# Serve static files from web_dir for / and any non-API path
if not path.startswith("/api/"):
# Map / → index.html, otherwise strip leading /
rel = "index-style-v2.html" if path == "/" else path.lstrip("/")
target = (CONFIG.web_dir / rel).resolve()
# Safety: stay inside web_dir
try:
target.relative_to(CONFIG.web_dir)
except ValueError:
self._json_response({"error": "Forbidden"}, 403)
return
if target.exists() and target.is_file():
ext = target.suffix.lower()
mime = {
".html": "text/html", ".css": "text/css",
".js": "application/javascript", ".jsx": "application/javascript",
".json": "application/json", ".md": "text/markdown",
".png": "image/png", ".svg": "image/svg+xml",
}.get(ext, "application/octet-stream")
self._file_response(target, mime)
else:
self._json_response({"error": "Not found"}, 404)
return
if path == "/api/projects":
self._json_response({"projects": list_projects()})
return
if path.startswith("/api/projects/"):
name, action = self._project_route_parts(path)
paths = self._resolve_project_or_404(name)
if paths is None:
return
if action == "backlog":
info = read_master_at(paths.master)
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"]})
return
if action == "archive":
info = read_archive_at(paths.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 action == "backups":
self._json_response({"backups": list_backups_at(paths.backups_dir)})
return
self._json_response({"error": "Not found"}, 404)
return
if path == "/api/health":
paths = resolve_default_project()
info = read_master_at(paths.master)
archive_info = read_archive_at(paths.archive)
backups = list_backups_at(paths.backups_dir)
self._json_response({
"status": "ok",
"lastSave": info.get("meta", {}).get("saved", "") if isinstance(info, dict) else "",
"lastBackup": backups[0]["timestamp"] if backups else "",
"masterSize": paths.master.stat().st_size if paths.master.exists() else 0,
"backupCount": len(backups),
"masterPath": str(paths.master),
"backupsPath": str(paths.backups_dir),
"archiveExists": archive_info["exists"],
"archiveSize": archive_info["size"],
"archivePath": str(paths.archive),
})
return
if path == "/api/backlog":
info = read_master()
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"]})
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
if path.startswith("/api/backups/"):
name = path[len("/api/backups/"):]
backup_path = resolve_default_project().backups_dir / name
self._file_response(backup_path, "text/markdown")
return
if path == "/api/stats":
events = read_stats(qs.get("from", [None])[0], qs.get("to", [None])[0])
self._json_response({"events": events})
return
self._json_response({"error": "Not found"}, 404)
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path
if path == "/api/projects":
body = self._read_json_body_or_400()
if body is None:
return
raw_path = body.get("path")
if not isinstance(raw_path, str) or not raw_path:
self._json_response({"ok": False, "error": "Missing 'path'"}, 400)
return
raw_name = body.get("name")
if raw_name is not None and not isinstance(raw_name, str):
self._json_response({"ok": False, "error": "Invalid project name"}, 400)
return
payload, status = register_project(raw_path, raw_name)
self._json_response(payload, status)
return
if path.startswith("/api/projects/"):
name, action = self._project_route_parts(path)
body = self._read_json_body_or_400()
if body is None:
return
paths = self._resolve_project_or_404(name)
if paths is None:
return
if action == "rename":
new_name = safe_project_name(body.get("newName", ""))
if not new_name:
self._json_response({"ok": False, "error": "Invalid project name"}, 400)
return
registry = CONFIG.load_registry()
if new_name in registry:
self._json_response(
{"ok": False, "error": f"Project '{new_name}' already registered"}, 409)
return
entry = registry.pop(name)
registry[new_name] = {"path": entry["path"], "name": new_name}
CONFIG.save_registry(registry)
self._json_response({"ok": True, "name": new_name})
return
if action == "backlog":
result = write_master_at(body.get("content", ""), paths)
self._json_response(result)
return
if action == "archive":
# Archive items: save both backlog and archive atomically
backlog_content = body.get("backlog", "")
archive_content = body.get("archive", "")
parse_markdown_sections(backlog_content)
parse_markdown_sections(archive_content)
# Write archive first (append-only), then backlog
archive_result = write_archive_at(archive_content, paths)
backlog_result = write_master_at(backlog_content, paths)
self._json_response({"ok": True, "backlog": backlog_result, "archive": archive_result})
return
if action == "archive/restore":
# Restore items from archive: save both backlog and archive atomically
backlog_content = body.get("backlog", "")
archive_content = body.get("archive", "")
parse_markdown_sections(backlog_content)
parse_markdown_sections(archive_content)
backlog_result = write_master_at(backlog_content, paths)
archive_result = write_archive_at(archive_content, paths)
self._json_response({"ok": True, "backlog": backlog_result, "archive": archive_result})
return
if action == "backups/restore":
result = restore_backup_at(body.get("name", ""), paths)
self._json_response(result)
return
self._json_response({"error": "Not found"}, 404)
return
if path == "/api/backlog":
body = self._read_json_body()
content = body.get("content", "")
result = write_master(content)
self._json_response(result)
return
if path == "/api/backups/restore":
body = self._read_json_body()
result = restore_backup(body.get("name", ""))
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")
info = read_master()
if fmt == "json":
entries, history, meta = parse_markdown_sections(info["content"])
self._json_response({
"format": "json",
"exported_at": datetime.now(timezone.utc).isoformat(),
"entries_raw": entries,
"history_raw": history,
"integrity": meta,
})
else:
self._text_response(info["content"], content_type="text/markdown")
return
if path == "/api/import":
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length).decode("utf-8")
try:
data = json.loads(raw)
content = data.get("content", "")
except json.JSONDecodeError:
content = raw # assume markdown
parse_markdown_sections(content) # validate readable
result = write_master(content)
self._json_response(result)
return
if path == "/api/stats":
body = self._read_json_body()
append_stats(body)
self._json_response({"ok": True})
return
self._json_response({"error": "Not found"}, 404)
def do_DELETE(self):
parsed = urlparse(self.path)
path = parsed.path
qs = parse_qs(parsed.query)
if path.startswith("/api/projects/"):
name, action = self._project_route_parts(path)
if action:
self._json_response({"error": "Not found"}, 404)
return
registry = CONFIG.load_registry()
if name not in registry:
self._json_response({"error": "Project not found"}, 404)
return
if len(registry) == 1:
self._json_response({"error": "At least one project must remain registered"}, 400)
return
master = Path(registry[name]["path"]).resolve()
del registry[name]
CONFIG.save_registry(registry)
result = {"ok": True, "name": name, "deleted": False}
if qs.get("delete_file", [""])[0] == "true" and master.exists():
try:
master.unlink()
result["deleted"] = True
except OSError as e:
result["warning"] = f"Could not delete file: {e}"
self._json_response(result)
return
self._json_response({"error": "Not found"}, 404)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def initialize_storage():
CONFIG.ensure_dirs()
if not CONFIG.master.exists():
CONFIG.master.write_text(build_markdown("", BLANK_HISTORY), encoding="utf-8")
print(f"[init] Created blank {CONFIG.master}")
auto_register_default()
def main():
parser = argparse.ArgumentParser(description="Personal Backlog Server")
parser.add_argument("--port", type=int, default=8080, help="Port to listen on")
parser.add_argument("--dir", type=str, default=str(Path(__file__).parent),
help="Directory for backlog.md, backups/, stats.jsonl (default: same dir as server.py)")
parser.add_argument("--web-dir", type=str, default=None, help="Directory to serve static files from (default: webapp/)")
args = parser.parse_args()
global CONFIG
web_dir = Path(args.web_dir) if args.web_dir else None
CONFIG = Config(Path(args.dir), args.port, web_dir)
initialize_storage()
server = HTTPServer(("0.0.0.0", args.port), Handler)
print(f"[server] Listening on http://0.0.0.0:{args.port}")
print(f"[server] Data dir: {CONFIG.dir}")
print(f"[server] Web dir: {CONFIG.web_dir}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[server] Shutting down.")
server.shutdown()
if __name__ == "__main__":
main()