Coverage for src/lilbee/wiki/index.py: 100%
77 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +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.core.config import Config, cfg
19from lilbee.wiki.shared import (
20 SUBDIR_TO_TYPE,
21 WIKI_TYPE_HEADINGS,
22 WikiSubdir,
23 atomic_write_text,
24 parse_frontmatter,
25)
27log = logging.getLogger(__name__)
29_INDEX_SECTION_ORDER: tuple[str, ...] = (
30 WikiSubdir.CONCEPTS,
31 WikiSubdir.ENTITIES,
32 WikiSubdir.SUMMARIES,
33 WikiSubdir.SYNTHESIS,
34)
37def _wiki_root(config: Config) -> Path:
38 return config.data_root / config.wiki_dir
41def parse_title(text: str) -> str:
42 """Extract title from YAML frontmatter ``title`` field or first H1 heading.
44 Assumes wiki/Obsidian markdown conventions. Returns the empty string
45 when neither is present.
46 """
47 return _title_from_frontmatter(parse_frontmatter(text), text)
50def _title_from_frontmatter(fm: dict[str, object], text: str) -> str:
51 """Return ``fm['title']`` when set, else the first H1 heading, else ``""``.
53 Uses ``get(...) is not None`` (not key-presence) so an explicit empty
54 ``title:`` falls back to the H1, matching ``browse._resolve_page_title``
55 instead of rendering the literal ``"None"``.
56 """
57 if (fm_title := fm.get("title")) is not None:
58 return str(fm_title)
59 for line in text.splitlines():
60 stripped = line.strip()
61 if stripped.startswith("# "):
62 return stripped.removeprefix("# ").strip()
63 return ""
66def parse_source_count(text: str) -> int:
67 """Count sources from frontmatter sources field."""
68 return _source_count_from_frontmatter(parse_frontmatter(text))
71def _source_count_from_frontmatter(fm: dict[str, object]) -> int:
72 """Count entries in the ``sources`` frontmatter field."""
73 sources = fm.get("sources")
74 if isinstance(sources, list): # yaml.safe_load may return str or list
75 return len(sources)
76 if isinstance(sources, str): # yaml.safe_load may return str or list
77 return len([s for s in sources.split(",") if s.strip()])
78 return 0
81def update_wiki_index(config: Config | None = None) -> Path:
82 """Regenerate wiki/index.md, grouping pages by type.
84 Sections appear in a fixed order (Concepts, Entities, Source
85 Summaries, Synthesis). Empty sections are omitted. Each entry keeps
86 the ``[title](subdir/slug.md) | type | N sources`` format so
87 readers and existing tooling stay stable.
88 """
89 if config is None:
90 config = cfg
91 root = _wiki_root(config)
92 root.mkdir(parents=True, exist_ok=True)
94 lines: list[str] = ["# Wiki Index", ""]
95 total = 0
96 for subdir in _INDEX_SECTION_ORDER:
97 section_lines = _render_section(root, subdir)
98 if not section_lines:
99 continue
100 lines.append(f"## {WIKI_TYPE_HEADINGS[SUBDIR_TO_TYPE[subdir]]}")
101 lines.append("")
102 lines.extend(section_lines)
103 lines.append("")
104 total += len(section_lines)
106 lines.append("") # trailing newline
107 index_path = root / "index.md"
108 atomic_write_text(index_path, "\n".join(lines))
109 log.info("Updated wiki index: %d entries", total)
110 return index_path
113def _render_section(root: Path, subdir: str) -> list[str]:
114 """Return formatted index lines for one subdir (empty if the subdir has no pages).
116 Parses each file's frontmatter once and reuses it for title and
117 source-count, halving file-read / YAML-parse work on a wiki with
118 hundreds of pages.
119 """
120 subdir_path = root / subdir
121 if not subdir_path.is_dir():
122 return []
123 page_type = SUBDIR_TO_TYPE[subdir]
124 lines: list[str] = []
125 for md_path in sorted(subdir_path.rglob("*.md")):
126 text = md_path.read_text(encoding="utf-8")
127 fm = parse_frontmatter(text)
128 title = _title_from_frontmatter(fm, text) or md_path.stem.replace("-", " ").title()
129 source_count = _source_count_from_frontmatter(fm)
130 rel = md_path.relative_to(root).with_suffix("").as_posix()
131 lines.append(f"- [{title}]({rel}.md) | {page_type} | {source_count} sources")
132 return lines
135def append_wiki_log(
136 action: str,
137 details: str,
138 config: Config | None = None,
139) -> Path:
140 """Append an entry to wiki/log.md.
142 Format: ``## [YYYY-MM-DD HH:MM] action | details``. The minute-level
143 timestamp means audit entries written within the same build each
144 have their own line and ``grep '## \\[2026-04-22'`` still works.
145 Returns the path to the log file.
146 """
147 if config is None:
148 config = cfg
149 root = _wiki_root(config)
150 root.mkdir(parents=True, exist_ok=True)
152 log_path = root / "log.md"
153 timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M")
154 entry = f"## [{timestamp}] {action} | {details}\n\n"
156 if not log_path.exists():
157 log_path.write_text("# Wiki Log\n\n", encoding="utf-8")
159 with log_path.open("a", encoding="utf-8") as f:
160 f.write(entry)
161 return log_path