Coverage for src/lilbee/cli/tui/screens/wiki.py: 100%
360 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""Wiki screen: browse wiki pages as a navigable tree with markdown preview."""
3from __future__ import annotations
5import logging
6from pathlib import Path
7from typing import TYPE_CHECKING, ClassVar, cast
9if TYPE_CHECKING:
10 from lilbee.cli.tui.app import LilbeeApp
11 from lilbee.cli.tui.widgets.task_bar_controller import ProgressReporter
12 from lilbee.data.store import Store
13 from lilbee.runtime.progress import DetailedProgressCallback, ProgressEvent
14 from lilbee.wiki.browse import WikiPageInfo
16from rich.text import Text
17from textual import on
18from textual.app import ComposeResult
19from textual.binding import Binding, BindingType
20from textual.containers import Horizontal, Vertical, VerticalScroll
21from textual.content import Content
22from textual.screen import Screen
23from textual.timer import Timer
24from textual.widgets import Input, Markdown, Static, Tree
25from textual.widgets.tree import TreeNode
27from lilbee.app.services import get_services
28from lilbee.cli.tui import messages as msg
29from lilbee.cli.tui.browse_bindings import BROWSE_LIST_BINDINGS, browse_back_bindings
30from lilbee.cli.tui.task_queue import TaskType
31from lilbee.cli.tui.thread_safe import call_from_thread
32from lilbee.cli.tui.widgets.task_bar import TaskBar
33from lilbee.core.config import cfg
34from lilbee.core.text import strip_control_characters
35from lilbee.runtime.progress import EventType, WikiPageEvent, WikiPhaseEvent
36from lilbee.wiki.stubs import WikiStub, load_stub_index, shown_sources, ungenerated_stubs
38log = logging.getLogger(__name__)
40# Tree node data carries the full wiki-page slug when present. Group folders
41# (page-type headings, per-source branches, inner-section branches) use None.
42_INDEX_STEM = "index"
43# Wiki slugs of the form ``<subdir>/<name>`` carry a meaningful page type;
44# bare slugs (no slash) do not.
45_SLUG_WITH_TYPE_MIN_PARTS = 2
48def _wiki_root() -> Path:
49 """Resolve the wiki root directory from config."""
50 return cfg.data_root / cfg.wiki_dir
53def _root_shortcuts() -> list[tuple[str, str]]:
54 """The auto-generated root pages that exist, as ``(slug, label)`` pairs.
56 Returned rather than mounted so the caller filters them alongside the pages.
57 """
58 return [
59 (slug, label)
60 for slug, label in (("index", msg.WIKI_INDEX_LABEL), ("log", msg.WIKI_LOG_LABEL))
61 if (_wiki_root() / f"{slug}.md").is_file()
62 ]
65def _safe_float(value: object) -> float | None:
66 """Coerce an untyped frontmatter value to float, or None if not numeric."""
67 try:
68 return float(cast("float", value))
69 except (TypeError, ValueError):
70 return None
73def _safe_int(value: object, default: int = 0) -> int:
74 """Coerce an untyped frontmatter value to int, or *default* if not numeric."""
75 try:
76 return int(cast("float", value))
77 except (TypeError, ValueError):
78 return default
81def _format_page_header(
82 title: str,
83 page_type: str,
84 source_count: int,
85 created_at: str,
86 faithfulness: float | None,
87) -> Content:
88 """Build the header line for the content pane, with the title as literal text."""
89 parts: list[tuple[str, str]] = [
90 (strip_control_characters(title), "bold"),
91 (f" {page_type}", "dim"),
92 ]
93 if source_count > 0:
94 parts.append((f" {source_count} sources", "dim"))
95 if created_at:
96 parts.append((f" {created_at}", "dim"))
97 if faithfulness is not None:
98 parts.append((f" faithfulness {int(faithfulness * 100)}%", "dim"))
99 return Content.assemble(*parts)
102def _short_label(slug_part: str) -> str:
103 """Render a slug component as a human-friendly tree label."""
104 return slug_part.replace("-", " ").replace("_", " ").strip()
107def _breadcrumb_for_slug(slug: str, title: str) -> Content:
108 """Build the breadcrumb chapter > section > page, with every part as literal text."""
109 parts = slug.split("/")
110 if len(parts) <= 1:
111 return Content("")
112 display_parts = [Content(_short_label(p)) for p in parts[:-1]]
113 display_parts.append(Content(strip_control_characters(title)))
114 return Content.assemble(" ", Content.styled(">", "dim"), " ").join(display_parts)
117class WikiScreen(Screen[None]):
118 """Wiki page browser with a tree sidebar and markdown content viewer."""
120 app: LilbeeApp # type: ignore[assignment]
122 CSS_PATH = "wiki.tcss"
123 AUTO_FOCUS = "#wiki-page-list"
124 HELP = (
125 "Browse wiki pages. h/l collapse/expand, j/k navigate, Enter opens a page, "
126 "/ searches, b generates pages from your documents (GPU-heavy)."
127 )
129 BINDINGS: ClassVar[list[BindingType]] = [
130 *browse_back_bindings(escape_action="dismiss_or_back"),
131 *BROWSE_LIST_BINDINGS,
132 Binding("slash", "focus_search", "Search", show=True),
133 Binding("D", "open_drafts", "Drafts", show=False),
134 Binding("b", "wikify", "Wikify", show=True),
135 Binding("W", "wipe", "Wipe", show=False),
136 Binding("h", "cursor_left", "Collapse", show=False),
137 Binding("l", "cursor_right", "Expand", show=False),
138 ]
140 _SEARCH_FILTER_DEBOUNCE_SECONDS = 0.12
142 def __init__(self) -> None:
143 super().__init__()
144 self._page_slugs: list[str] = []
145 self._pages: list[WikiPageInfo] = []
146 self._stubs: dict[str, WikiStub] = {}
147 self._load_error: str | None = None
148 self._search_filter_timer: Timer | None = None
149 # Filter the last paint ran under. Only a change to it drops the scroll
150 # offset; a plain reload keeps the reader's place.
151 self._painted_filter: str = ""
153 def compose(self) -> ComposeResult:
154 from textual.widgets import Footer
156 from lilbee.cli.tui.widgets.bottom_bars import BottomBars
157 from lilbee.cli.tui.widgets.status_bar import ViewTabs
158 from lilbee.cli.tui.widgets.top_bars import TopBars
160 with TopBars():
161 yield ViewTabs()
162 tree: Tree[str | None] = Tree("Wiki", id="wiki-page-list")
163 tree.show_root = False
164 yield Horizontal(
165 Vertical(
166 Input(
167 placeholder=msg.WIKI_SEARCH_PLACEHOLDER,
168 id="wiki-search",
169 ),
170 tree,
171 id="wiki-sidebar",
172 ),
173 Vertical(
174 Static("", id="wiki-breadcrumb"),
175 Static("", id="wiki-page-header"),
176 VerticalScroll(
177 Markdown("", id="wiki-content"),
178 id="wiki-content-scroll",
179 ),
180 id="wiki-main",
181 ),
182 id="wiki-layout",
183 )
184 with BottomBars():
185 yield TaskBar()
186 yield Footer()
188 def on_screen_resume(self) -> None:
189 """Re-scan on every activation so out-of-band builds (`lilbee wiki build`
190 from a sibling shell), incremental updates, and builds that finished while
191 this screen was out of view land without a TUI restart.
193 Resume fires on the initial push and on each switch back; Show fires only
194 once per screen, so it cannot carry this.
195 """
196 self.reload()
198 def reload(self) -> None:
199 """Re-walk the wiki from disk, then repaint under the live search filter.
201 Public entry point for external callers (the task bar refreshes an
202 open wiki screen after a sync or a wikify run). The walk parses every
203 page's frontmatter, so it runs here and not on every filter keystroke.
204 """
205 from lilbee.wiki.browse import list_pages
207 self._pages = []
208 self._stubs = {}
209 self._load_error = None
210 if cfg.wiki:
211 try:
212 self._pages = list_pages(_wiki_root())
213 self._stubs = {
214 stub.wiki_slug: stub
215 for stub in ungenerated_stubs(load_stub_index(), _wiki_root())
216 }
217 except Exception as exc:
218 log.warning("Failed to list wiki pages", exc_info=True)
219 self._load_error = msg.WIKI_LOAD_FAILED.format(error=exc)
220 self._show_load_failure(self._load_error)
221 return
222 self._load_pages(filter_text=self.query_one("#wiki-search", Input).value.strip())
224 def _show_load_failure(self, detail: str) -> None:
225 """Render the listing failure in both panes."""
226 tree = self._empty_tree()
227 tree.root.add_leaf(msg.WIKI_LOAD_FAILED_LEAF)
228 self._show_detail(detail)
230 def _empty_tree(self) -> Tree[str | None]:
231 """Clear the sidebar tree and the slug list it indexes."""
232 tree = self.query_one("#wiki-page-list", Tree)
233 tree.reset("Wiki")
234 self._page_slugs = []
235 return tree
237 def _load_pages(self, filter_text: str = "") -> None:
238 """Populate the sidebar tree from the cached page list, optionally filtered."""
239 if self._load_error is not None:
240 # The cache is empty because listing failed, not because the wiki is.
241 self._show_load_failure(self._load_error)
242 return
243 tree = self._empty_tree()
244 filter_changed = filter_text != self._painted_filter
245 self._painted_filter = filter_text
246 if filter_changed:
247 # The offset belongs to the previous result set. Keeping it parks
248 # the sidebar past the end of a shorter one (Textual clamps to the
249 # new max), so the matches render above the visible area.
250 tree.scroll_to(y=0, animate=False)
251 if not self._pages and not self._stubs:
252 tree.root.add_leaf(msg.wiki_empty_state_leaf())
253 self._show_placeholder()
254 return
256 needle = filter_text.lower()
257 pages = [p for p in self._pages if needle in p.title.lower()]
258 stubs = [s for s in self._stubs.values() if needle in s.label.lower()]
259 shortcuts = [(slug, label) for slug, label in _root_shortcuts() if needle in label.lower()]
260 if not pages and not stubs and not shortcuts:
261 # Pages exist but none match: leave the content pane untouched
262 # rather than rendering the empty-wiki state.
263 tree.root.add_leaf(Text(msg.WIKI_NO_MATCHES.format(filter=filter_text)))
264 return
266 self._populate_tree(tree, pages, shortcuts)
267 # Under a filter every stub in the group is a match, so it opens.
268 # Unfiltered it stays collapsed; stubs outnumber the written pages.
269 self._add_stub_group(tree, stubs, expand=bool(needle))
271 def _populate_tree(
272 self,
273 tree: Tree[str | None],
274 pages: list[WikiPageInfo],
275 shortcuts: list[tuple[str, str]],
276 ) -> None:
277 """Build the sidebar tree from a flat list of wiki pages.
279 Slugs like ``summaries/cv-manual/01-brakes/page-0042`` become nested
280 branches under their page-type group, with leaves for leaf pages and
281 expandable branches for intermediate heading folders. *shortcuts* are
282 the auto-generated root pages (``index.md``, ``log.md``) that survived
283 the caller's filter, surfaced as top-level leaves.
284 """
285 for slug, label in shortcuts:
286 tree.root.add_leaf(label, data=slug)
287 self._page_slugs.append(slug)
288 grouped = _group_pages(pages)
289 branches: dict[str, TreeNode[str | None]] = {}
290 for page_type, group_pages in grouped:
291 heading = msg.WIKI_TYPE_HEADINGS.get(page_type, page_type.capitalize())
292 group_node = tree.root.add(heading, expand=True)
293 for page in group_pages:
294 self._page_slugs.append(page.slug)
295 self._insert_page(group_node, page, branches)
297 def _add_stub_group(
298 self, tree: Tree[str | None], stubs: list[WikiStub], *, expand: bool
299 ) -> None:
300 """List the pages the corpus names but nothing has written yet.
302 Rendered distinctly, following the red-link convention: a reader must
303 never wonder whether a page is blank because nothing was written or
304 because generation failed. *expand* opens the group; the caller sets it
305 while a filter is active, when every stub in it is a match.
306 """
307 if not stubs:
308 return
309 group = tree.root.add(msg.WIKI_STUBS_HEADING, expand=expand)
310 for stub in stubs:
311 label = Text.assemble(
312 (strip_control_characters(stub.label), "dim"),
313 " ",
314 (msg.WIKI_STUB_SUFFIX, "dim italic"),
315 )
316 group.add_leaf(label, data=stub.wiki_slug)
317 self._page_slugs.append(stub.wiki_slug)
319 def _insert_page(
320 self,
321 group_node: TreeNode[str | None],
322 page: WikiPageInfo,
323 branches: dict[str, TreeNode[str | None]],
324 ) -> None:
325 """Walk the slug path and add/reuse branches until the leaf position.
327 Slugs begin with the page-type prefix (``summaries/``/``synthesis/``),
328 which is already reflected in the enclosing group node. The remaining
329 path components form the nested tree inside the group. *branches* is
330 the per-build reuse map, keyed by raw slug path.
331 """
332 parts = page.slug.split("/")
333 if len(parts) <= 1:
334 group_node.add_leaf(Text(strip_control_characters(page.title)), data=page.slug)
335 return
337 # Skip the leading page-type component since the group node represents it.
338 node = group_node
339 *branch_parts, leaf_part = parts[1:]
340 path = parts[0]
341 for part in branch_parts:
342 path = f"{path}/{part}"
343 node = _find_or_add_branch(node, part, path, branches)
345 if leaf_part == _INDEX_STEM:
346 # An inner-node index.md file: show its title on the enclosing branch.
347 node.label = Text(strip_control_characters(page.title))
348 node.data = page.slug
349 return
351 label = _short_label(leaf_part)
352 node.add_leaf(Text(strip_control_characters(page.title) or label), data=page.slug)
354 def _show_detail(self, markdown: str) -> None:
355 """Clear the breadcrumb and header rows, then render *markdown* alone."""
356 self.query_one("#wiki-breadcrumb", Static).update("")
357 self.query_one("#wiki-page-header", Static).update("")
358 self.query_one("#wiki-content", Markdown).update(markdown)
360 def _show_placeholder(self) -> None:
361 """Show the no-content placeholder in the main area."""
362 self._show_detail(msg.wiki_empty_state_detail())
364 @on(Tree.NodeSelected, "#wiki-page-list")
365 def _on_node_selected(self, event: Tree.NodeSelected[str | None]) -> None:
366 """Load and display the selected wiki page when the node carries a slug."""
367 slug = event.node.data
368 if not isinstance(slug, str):
369 return
370 stub = self._stubs.get(slug)
371 if stub is not None:
372 shown_label = strip_control_characters(stub.label)
373 # Opening a stub asks rather than generating. The detail pane
374 # explains the page either way, so dismissing the dialog leaves
375 # the reader looking at why the page is blank.
376 self._show_detail(
377 msg.WIKI_STUB_DETAIL.format(
378 title=shown_label,
379 label=shown_label,
380 sources=_describe_sources(stub),
381 )
382 )
383 confirm_stub_generation(self.app, stub)
384 return
385 self._display_page(slug)
387 def _display_page(self, slug: str) -> None:
388 """Read and render a wiki page by slug."""
389 from lilbee.wiki.browse import read_page
391 root = _wiki_root()
392 page = read_page(root, slug)
393 if page is None:
394 self._show_detail(msg.WIKI_NO_CONTENT)
395 return
397 # Frontmatter is arbitrary parsed YAML; a non-numeric value must not
398 # crash the node-select handler that calls this.
399 faith_val = _safe_float(page.frontmatter.get("faithfulness_score"))
401 page_type = ""
402 parts = slug.split("/")
403 if len(parts) >= _SLUG_WITH_TYPE_MIN_PARTS:
404 from lilbee.wiki.shared import SUBDIR_TO_TYPE
406 page_type = SUBDIR_TO_TYPE.get(parts[0], "")
408 source_count = page.frontmatter.get("source_count", 0)
409 created_at = page.frontmatter.get("generated_at", "")
411 header_text = _format_page_header(
412 title=page.title,
413 page_type=page_type,
414 source_count=_safe_int(source_count),
415 created_at=str(created_at),
416 faithfulness=faith_val,
417 )
418 self.query_one("#wiki-breadcrumb", Static).update(_breadcrumb_for_slug(slug, page.title))
419 self.query_one("#wiki-page-header", Static).update(header_text)
420 self.query_one("#wiki-content", Markdown).update(
421 strip_control_characters(page.content, keep_lines=True)
422 )
424 @on(Input.Changed, "#wiki-search")
425 def _on_search_changed(self, event: Input.Changed) -> None:
426 """Re-filter after a short debounce so a multi-key term repaints the
427 tree once on pause, not once per keystroke."""
428 filter_text = event.value.strip()
429 if self._search_filter_timer is not None:
430 self._search_filter_timer.stop()
431 self._search_filter_timer = self.set_timer(
432 self._SEARCH_FILTER_DEBOUNCE_SECONDS,
433 lambda: self._load_pages(filter_text=filter_text),
434 )
436 def action_focus_search(self) -> None:
437 """Focus the search input -- bound to / key."""
438 self.query_one("#wiki-search", Input).focus()
440 def action_open_drafts(self) -> None:
441 """Open the drafts review screen -- bound to capital D."""
442 from lilbee.cli.tui.screens.wiki_drafts import WikiDraftsScreen
444 self.app.push_screen(WikiDraftsScreen())
446 def action_dismiss_or_back(self) -> None:
447 """Clear search if active, otherwise go back."""
448 search = self.query_one("#wiki-search", Input)
449 if search.value:
450 search.value = ""
451 return
452 self.action_go_back()
454 def action_go_back(self) -> None:
455 self.app.go_back()
457 def _tree_or_none(self) -> Tree[str | None] | None:
458 if isinstance(self.focused, Input):
459 return None
460 return self.query_one("#wiki-page-list", Tree)
462 def action_cursor_down(self) -> None:
463 tree = self._tree_or_none()
464 if tree is not None:
465 tree.action_cursor_down()
467 def action_cursor_up(self) -> None:
468 tree = self._tree_or_none()
469 if tree is not None:
470 tree.action_cursor_up()
472 def action_cursor_left(self) -> None:
473 tree = self._tree_or_none()
474 if tree is not None:
475 tree.action_cursor_parent()
477 def action_cursor_right(self) -> None:
478 tree = self._tree_or_none()
479 if tree is not None:
480 tree.action_toggle_node()
482 def action_jump_top(self) -> None:
483 tree = self._tree_or_none()
484 if tree is not None:
485 tree.action_scroll_home()
487 def action_jump_bottom(self) -> None:
488 tree = self._tree_or_none()
489 if tree is not None:
490 tree.action_scroll_end()
492 def action_wikify(self) -> None:
493 """Generate wiki pages from the ingested corpus -- bound to b."""
494 start_wikify(self.app)
496 def action_wipe(self) -> None:
497 """Delete every generated page and its indexed rows -- bound to W."""
498 confirm_wiki_wipe(self.app)
501def _build_progress(reporter: ProgressReporter) -> DetailedProgressCallback:
502 """Map wiki build events onto task-bar progress updates."""
504 def _on_progress(event_type: EventType, data: ProgressEvent) -> None:
505 if event_type is EventType.WIKI_PHASE and isinstance(data, WikiPhaseEvent):
506 reporter.update(
507 0, msg.WIKI_BUILD_PHASE.format(phase=data.phase.value), indeterminate=True
508 )
509 elif event_type is EventType.WIKI_PAGE and isinstance(data, WikiPageEvent):
510 percent = int(data.current * 100 / data.total) if data.total else 0
511 reporter.update(
512 percent,
513 msg.WIKI_BUILD_PAGE.format(
514 label=data.label, current=data.current, total=data.total
515 ),
516 indeterminate=False,
517 )
519 return _on_progress
522def start_wikify(app: LilbeeApp) -> None:
523 """Run a full wiki build on the task bar.
525 Shared by the wiki screen's ``b`` binding and the command palette so both
526 surfaces get the same progress, serialization and completion refresh. The
527 build takes the wiki mutex, so it must never run on the event loop.
528 """
529 if not cfg.wiki:
530 app.notify(msg.CMD_WIKI_DISABLED, severity="warning")
531 return
533 queue = app.task_bar.queue
534 pending = queue.active_tasks + queue.queued_tasks
535 if any(t.task_type == TaskType.WIKI and t.name == msg.TASK_NAME_WIKI for t in pending):
536 app.notify(msg.WIKI_ALREADY_ACTIVE, severity="warning")
537 return
539 def _target(reporter: ProgressReporter) -> None:
540 from lilbee.wiki.generation import run_full_build
542 try:
543 summary = run_full_build(on_progress=_build_progress(reporter))
544 reporter.check_cancelled()
545 except Exception:
546 # Pages already written must show; the done hook only fires on success.
547 call_from_thread(app, app.task_bar.reload_wiki_screens)
548 raise
549 call_from_thread(app, app.notify, msg.WIKI_BUILD_DONE.format(count=summary["count"]))
551 app.task_bar.start_task(msg.TASK_NAME_WIKI, TaskType.WIKI, _target, indeterminate=True)
554def _describe_sources(stub: WikiStub) -> str:
555 """Name the documents a stub's page would be written from."""
556 count = len(stub.sources)
557 shown = shown_sources(stub, get_services().store) if count == 1 else []
558 return shown[0] if shown else f"{count} documents"
561def confirm_stub_generation(app: LilbeeApp, stub: WikiStub) -> None:
562 """Ask before writing a page, then write it on the task bar.
564 Generation always asks. It spends real GPU time on the user's own machine,
565 so the prompt names that cost and says how to stop being asked at all.
566 """
567 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
569 def _on_confirm(confirmed: bool | None) -> None:
570 if confirmed:
571 start_stub_generation(app, stub)
573 app.push_screen(
574 ConfirmDialog(
575 msg.WIKI_STUB_CONFIRM_TITLE,
576 msg.WIKI_STUB_CONFIRM_MESSAGE.format(label=stub.label, sources=_describe_sources(stub)),
577 ),
578 _on_confirm,
579 )
582def start_stub_generation(app: LilbeeApp, stub: WikiStub) -> None:
583 """Write one page on the task bar and refresh the wiki screens after it."""
585 def _target(reporter: ProgressReporter) -> None:
586 from lilbee.wiki.lazy import generate_stub_page
588 reporter.update(0, stub.label, indeterminate=True)
589 try:
590 path = generate_stub_page(stub.slug, get_services().store)
591 except Exception as exc:
592 call_from_thread(
593 app,
594 app.notify,
595 msg.WIKI_STUB_FAILED.format(label=stub.label, error=exc),
596 severity="error",
597 )
598 raise
599 finally:
600 call_from_thread(app, app.task_bar.reload_wiki_screens)
601 if path is None:
602 message = msg.WIKI_STUB_STALE.format(label=stub.label)
603 call_from_thread(app, app.notify, message, severity="warning")
604 raise RuntimeError(message)
605 call_from_thread(app, app.notify, msg.WIKI_STUB_DONE.format(label=stub.label))
607 app.task_bar.start_task(
608 msg.WIKI_STUB_TASK.format(label=stub.label),
609 TaskType.WIKI,
610 _target,
611 indeterminate=True,
612 )
615def wiki_has_content(store: Store) -> bool:
616 """Whether anything generated is still on disk or in the store.
618 Read before offering a wipe so the offer only appears when there is
619 something to remove.
620 """
621 wiki_root = _wiki_root()
622 if wiki_root.is_dir() and any(wiki_root.rglob("*.md")):
623 return True
624 return bool(store.wiki_chunk_sources() or store.wiki_citation_sources())
627def confirm_wiki_wipe(
628 app: LilbeeApp,
629 *,
630 title: str = msg.WIKI_WIPE_CONFIRM_TITLE,
631 message: str = msg.WIKI_WIPE_CONFIRM_MESSAGE,
632 notify_when_empty: bool = True,
633) -> None:
634 """Ask before deleting the wiki, then run the wipe on the task bar.
636 Offered from the wiki screen and from turning the wiki setting off, so
637 both routes get the same warning and the same task-bar progress. The
638 turn-off route passes its own wording and stays silent when there is
639 nothing to delete, since the user asked to disable, not to clean up.
640 """
641 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
643 if not wiki_has_content(get_services().store):
644 if notify_when_empty:
645 app.notify(msg.WIKI_WIPE_NOTHING)
646 return
648 def _on_confirm(confirmed: bool | None) -> None:
649 if confirmed:
650 start_wiki_wipe(app)
652 app.push_screen(ConfirmDialog(title, message), _on_confirm)
655def start_wiki_wipe(app: LilbeeApp) -> None:
656 """Run the wipe on the task bar and refresh the wiki screens after it.
658 The wipe takes the wiki mutex, so it must never run on the event loop.
659 """
661 def _target(reporter: ProgressReporter) -> None:
662 from lilbee.wiki.wipe import wipe_wiki
664 reporter.update(0, msg.WIKI_WIPE_RUNNING, indeterminate=True)
665 try:
666 report = wipe_wiki(get_services().store)
667 finally:
668 # Pages are gone whether or not the row delete landed; the tree
669 # must show the disk state either way.
670 call_from_thread(app, app.task_bar.reload_wiki_screens)
671 if not report.rows_deleted:
672 call_from_thread(app, app.notify, report.summary(), severity="error")
673 raise RuntimeError(report.summary())
674 call_from_thread(app, app.notify, msg.WIKI_WIPE_DONE.format(count=report.pages_removed))
676 app.task_bar.start_task(msg.TASK_NAME_WIKI_WIPE, TaskType.WIKI, _target, indeterminate=True)
679def _find_or_add_branch(
680 parent: TreeNode[str | None],
681 label_part: str,
682 path: str,
683 branches: dict[str, TreeNode[str | None]],
684) -> TreeNode[str | None]:
685 """Return the branch registered for *path*, adding it under *parent* if absent.
687 Reuse is keyed on the raw slug path, so components that render to the
688 same display label ("cv-manual" and "cv_manual") stay separate, and a
689 branch renamed by an inner index page is still found.
690 """
691 node = branches.get(path)
692 if node is None:
693 node = parent.add(Text(_short_label(label_part)), expand=True)
694 branches[path] = node
695 return node
698def _group_pages(
699 pages: list[WikiPageInfo],
700) -> list[tuple[str, list[WikiPageInfo]]]:
701 """Group pages by page_type in sidebar order: concepts, entities, then legacy."""
702 from lilbee.wiki.shared import WikiPageType
704 groups: dict[str, list[WikiPageInfo]] = {}
705 type_order: tuple[str, ...] = (
706 WikiPageType.CONCEPT,
707 WikiPageType.ENTITY,
708 WikiPageType.SUMMARY,
709 WikiPageType.SYNTHESIS,
710 )
711 for t in type_order:
712 group = [p for p in pages if p.page_type == t]
713 if group:
714 groups[t] = group
715 for p in pages:
716 if p.page_type not in groups:
717 groups[p.page_type] = []
718 if p.page_type not in type_order:
719 groups[p.page_type].append(p)
720 return [(k, v) for k, v in groups.items() if v]