|
| 1 | +"""Render the wiki's [[wikilink]] graph as a self-contained interactive HTML page.""" |
| 2 | +from __future__ import annotations |
| 3 | + |
| 4 | +import json |
| 5 | +from importlib import resources |
| 6 | +from pathlib import Path |
| 7 | + |
| 8 | +from openkb import frontmatter |
| 9 | +from openkb.lint import _extract_wikilinks, _normalize_target |
| 10 | +from openkb.schema import PAGE_CONTENT_DIRS |
| 11 | + |
| 12 | +# Singular display type per content dir; falls back to a derived name for any |
| 13 | +# dir not listed (so a new PAGE_CONTENT_DIRS entry never KeyErrors here). |
| 14 | +_DIR_TYPE = {"summaries": "Summary", "concepts": "Concept", "entities": "Entity"} |
| 15 | + |
| 16 | + |
| 17 | +def _type_for_dir(sub: str) -> str: |
| 18 | + return _DIR_TYPE.get(sub) or (sub[:-1] if sub.endswith("s") else sub).capitalize() or sub |
| 19 | + |
| 20 | + |
| 21 | +def build_graph(wiki_dir: Path) -> dict: |
| 22 | + """Collect nodes (pages), directed edges (wikilinks), and the set of types.""" |
| 23 | + nodes: dict[str, dict] = {} |
| 24 | + texts: dict[str, str] = {} # nid -> file text, read once and reused for edges |
| 25 | + for sub in PAGE_CONTENT_DIRS: |
| 26 | + d = wiki_dir / sub |
| 27 | + if not d.exists(): |
| 28 | + continue |
| 29 | + for p in sorted(d.glob("*.md")): |
| 30 | + nid = f"{sub}/{p.stem}" |
| 31 | + text = p.read_text(encoding="utf-8") |
| 32 | + texts[nid] = text |
| 33 | + fm = frontmatter.parse(text) |
| 34 | + t = fm.get("type") |
| 35 | + t = t.strip() if isinstance(t, str) and t.strip() else _type_for_dir(sub) |
| 36 | + desc = fm.get("description") |
| 37 | + desc = desc.strip() if isinstance(desc, str) else "" |
| 38 | + srcs = fm.get("sources") |
| 39 | + srcs = [str(s) for s in srcs] if isinstance(srcs, list) else [] |
| 40 | + ft = fm.get("full_text") # summaries record their origin document here, not in `sources` |
| 41 | + if isinstance(ft, str) and ft.strip(): |
| 42 | + srcs.insert(0, ft.strip()) |
| 43 | + nodes[nid] = {"id": nid, "label": p.stem, "type": t, |
| 44 | + "description": desc, "sources": srcs, "out": 0, "in": 0} |
| 45 | + |
| 46 | + norm = {_normalize_target(nid): nid for nid in nodes} |
| 47 | + edges: list[dict] = [] |
| 48 | + seen: set[tuple[str, str]] = set() |
| 49 | + for src, text in texts.items(): |
| 50 | + for raw in _extract_wikilinks(text): |
| 51 | + tgt = norm.get(_normalize_target(raw)) |
| 52 | + if not tgt or tgt == src or (src, tgt) in seen: |
| 53 | + continue |
| 54 | + seen.add((src, tgt)) |
| 55 | + edges.append({"source": src, "target": tgt}) |
| 56 | + nodes[src]["out"] += 1 |
| 57 | + nodes[tgt]["in"] += 1 |
| 58 | + |
| 59 | + types = sorted({n["type"] for n in nodes.values()}) |
| 60 | + return {"nodes": list(nodes.values()), "edges": edges, "types": types} |
| 61 | + |
| 62 | + |
| 63 | +def render_html(graph: dict) -> str: |
| 64 | + """Inject the graph as JSON into the self-contained HTML template.""" |
| 65 | + template = resources.files("openkb").joinpath("templates/graph.html").read_text(encoding="utf-8") |
| 66 | + data = json.dumps(graph, ensure_ascii=False).replace("</", "<\\/") # avoid </script> breakout |
| 67 | + return template.replace("__GRAPH_DATA__", data) |
0 commit comments