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