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

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.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) 

28 

29log = logging.getLogger(__name__) 

30 

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

32 CONCEPTS_SUBDIR, 

33 ENTITIES_SUBDIR, 

34 SUMMARIES_SUBDIR, 

35 SYNTHESIS_SUBDIR, 

36) 

37 

38 

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

40 return config.data_root / config.wiki_dir 

41 

42 

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

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

45 

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) 

50 

51 

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

61 

62 

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

64 """Count sources from frontmatter sources field.""" 

65 return _source_count_from_frontmatter(parse_frontmatter(text)) 

66 

67 

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 

76 

77 

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

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

80 

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) 

90 

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) 

102 

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 

108 

109 

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

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

112 

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 

130 

131 

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. 

138 

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) 

148 

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" 

152 

153 if not log_path.exists(): 

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

155 

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

157 f.write(entry) 

158 return log_path