Coverage for src/lilbee/wiki/rerender.py: 100%

124 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Render the current path a wiki page or draft shows for each source it names by id.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import logging 

7from typing import TYPE_CHECKING, Any 

8 

9import yaml 

10 

11from lilbee.wiki.citations import fence_flags, format_source_ref 

12from lilbee.wiki.grammar import FOOTNOTE_RE 

13from lilbee.wiki.shared import WIKI_BUILD_LOCK, frontmatter_span, rewrite_keeping_time 

14 

15if TYPE_CHECKING: 

16 from collections.abc import Iterable, Mapping 

17 from pathlib import Path 

18 

19 from lilbee.core.config import Config 

20 from lilbee.data.store import CitationRecord, Store 

21 

22log = logging.getLogger(__name__) 

23 

24_SOURCES_PREFIX = "sources: " 

25_PROVENANCE_LINE = "provenance:" 

26# What follows the path in a footnote reference that names more than its path. 

27_AFTER_PATH = "," 

28 

29 

30def _shown_before(ref: str, record: CitationRecord) -> str | None: 

31 """The path footnote text *ref* shows when it is *record* under another path, else None.""" 

32 path = record["source_filename"] 

33 suffix = format_source_ref(record)[len(path) :] 

34 before = ref[: len(ref) - len(suffix)] 

35 if not ref.endswith(suffix) or not before or before == path: 

36 return None 

37 return before 

38 

39 

40def _rendered_footnotes( 

41 lines: list[str], records: Mapping[str, CitationRecord] 

42) -> tuple[list[str], dict[str, str]]: 

43 """*lines* with each footnote that shows a stale path rendered again; each old path to the new. 

44 

45 Two footnotes of one source that render alike are written once. 

46 """ 

47 renamed: dict[str, str] = {} 

48 out: list[str] = [] 

49 for line, fenced in zip(lines, fence_flags(lines), strict=True): 

50 match = None if fenced else FOOTNOTE_RE.match(line) 

51 record = None if match is None else records.get(match.group(1)) 

52 before = None if match is None or record is None else _shown_before(match.group(2), record) 

53 if match is None or record is None or before is None: 

54 out.append(line) 

55 continue 

56 renamed[before] = record["source_filename"] 

57 rendered = f"[^{match.group(1)}]: {format_source_ref(record)}" 

58 if rendered not in out: 

59 out.append(rendered) 

60 return out, renamed 

61 

62 

63def _sources_line(line: str, renamed: Mapping[str, str]) -> str: 

64 """A ``sources:`` frontmatter line with each renamed path replaced in place.""" 

65 if not line.startswith(_SOURCES_PREFIX): 

66 return line 

67 try: 

68 names = json.loads(line[len(_SOURCES_PREFIX) :]) 

69 except ValueError: 

70 return line 

71 # Frontmatter is untyped: a hand-edited page can hold anything here. 

72 if not isinstance(names, list) or not all(isinstance(name, str) for name in names): 

73 return line 

74 if not any(name in renamed for name in names): 

75 return line 

76 shown = dict.fromkeys(renamed.get(name, name) for name in names) 

77 return _SOURCES_PREFIX + json.dumps(list(shown)) 

78 

79 

80def _provenance(block: list[str], renamed: Mapping[str, str]) -> list[str]: 

81 """The lines of a ``provenance:`` block with each renamed chunk source replaced.""" 

82 try: 

83 parsed: Any = yaml.safe_load("\n".join(block)) 

84 chunks = parsed["provenance"]["chunks"] 

85 if not any(chunk["source"] in renamed for chunk in chunks): 

86 return block 

87 for chunk in chunks: 

88 chunk["source"] = renamed.get(chunk["source"], chunk["source"]) 

89 except (yaml.YAMLError, KeyError, TypeError, AttributeError): 

90 return block # a hand-edited block is left as it is 

91 # Two sources that now show one path name each of its chunks once. 

92 parsed["provenance"]["chunks"] = [ 

93 chunk for index, chunk in enumerate(chunks) if chunk not in chunks[:index] 

94 ] 

95 return yaml.safe_dump(parsed, sort_keys=False).rstrip("\n").split("\n") 

96 

97 

98def _frontmatter(lines: list[str], renamed: Mapping[str, str]) -> list[str]: 

99 """Frontmatter *lines* with the renamed paths of the sources list and the provenance block.""" 

100 out: list[str] = [] 

101 index = 0 

102 while index < len(lines): 

103 if lines[index] != _PROVENANCE_LINE: 

104 out.append(_sources_line(lines[index], renamed)) 

105 index += 1 

106 continue 

107 end = index + 1 

108 while end < len(lines) and lines[end].startswith((" ", "-")): 

109 end += 1 

110 out.extend(_provenance(lines[index:end], renamed)) 

111 index = end 

112 return out 

113 

114 

115def _with_frontmatter(lines: list[str], renamed: Mapping[str, str]) -> str: 

116 """Page *lines* with the renamed paths of the frontmatter replaced, joined.""" 

117 span = frontmatter_span(lines) 

118 if span is not None: 

119 start, end = span 

120 lines[start + 1 : end] = _frontmatter(lines[start + 1 : end], renamed) 

121 return "\n".join(lines) 

122 

123 

124def rerendered_page(text: str, records: Iterable[CitationRecord]) -> str: 

125 """Page *text* showing the current path of each source its citation *records* name.""" 

126 by_key = {record["citation_key"]: record for record in records} 

127 lines, renamed = _rendered_footnotes(text.split("\n"), by_key) 

128 if not renamed: 

129 return text 

130 return _with_frontmatter(lines, renamed) 

131 

132 

133def _renamed_footnote(line: str, renamed: Mapping[str, str]) -> str: 

134 """A footnote whose reference starts with a path of *renamed*, showing its new path.""" 

135 match = FOOTNOTE_RE.match(line) 

136 if match is None: 

137 return line 

138 ref = match.group(2) 

139 for before in sorted(renamed, key=len, reverse=True): 

140 if ref == before or ref.startswith(f"{before}{_AFTER_PATH}"): 

141 return line[: match.start(2)] + renamed[before] + ref[len(before) :] 

142 return line 

143 

144 

145def renamed_page(text: str, renamed: Mapping[str, str]) -> str: 

146 """Page *text* showing each path of *renamed* as its new path in footnotes and frontmatter.""" 

147 if not renamed: 

148 return text 

149 lines = text.split("\n") 

150 shown = [ 

151 line if fenced else _renamed_footnote(line, renamed) 

152 for line, fenced in zip(lines, fence_flags(lines), strict=True) 

153 ] 

154 return _with_frontmatter(shown, renamed) 

155 

156 

157def _rerender(store: Store, page: Path, wiki_source: str) -> Path | None: 

158 """Write *page* again when a source it cites shows another path; the page, or None.""" 

159 try: 

160 text = page.read_text(encoding="utf-8") 

161 except (OSError, UnicodeDecodeError) as exc: 

162 log.warning("Left %s as it is, it cannot be read: %s", page, exc) 

163 return None 

164 rendered = rerendered_page(text, store.get_citations_for_wiki(wiki_source)) 

165 if rendered == text: 

166 return None 

167 # The page keeps its time: the stale check of a pending draft compares the two. 

168 rewrite_keeping_time(page, rendered) 

169 return page 

170 

171 

172def rerender_pages(store: Store, config: Config, wiki_sources: Iterable[str]) -> list[Path]: 

173 """Render again the citation block and frontmatter of *wiki_sources*; the pages written.""" 

174 with WIKI_BUILD_LOCK: 

175 written = [ 

176 _rerender(store, config.data_root / wiki_source, wiki_source) 

177 for wiki_source in sorted(wiki_sources) 

178 ] 

179 return [page for page in written if page is not None] 

180 

181 

182def _shows_other_paths(page: Path, records: Iterable[CitationRecord]) -> bool: 

183 """Whether *page* shows a path other than the one its citation *records* name now.""" 

184 try: 

185 text = page.read_text(encoding="utf-8") 

186 except (OSError, UnicodeDecodeError): 

187 return False # a page that is gone or cannot be read is left as it is 

188 return rerendered_page(text, records) != text 

189 

190 

191def rerender_stale_pages( 

192 store: Store, config: Config, citations: Mapping[str, Iterable[CitationRecord]] 

193) -> list[Path]: 

194 """Render again each page of *citations* that shows a path its cited source no longer has.""" 

195 stale = [ 

196 wiki_source 

197 for wiki_source, records in citations.items() 

198 if _shows_other_paths(config.data_root / wiki_source, records) 

199 ] 

200 return rerender_pages(store, config, stale) if stale else []