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

1"""Wiki index and log management. 

2 

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 

6 

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""" 

11 

12from __future__ import annotations 

13 

14import logging 

15from datetime import UTC, datetime 

16from pathlib import Path 

17 

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) 

26 

27log = logging.getLogger(__name__) 

28 

29_INDEX_SECTION_ORDER: tuple[str, ...] = ( 

30 WikiSubdir.CONCEPTS, 

31 WikiSubdir.ENTITIES, 

32 WikiSubdir.SUMMARIES, 

33 WikiSubdir.SYNTHESIS, 

34) 

35 

36 

37def _wiki_root(config: Config) -> Path: 

38 return config.data_root / config.wiki_dir 

39 

40 

41def parse_title(text: str) -> str: 

42 """Extract title from YAML frontmatter ``title`` field or first H1 heading. 

43 

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) 

48 

49 

50def _title_from_frontmatter(fm: dict[str, object], text: str) -> str: 

51 """Return ``fm['title']`` when set, else the first H1 heading, else ``""``. 

52 

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 "" 

64 

65 

66def parse_source_count(text: str) -> int: 

67 """Count sources from frontmatter sources field.""" 

68 return _source_count_from_frontmatter(parse_frontmatter(text)) 

69 

70 

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 

79 

80 

81def update_wiki_index(config: Config | None = None) -> Path: 

82 """Regenerate wiki/index.md, grouping pages by type. 

83 

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) 

93 

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) 

105 

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 

111 

112 

113def _render_section(root: Path, subdir: str) -> list[str]: 

114 """Return formatted index lines for one subdir (empty if the subdir has no pages). 

115 

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 

133 

134 

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. 

141 

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) 

151 

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" 

155 

156 if not log_path.exists(): 

157 log_path.write_text("# Wiki Log\n\n", encoding="utf-8") 

158 

159 with log_path.open("a", encoding="utf-8") as f: 

160 f.write(entry) 

161 return log_path