Coverage for src / lilbee / wiki / index.py: 100%
77 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
1"""Wiki index and log management.
3Maintains two auto-generated files in the wiki directory:
4- index.md: table of contents listing all wiki pages, grouped by type
5- log.md: append-only chronological record of wiki events
7index.md is regenerated end-to-end on every call. log.md is append-only
8so the history survives rebuilds; each entry starts with
9``## [YYYY-MM-DD HH:MM]`` so simple grep patterns still work.
10"""
12from __future__ import annotations
14import logging
15from datetime import UTC, datetime
16from pathlib import Path
18from lilbee.config import Config, cfg
19from lilbee.wiki.shared import (
20 CONCEPTS_SUBDIR,
21 ENTITIES_SUBDIR,
22 SUBDIR_TO_TYPE,
23 SUMMARIES_SUBDIR,
24 SYNTHESIS_SUBDIR,
25 WIKI_TYPE_HEADINGS,
26 parse_frontmatter,
27)
29log = logging.getLogger(__name__)
31_INDEX_SECTION_ORDER: tuple[str, ...] = (
32 CONCEPTS_SUBDIR,
33 ENTITIES_SUBDIR,
34 SUMMARIES_SUBDIR,
35 SYNTHESIS_SUBDIR,
36)
39def _wiki_root(config: Config) -> Path:
40 return config.data_root / config.wiki_dir
43def parse_title(text: str) -> str:
44 """Extract title from YAML frontmatter ``title`` field or first H1 heading.
46 Assumes wiki/Obsidian markdown conventions. Returns the empty string
47 when neither is present.
48 """
49 return _title_from_frontmatter(parse_frontmatter(text), text)
52def _title_from_frontmatter(fm: dict[str, object], text: str) -> str:
53 """Return ``fm['title']`` when present, else the first H1 heading, else ``""``."""
54 if "title" in fm:
55 return str(fm["title"])
56 for line in text.splitlines():
57 stripped = line.strip()
58 if stripped.startswith("# "):
59 return stripped.removeprefix("# ").strip()
60 return ""
63def parse_source_count(text: str) -> int:
64 """Count sources from frontmatter sources field."""
65 return _source_count_from_frontmatter(parse_frontmatter(text))
68def _source_count_from_frontmatter(fm: dict[str, object]) -> int:
69 """Count entries in the ``sources`` frontmatter field."""
70 sources = fm.get("sources")
71 if isinstance(sources, list): # yaml.safe_load may return str or list
72 return len(sources)
73 if isinstance(sources, str): # yaml.safe_load may return str or list
74 return len([s for s in sources.split(",") if s.strip()])
75 return 0
78def update_wiki_index(config: Config | None = None) -> Path:
79 """Regenerate wiki/index.md, grouping pages by type.
81 Sections appear in a fixed order (Concepts, Entities, Source
82 Summaries, Synthesis). Empty sections are omitted. Each entry keeps
83 the ``[title](subdir/slug.md) | type | N sources`` format so
84 readers and existing tooling stay stable.
85 """
86 if config is None:
87 config = cfg
88 root = _wiki_root(config)
89 root.mkdir(parents=True, exist_ok=True)
91 lines: list[str] = ["# Wiki Index", ""]
92 total = 0
93 for subdir in _INDEX_SECTION_ORDER:
94 section_lines = _render_section(root, subdir)
95 if not section_lines:
96 continue
97 lines.append(f"## {WIKI_TYPE_HEADINGS[SUBDIR_TO_TYPE[subdir]]}")
98 lines.append("")
99 lines.extend(section_lines)
100 lines.append("")
101 total += len(section_lines)
103 lines.append("") # trailing newline
104 index_path = root / "index.md"
105 index_path.write_text("\n".join(lines), encoding="utf-8")
106 log.info("Updated wiki index: %d entries", total)
107 return index_path
110def _render_section(root: Path, subdir: str) -> list[str]:
111 """Return formatted index lines for one subdir (empty if the subdir has no pages).
113 Parses each file's frontmatter once and reuses it for title and
114 source-count, halving file-read / YAML-parse work on a wiki with
115 hundreds of pages.
116 """
117 subdir_path = root / subdir
118 if not subdir_path.is_dir():
119 return []
120 page_type = SUBDIR_TO_TYPE[subdir]
121 lines: list[str] = []
122 for md_path in sorted(subdir_path.rglob("*.md")):
123 text = md_path.read_text(encoding="utf-8")
124 fm = parse_frontmatter(text)
125 title = _title_from_frontmatter(fm, text) or md_path.stem.replace("-", " ").title()
126 source_count = _source_count_from_frontmatter(fm)
127 rel = md_path.relative_to(root).with_suffix("").as_posix()
128 lines.append(f"- [{title}]({rel}.md) | {page_type} | {source_count} sources")
129 return lines
132def append_wiki_log(
133 action: str,
134 details: str,
135 config: Config | None = None,
136) -> Path:
137 """Append an entry to wiki/log.md.
139 Format: ``## [YYYY-MM-DD HH:MM] action | details``. The minute-level
140 timestamp means audit entries written within the same build each
141 have their own line and ``grep '## \\[2026-04-22'`` still works.
142 Returns the path to the log file.
143 """
144 if config is None:
145 config = cfg
146 root = _wiki_root(config)
147 root.mkdir(parents=True, exist_ok=True)
149 log_path = root / "log.md"
150 timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M")
151 entry = f"## [{timestamp}] {action} | {details}\n\n"
153 if not log_path.exists():
154 log_path.write_text("# Wiki Log\n\n", encoding="utf-8")
156 with log_path.open("a", encoding="utf-8") as f:
157 f.write(entry)
158 return log_path