Files
micronomicon/backend/graph.py
2026-03-31 17:21:33 +02:00

82 lines
2.1 KiB
Python

import os
import re
from pathlib import Path
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
# Matches markdown links: [text](slug) where slug has no protocol or path separators
_INTERNAL_LINK = re.compile(r"\[([^\]]+)\]\(([a-zA-Z0-9_-]+)\)")
class GraphNode(BaseModel):
id: str
published: bool
title: str | None = None
class GraphEdge(BaseModel):
source: str
target: str
class GraphData(BaseModel):
nodes: list[GraphNode]
edges: list[GraphEdge]
def _all_page_names() -> set[str]:
names: set[str] = set()
if PAGES_DIR.is_dir():
for f in PAGES_DIR.iterdir():
if f.suffix == ".mu" and f.is_file():
names.add(f.stem)
if SOURCES_DIR.is_dir():
for f in SOURCES_DIR.iterdir():
if f.suffix == ".md" and f.is_file():
names.add(f.stem)
return names
def _extract_title(markdown: str) -> str | None:
for line in markdown.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
return None
@router.get("/graph", response_model=GraphData)
async def get_graph():
all_names = _all_page_names()
nodes: list[GraphNode] = []
edges: list[GraphEdge] = []
for name in sorted(all_names):
md_path = SOURCES_DIR / f"{name}.md"
mu_path = PAGES_DIR / f"{name}.mu"
title = None
if md_path.is_file():
content = md_path.read_text(encoding="utf-8")
title = _extract_title(content)
# Parse internal links
for match in _INTERNAL_LINK.finditer(content):
target = match.group(2)
if target in all_names:
edges.append(GraphEdge(source=name, target=target))
nodes.append(GraphNode(
id=name,
published=mu_path.is_file(),
title=title,
))
return GraphData(nodes=nodes, edges=edges)