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

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 parse_frontmatter, 

24) 

25 

26log = logging.getLogger(__name__) 

27 

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

29 WikiSubdir.CONCEPTS, 

30 WikiSubdir.ENTITIES, 

31 WikiSubdir.SUMMARIES, 

32 WikiSubdir.SYNTHESIS, 

33) 

34 

35 

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

37 return config.data_root / config.wiki_dir 

38 

39 

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

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

42 

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) 

47 

48 

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

58 

59 

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

61 """Count sources from frontmatter sources field.""" 

62 return _source_count_from_frontmatter(parse_frontmatter(text)) 

63 

64 

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 

73 

74 

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

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

77 

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) 

87 

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) 

99 

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 

105 

106 

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

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

109 

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 

127 

128 

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. 

135 

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) 

145 

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" 

149 

150 if not log_path.exists(): 

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

152 

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

154 f.write(entry) 

155 return log_path