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