"""The project's Logbook, kept by Harbor and shown on the board's Logbook view: headline statistics, the chart of the executable, the trend, milestones and the ship's log. (It replaced a separately published page on 2026-10-05.) One JSON file per collection in the project's logbook folder ([logbook] dir, default /logbook/): stats.json the latest statistics (functions, bytes, by_category, percentages, commit, updated_at) map.json the chart of the executable (low, high, cells: "game,engine,platform,crt,unsorted,done" per cell) history.json [{at, classified, decompiled, weighted, verified}] one point per statistics report milestones.json [{id, code, title, phase, state, order, items: [{text, done}]}] log.json [{id, at, kind, title, detail}] the ship's log, newest last Writes replace the file atomically. Nothing here talks to the network.""" import datetime import json import os import threading from pathlib import Path from . import projects from .util import HarborError KINDS = ("milestone", "tooling", "progress", "decision", "finding", "naming", "review", "setback", "note") _LOCK = threading.Lock() def folder(project): configured = (getattr(project, "logbook", None) or {}).get("dir") path = Path(configured) if configured else projects.harbor_home() / "logbook" / project.name path.mkdir(parents=True, exist_ok=True) return path def _read(project, name, default): path = folder(project) / name if not path.exists(): return default try: return json.loads(path.read_text(encoding="utf-8")) except ValueError: raise HarborError(f"{path} is not valid JSON") from None def _write(project, name, value): path = folder(project) / name tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(value, indent=1, ensure_ascii=False), encoding="utf-8") os.replace(tmp, path) def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def read_all(project, log_limit=80): log = _read(project, "log.json", []) return {"stats": _read(project, "stats.json", None), "map": _read(project, "map.json", None), "history": sorted(_read(project, "history.json", []), key=lambda p: p.get("at", "")), "milestones": sorted(_read(project, "milestones.json", []), key=lambda m: m.get("order", 0)), "log": sorted(log, key=lambda e: e.get("at", ""), reverse=True)[:log_limit], "log_total": len(log), "auto": _read(project, "auto.json", {})} def add_entry(project, title, detail, kind="note", at=None): title, detail = (title or "").strip(), (detail or "").strip() if not title or not detail: raise HarborError("a log entry needs a title and a detail") if kind not in KINDS: raise HarborError("kind must be one of " + ", ".join(KINDS)) with _LOCK: log = _read(project, "log.json", []) at = at or now() stem = at[:16].replace("-", "").replace(":", "").replace("T", "-") n = 1 + sum(1 for e in log if str(e.get("id", "")).startswith(stem)) entry = {"id": f"{stem}-{n}", "at": at, "kind": kind, "title": title, "detail": detail} log.append(entry) _write(project, "log.json", log) return entry def set_stats(project, stats, history_point=True): """Replace the statistics; also record a trend point (classified, decompiled, weighted, verified).""" if not isinstance(stats, dict) or "functions" not in stats: raise HarborError("statistics need at least 'functions'") stats = {**stats, "updated_at": stats.get("updated_at") or now()} with _LOCK: _write(project, "stats.json", stats) if history_point: history = _read(project, "history.json", []) cats = stats.get("by_category") or {} total = stats.get("functions") or 0 undone_game = max(0, (cats.get("game") or {}).get("functions", 0) - ((stats.get("by_status") or {}).get("decompiled") or {}).get("functions", 0)) handled = total - (cats.get("unsorted") or {}).get("functions", 0) - undone_game history.append({"at": stats["updated_at"], "classified": stats.get("classified_percent", 0), "done": round(100 * handled / total, 2) if total else 0, "decompiled": stats.get("game_bytes_decompiled_percent", 0), "weighted": stats.get("weighted_percent", 0), "verified": stats.get("game_bytes_verified_percent", 0)}) _write(project, "history.json", history) return stats def set_map(project, chart): if not isinstance(chart, dict) or "cells" not in chart: raise HarborError("the chart needs 'cells'") with _LOCK: _write(project, "map.json", chart) return {"cells": len(chart["cells"])} def upsert_milestone(project, milestone): if not isinstance(milestone, dict) or not milestone.get("id"): raise HarborError("a milestone needs an id") with _LOCK: milestones = [m for m in _read(project, "milestones.json", []) if m.get("id") != milestone["id"]] milestones.append(milestone) _write(project, "milestones.json", sorted(milestones, key=lambda m: m.get("order", 0))) return milestone def import_export(project, export_dir): """One-time import of the published page's database export (one JSON file per document).""" export = Path(export_dir) def docs(collection): return [json.loads(p.read_text(encoding="utf-8")) for p in sorted((export / collection).glob("*.json"))] with _LOCK: log = [{**d, "id": d.get("id") or p.stem} for d, p in zip(docs("log"), sorted((export / "log").glob("*.json")))] _write(project, "log.json", sorted(log, key=lambda e: e.get("at", ""))) _write(project, "history.json", sorted(docs("history"), key=lambda e: e.get("at", ""))) _write(project, "milestones.json", sorted(docs("milestones"), key=lambda m: m.get("order", 0))) if (export / "stats" / "current.json").exists(): _write(project, "stats.json", json.loads((export / "stats" / "current.json").read_text(encoding="utf-8"))) if (export / "stats" / "map.json").exists(): _write(project, "map.json", json.loads((export / "stats" / "map.json").read_text(encoding="utf-8"))) data = read_all(project, log_limit=0) return {"log": data["log_total"], "history": len(data["history"]), "milestones": len(data["milestones"]), "stats": bool(data["stats"]), "map": bool(data["map"])}