Coverage for src/lilbee/cli/tui/screens/wiki.py: 100%
358 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +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 textual import on
17from textual.app import ComposeResult
18from textual.binding import Binding, BindingType
19from textual.containers import Horizontal, Vertical, VerticalScroll
20from textual.screen import Screen
21from textual.timer import Timer
22from textual.widgets import Input, Markdown, Static, Tree
23from textual.widgets.tree import TreeNode
25from lilbee.app.services import get_services
26from lilbee.cli.tui import messages as msg
27from lilbee.cli.tui.browse_bindings import BROWSE_LIST_BINDINGS, browse_back_bindings
28from lilbee.cli.tui.task_queue import TaskType
29from lilbee.cli.tui.thread_safe import call_from_thread
30from lilbee.cli.tui.widgets.task_bar import TaskBar
31from lilbee.core.config import cfg
32from lilbee.runtime.progress import EventType, WikiPageEvent, WikiPhaseEvent
33from lilbee.wiki.stubs import WikiStub, load_stub_index, ungenerated_stubs
35log = logging.getLogger(__name__)
37# Tree node data carries the full wiki-page slug when present. Group folders
38# (page-type headings, per-source branches, inner-section branches) use None.
39_INDEX_STEM = "index"
40# Wiki slugs of the form ``<subdir>/<name>`` carry a meaningful page type;
41# bare slugs (no slash) do not.
42_SLUG_WITH_TYPE_MIN_PARTS = 2
45def _wiki_root() -> Path:
46 """Resolve the wiki root directory from config."""
47 return cfg.data_root / cfg.wiki_dir
50def _root_shortcuts() -> list[tuple[str, str]]:
51 """The auto-generated root pages that exist, as ``(slug, label)`` pairs.
53 Returned rather than mounted so the caller filters them alongside the pages.
54 """
55 return [
56 (slug, label)
57 for slug, label in (("index", msg.WIKI_INDEX_LABEL), ("log", msg.WIKI_LOG_LABEL))
58 if (_wiki_root() / f"{slug}.md").is_file()
59 ]
62def _safe_float(value: object) -> float | None:
63 """Coerce an untyped frontmatter value to float, or None if not numeric."""
64 try:
65 return float(cast("float", value))
66 except (TypeError, ValueError):
67 return None
70def _safe_int(value: object, default: int = 0) -> int:
71 """Coerce an untyped frontmatter value to int, or *default* if not numeric."""
72 try:
73 return int(cast("float", value))
74 except (TypeError, ValueError):
75 return default
78def _format_page_header(
79 title: str,
80 page_type: str,
81 source_count: int,
82 created_at: str,
83 faithfulness: float | None,
84) -> str:
85 """Build a header string for the content pane."""
86 parts = [f"[bold]{title}[/]"]
87 parts.append(f" [dim]{page_type}[/]")
88 if source_count > 0:
89 parts.append(f" [dim]{source_count} sources[/]")
90 if created_at:
91 parts.append(f" [dim]{created_at}[/]")
92 if faithfulness is not None:
93 pct = int(faithfulness * 100)
94 parts.append(f" [dim]faithfulness {pct}%[/]")
95 return "".join(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) -> str:
104 """Build a dim-themed breadcrumb string: chapter > section > page."""
105 parts = slug.split("/")
106 if len(parts) <= 1:
107 return ""
108 display_parts = [_short_label(p) for p in parts[:-1]]
109 display_parts.append(title)
110 return " [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(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 group.add_leaf(msg.WIKI_STUB_LABEL.format(title=stub.label), data=stub.wiki_slug)
308 self._page_slugs.append(stub.wiki_slug)
310 def _insert_page(
311 self,
312 group_node: TreeNode[str | None],
313 page: WikiPageInfo,
314 branches: dict[str, TreeNode[str | None]],
315 ) -> None:
316 """Walk the slug path and add/reuse branches until the leaf position.
318 Slugs begin with the page-type prefix (``summaries/``/``synthesis/``),
319 which is already reflected in the enclosing group node. The remaining
320 path components form the nested tree inside the group. *branches* is
321 the per-build reuse map, keyed by raw slug path.
322 """
323 parts = page.slug.split("/")
324 if len(parts) <= 1:
325 group_node.add_leaf(page.title, data=page.slug)
326 return
328 # Skip the leading page-type component since the group node represents it.
329 node = group_node
330 *branch_parts, leaf_part = parts[1:]
331 path = parts[0]
332 for part in branch_parts:
333 path = f"{path}/{part}"
334 node = _find_or_add_branch(node, part, path, branches)
336 if leaf_part == _INDEX_STEM:
337 # An inner-node index.md file: show its title on the enclosing branch.
338 node.label = page.title
339 node.data = page.slug
340 return
342 label = _short_label(leaf_part)
343 node.add_leaf(page.title if page.title else label, data=page.slug)
345 def _show_detail(self, markdown: str) -> None:
346 """Clear the breadcrumb and header rows, then render *markdown* alone."""
347 self.query_one("#wiki-breadcrumb", Static).update("")
348 self.query_one("#wiki-page-header", Static).update("")
349 self.query_one("#wiki-content", Markdown).update(markdown)
351 def _show_placeholder(self) -> None:
352 """Show the no-content placeholder in the main area."""
353 self._show_detail(msg.wiki_empty_state_detail())
355 @on(Tree.NodeSelected, "#wiki-page-list")
356 def _on_node_selected(self, event: Tree.NodeSelected[str | None]) -> None:
357 """Load and display the selected wiki page when the node carries a slug."""
358 slug = event.node.data
359 if not isinstance(slug, str):
360 return
361 stub = self._stubs.get(slug)
362 if stub is not None:
363 # Opening a stub asks rather than generating. The detail pane
364 # explains the page either way, so dismissing the dialog leaves
365 # the reader looking at why the page is blank.
366 self._show_detail(
367 msg.WIKI_STUB_DETAIL.format(
368 title=stub.label, label=stub.label, sources=_describe_sources(stub)
369 )
370 )
371 confirm_stub_generation(self.app, stub)
372 return
373 self._display_page(slug)
375 def _display_page(self, slug: str) -> None:
376 """Read and render a wiki page by slug."""
377 from lilbee.wiki.browse import read_page
379 root = _wiki_root()
380 page = read_page(root, slug)
381 if page is None:
382 self._show_detail(msg.WIKI_NO_CONTENT)
383 return
385 # Frontmatter is arbitrary parsed YAML; a non-numeric value must not
386 # crash the node-select handler that calls this.
387 faith_val = _safe_float(page.frontmatter.get("faithfulness_score"))
389 page_type = ""
390 parts = slug.split("/")
391 if len(parts) >= _SLUG_WITH_TYPE_MIN_PARTS:
392 from lilbee.wiki.shared import SUBDIR_TO_TYPE
394 page_type = SUBDIR_TO_TYPE.get(parts[0], "")
396 source_count = page.frontmatter.get("source_count", 0)
397 created_at = page.frontmatter.get("generated_at", "")
399 header_text = _format_page_header(
400 title=page.title,
401 page_type=page_type,
402 source_count=_safe_int(source_count),
403 created_at=str(created_at),
404 faithfulness=faith_val,
405 )
406 self.query_one("#wiki-breadcrumb", Static).update(_breadcrumb_for_slug(slug, page.title))
407 self.query_one("#wiki-page-header", Static).update(header_text)
408 self.query_one("#wiki-content", Markdown).update(page.content)
410 @on(Input.Changed, "#wiki-search")
411 def _on_search_changed(self, event: Input.Changed) -> None:
412 """Re-filter after a short debounce so a multi-key term repaints the
413 tree once on pause, not once per keystroke."""
414 filter_text = event.value.strip()
415 if self._search_filter_timer is not None:
416 self._search_filter_timer.stop()
417 self._search_filter_timer = self.set_timer(
418 self._SEARCH_FILTER_DEBOUNCE_SECONDS,
419 lambda: self._load_pages(filter_text=filter_text),
420 )
422 def action_focus_search(self) -> None:
423 """Focus the search input -- bound to / key."""
424 self.query_one("#wiki-search", Input).focus()
426 def action_open_drafts(self) -> None:
427 """Open the drafts review screen -- bound to capital D."""
428 from lilbee.cli.tui.screens.wiki_drafts import WikiDraftsScreen
430 self.app.push_screen(WikiDraftsScreen())
432 def action_dismiss_or_back(self) -> None:
433 """Clear search if active, otherwise go back."""
434 search = self.query_one("#wiki-search", Input)
435 if search.value:
436 search.value = ""
437 return
438 self.action_go_back()
440 def action_go_back(self) -> None:
441 self.app.go_back()
443 def _tree_or_none(self) -> Tree[str | None] | None:
444 if isinstance(self.focused, Input):
445 return None
446 return self.query_one("#wiki-page-list", Tree)
448 def action_cursor_down(self) -> None:
449 tree = self._tree_or_none()
450 if tree is not None:
451 tree.action_cursor_down()
453 def action_cursor_up(self) -> None:
454 tree = self._tree_or_none()
455 if tree is not None:
456 tree.action_cursor_up()
458 def action_cursor_left(self) -> None:
459 tree = self._tree_or_none()
460 if tree is not None:
461 tree.action_cursor_parent()
463 def action_cursor_right(self) -> None:
464 tree = self._tree_or_none()
465 if tree is not None:
466 tree.action_toggle_node()
468 def action_jump_top(self) -> None:
469 tree = self._tree_or_none()
470 if tree is not None:
471 tree.action_scroll_home()
473 def action_jump_bottom(self) -> None:
474 tree = self._tree_or_none()
475 if tree is not None:
476 tree.action_scroll_end()
478 def action_wikify(self) -> None:
479 """Generate wiki pages from the ingested corpus -- bound to b."""
480 start_wikify(self.app)
482 def action_wipe(self) -> None:
483 """Delete every generated page and its indexed rows -- bound to W."""
484 confirm_wiki_wipe(self.app)
487def _build_progress(reporter: ProgressReporter) -> DetailedProgressCallback:
488 """Map wiki build events onto task-bar progress updates."""
490 def _on_progress(event_type: EventType, data: ProgressEvent) -> None:
491 if event_type is EventType.WIKI_PHASE and isinstance(data, WikiPhaseEvent):
492 reporter.update(
493 0, msg.WIKI_BUILD_PHASE.format(phase=data.phase.value), indeterminate=True
494 )
495 elif event_type is EventType.WIKI_PAGE and isinstance(data, WikiPageEvent):
496 percent = int(data.current * 100 / data.total) if data.total else 0
497 reporter.update(
498 percent,
499 msg.WIKI_BUILD_PAGE.format(
500 label=data.label, current=data.current, total=data.total
501 ),
502 indeterminate=False,
503 )
505 return _on_progress
508def start_wikify(app: LilbeeApp) -> None:
509 """Run a full wiki build on the task bar.
511 Shared by the wiki screen's ``b`` binding and the command palette so both
512 surfaces get the same progress, serialization and completion refresh. The
513 build takes the wiki mutex, so it must never run on the event loop.
514 """
515 if not cfg.wiki:
516 app.notify(msg.CMD_WIKI_DISABLED, severity="warning")
517 return
519 queue = app.task_bar.queue
520 pending = queue.active_tasks + queue.queued_tasks
521 if any(t.task_type == TaskType.WIKI and t.name == msg.TASK_NAME_WIKI for t in pending):
522 app.notify(msg.WIKI_ALREADY_ACTIVE, severity="warning")
523 return
525 def _target(reporter: ProgressReporter) -> None:
526 from lilbee.wiki.generation import run_full_build
528 try:
529 summary = run_full_build(on_progress=_build_progress(reporter))
530 reporter.check_cancelled()
531 except Exception:
532 # Pages already written must show; the done hook only fires on success.
533 call_from_thread(app, app.task_bar.reload_wiki_screens)
534 raise
535 call_from_thread(app, app.notify, msg.WIKI_BUILD_DONE.format(count=summary["count"]))
537 app.task_bar.start_task(msg.TASK_NAME_WIKI, TaskType.WIKI, _target, indeterminate=True)
540def _describe_sources(stub: WikiStub) -> str:
541 """Name the documents a stub's page would be written from."""
542 count = len(stub.sources)
543 if count == 1:
544 return stub.sources[0]
545 return f"{count} documents"
548def confirm_stub_generation(app: LilbeeApp, stub: WikiStub) -> None:
549 """Ask before writing a page, then write it on the task bar.
551 Generation always asks. It spends real GPU time on the user's own machine,
552 so the prompt names that cost and says how to stop being asked at all.
553 """
554 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
556 def _on_confirm(confirmed: bool | None) -> None:
557 if confirmed:
558 start_stub_generation(app, stub)
560 app.push_screen(
561 ConfirmDialog(
562 msg.WIKI_STUB_CONFIRM_TITLE,
563 msg.WIKI_STUB_CONFIRM_MESSAGE.format(label=stub.label, sources=_describe_sources(stub)),
564 ),
565 _on_confirm,
566 )
569def start_stub_generation(app: LilbeeApp, stub: WikiStub) -> None:
570 """Write one page on the task bar and refresh the wiki screens after it."""
572 def _target(reporter: ProgressReporter) -> None:
573 from lilbee.wiki.lazy import generate_stub_page
575 reporter.update(0, stub.label, indeterminate=True)
576 try:
577 path = generate_stub_page(stub.slug, get_services().store)
578 except Exception as exc:
579 call_from_thread(
580 app,
581 app.notify,
582 msg.WIKI_STUB_FAILED.format(label=stub.label, error=exc),
583 severity="error",
584 )
585 raise
586 finally:
587 call_from_thread(app, app.task_bar.reload_wiki_screens)
588 if path is None:
589 message = msg.WIKI_STUB_STALE.format(label=stub.label)
590 call_from_thread(app, app.notify, message, severity="warning")
591 raise RuntimeError(message)
592 call_from_thread(app, app.notify, msg.WIKI_STUB_DONE.format(label=stub.label))
594 app.task_bar.start_task(
595 msg.WIKI_STUB_TASK.format(label=stub.label),
596 TaskType.WIKI,
597 _target,
598 indeterminate=True,
599 )
602def wiki_has_content(store: Store) -> bool:
603 """Whether anything generated is still on disk or in the store.
605 Read before offering a wipe so the offer only appears when there is
606 something to remove.
607 """
608 wiki_root = _wiki_root()
609 if wiki_root.is_dir() and any(wiki_root.rglob("*.md")):
610 return True
611 return bool(store.wiki_chunk_sources() or store.wiki_citation_sources())
614def confirm_wiki_wipe(
615 app: LilbeeApp,
616 *,
617 title: str = msg.WIKI_WIPE_CONFIRM_TITLE,
618 message: str = msg.WIKI_WIPE_CONFIRM_MESSAGE,
619 notify_when_empty: bool = True,
620) -> None:
621 """Ask before deleting the wiki, then run the wipe on the task bar.
623 Offered from the wiki screen and from turning the wiki setting off, so
624 both routes get the same warning and the same task-bar progress. The
625 turn-off route passes its own wording and stays silent when there is
626 nothing to delete, since the user asked to disable, not to clean up.
627 """
628 from lilbee.cli.tui.widgets.confirm_dialog import ConfirmDialog
630 if not wiki_has_content(get_services().store):
631 if notify_when_empty:
632 app.notify(msg.WIKI_WIPE_NOTHING)
633 return
635 def _on_confirm(confirmed: bool | None) -> None:
636 if confirmed:
637 start_wiki_wipe(app)
639 app.push_screen(ConfirmDialog(title, message), _on_confirm)
642def start_wiki_wipe(app: LilbeeApp) -> None:
643 """Run the wipe on the task bar and refresh the wiki screens after it.
645 The wipe takes the wiki mutex, so it must never run on the event loop.
646 """
648 def _target(reporter: ProgressReporter) -> None:
649 from lilbee.wiki.wipe import wipe_wiki
651 reporter.update(0, msg.WIKI_WIPE_RUNNING, indeterminate=True)
652 try:
653 report = wipe_wiki(get_services().store)
654 finally:
655 # Pages are gone whether or not the row delete landed; the tree
656 # must show the disk state either way.
657 call_from_thread(app, app.task_bar.reload_wiki_screens)
658 if not report.rows_deleted:
659 call_from_thread(app, app.notify, report.summary(), severity="error")
660 raise RuntimeError(report.summary())
661 call_from_thread(app, app.notify, msg.WIKI_WIPE_DONE.format(count=report.pages_removed))
663 app.task_bar.start_task(msg.TASK_NAME_WIKI_WIPE, TaskType.WIKI, _target, indeterminate=True)
666def _find_or_add_branch(
667 parent: TreeNode[str | None],
668 label_part: str,
669 path: str,
670 branches: dict[str, TreeNode[str | None]],
671) -> TreeNode[str | None]:
672 """Return the branch registered for *path*, adding it under *parent* if absent.
674 Reuse is keyed on the raw slug path, so components that render to the
675 same display label ("cv-manual" and "cv_manual") stay separate, and a
676 branch renamed by an inner index page is still found.
677 """
678 node = branches.get(path)
679 if node is None:
680 node = parent.add(_short_label(label_part), expand=True)
681 branches[path] = node
682 return node
685def _group_pages(
686 pages: list[WikiPageInfo],
687) -> list[tuple[str, list[WikiPageInfo]]]:
688 """Group pages by page_type in sidebar order: concepts, entities, then legacy."""
689 from lilbee.wiki.shared import WikiPageType
691 groups: dict[str, list[WikiPageInfo]] = {}
692 type_order: tuple[str, ...] = (
693 WikiPageType.CONCEPT,
694 WikiPageType.ENTITY,
695 WikiPageType.SUMMARY,
696 WikiPageType.SYNTHESIS,
697 )
698 for t in type_order:
699 group = [p for p in pages if p.page_type == t]
700 if group:
701 groups[t] = group
702 for p in pages:
703 if p.page_type not in groups:
704 groups[p.page_type] = []
705 if p.page_type not in type_order:
706 groups[p.page_type].append(p)
707 return [(k, v) for k, v in groups.items() if v]