Files
KeelrLeek d5eb82261e Logbook progress map in the style of decomp.dev: "<project> is NN.NN% decompiled", a layered Code bar (exact solid, verified lighter, readable hatched, with per-segment tooltips), a Data bar (named globals of the data addresses the code touches), Previous / Next / Latest through the history reports, a Units dropdown and filter (text, <70%, >10kb), and a canvas treemap of units (original source files, else 64 KB blocks) that drills into functions
- GET /api/logbook/progress-map: one compact document, cached until an input changes (main's HEAD, the Logbook symbol table and statistics, match/emulator/library results, facts, names, source files, globals, deferral notes); ?have=<version> answers {"same": true}
- definitions follow the Logbook's progress.py (pc102.csv category game, status decompiled or later), so the headline equals game_bytes_decompiled_percent; engine, library and unsorted code are shown but not counted
- history points now record the commit and the game-code bytes
- the page lays out once per load, filter or size change, hit-tests hover on the computed rectangles, builds the 600-entry units list only when opened, and polls once a minute only while the Logbook is visible
2026-10-06 15:46:42 -05:00

140 lines
6.8 KiB
Python

"""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 <harbor home>/logbook/<project>):
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, done, decompiled, weighted, verified, commit, game_bytes}] one point per
statistics report (commit and game_bytes since 2026-10-06)
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),
**({"commit": stats["commit"]} if stats.get("commit") else {}),
**({"game_bytes": cats["game"]["bytes"]} if (cats.get("game") or {}).get("bytes") else {})})
_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"])}