Coverage for src/lilbee/cli/tui/widgets/crawl_dialog.py: 100%
114 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"""Modal dialog for configuring a web crawl."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from typing import ClassVar
8from textual.app import ComposeResult
9from textual.binding import Binding, BindingType
10from textual.containers import Center, Horizontal, VerticalScroll
11from textual.screen import ModalScreen
12from textual.widgets import Button, Checkbox, Input, Label, Static
14from lilbee.cli.tui import messages as msg
15from lilbee.core.config import cfg
16from lilbee.core.config.enums import CrawlRenderMode
17from lilbee.crawler.models import NO_LOGIN, CrawlLogin, crawl_limit_error
18from lilbee.crawler.stored_login import crawl_login
20_LOGIN_INPUTS: tuple[tuple[str, str, bool], ...] = (
21 ("crawl-authorization-input", msg.CRAWL_DIALOG_AUTHORIZATION_LABEL, True),
22 ("crawl-username-input", msg.CRAWL_DIALOG_USERNAME_LABEL, False),
23 ("crawl-password-input", msg.CRAWL_DIALOG_PASSWORD_LABEL, True),
24 ("crawl-cookie-input", msg.CRAWL_DIALOG_COOKIE_LABEL, True),
25)
28@dataclass(frozen=True)
29class CrawlParams:
30 """Validated crawl parameters returned by CrawlDialog.
32 depth: None = whole-site unbounded. 0 = single URL only. Positive int =
33 explicit link-follow depth cap. max_pages: CRAWL_PAGES_UNLIMITED (0) = no
34 limit (the user cleared the field); positive int = explicit page cap.
35 render_mode: http (browserless) or browser (Chromium with JavaScript).
36 login: the login typed for this crawl; it shows in no repr.
37 """
39 url: str
40 depth: int | None
41 max_pages: int
42 render_mode: CrawlRenderMode
43 login: CrawlLogin = field(default=NO_LOGIN, repr=False)
46class CrawlDialog(ModalScreen[CrawlParams | None]):
47 """Modal dialog that collects URL, recursion toggle, and optional caps."""
49 CSS_PATH = "crawl_dialog.tcss"
50 AUTO_FOCUS = "#crawl-url-input"
52 BINDINGS: ClassVar[list[BindingType]] = [
53 Binding("escape", "cancel", "Cancel", show=False),
54 ]
56 def compose(self) -> ComposeResult:
57 # Scrollable body so the full form (incl. depth + the action buttons)
58 # stays reachable on short terminals rather than clipping at max-height.
59 with VerticalScroll():
60 yield Static(msg.CRAWL_DIALOG_TITLE, id="crawl-title")
61 yield Label(msg.CRAWL_DIALOG_URL_LABEL)
62 yield Input(
63 placeholder=msg.CRAWL_DIALOG_URL_PLACEHOLDER,
64 id="crawl-url-input",
65 )
66 yield Checkbox(
67 msg.CRAWL_DIALOG_RECURSIVE_LABEL,
68 value=True,
69 id="crawl-recursive-checkbox",
70 )
71 # Defaults to the persisted crawl_render_mode; toggling it sticks
72 # (the chat screen writes the choice back when the crawl starts).
73 yield Checkbox(
74 msg.CRAWL_DIALOG_BROWSER_LABEL,
75 value=cfg.crawl_render_mode is CrawlRenderMode.BROWSER,
76 id="crawl-browser-checkbox",
77 )
78 # Max pages is the cap users actually reach for, so it sits at the top
79 # level (not behind Advanced) prefilled with the protective default;
80 # clearing it crawls unlimited without a trip to settings.
81 yield Label(msg.CRAWL_DIALOG_MAX_PAGES_LABEL, classes="crawl-field-label")
82 yield Input(
83 value=str(cfg.crawl_safety_max_pages),
84 placeholder=msg.CRAWL_DIALOG_MAX_PAGES_PLACEHOLDER,
85 id="crawl-max-pages-input",
86 )
87 # Depth sits at the top level (not behind a collapsible) so it is
88 # discoverable and always reachable; it only applies to recursive
89 # crawls and is ignored when Recursive is unchecked.
90 yield Label(msg.CRAWL_DIALOG_DEPTH_LABEL, classes="crawl-field-label")
91 yield Input(
92 placeholder=msg.CRAWL_DIALOG_DEPTH_PLACEHOLDER,
93 id="crawl-depth-input",
94 )
95 yield Label(msg.CRAWL_DIALOG_LOGIN_LABEL, classes="crawl-field-label")
96 for field_id, label, secret in _LOGIN_INPUTS:
97 yield Label(label)
98 yield Input(
99 placeholder=msg.CRAWL_DIALOG_LOGIN_PLACEHOLDER,
100 password=secret,
101 id=field_id,
102 )
103 yield Static("", id="crawl-error", markup=False)
104 with Center(), Horizontal(id="crawl-buttons"):
105 yield Button(msg.CRAWL_DIALOG_SUBMIT, variant="primary", id="crawl-submit")
106 yield Button(msg.CRAWL_DIALOG_CANCEL, variant="default", id="crawl-cancel")
108 def on_button_pressed(self, event: Button.Pressed) -> None:
109 if event.button.id == "crawl-submit":
110 self._try_submit()
111 else:
112 self.dismiss(None)
114 def on_input_submitted(self, _event: Input.Submitted) -> None:
115 self._try_submit()
117 @staticmethod
118 def _parse_optional_non_negative_int(value: str) -> int | None:
119 """Parse a non-negative integer from *value*; empty string returns None.
121 None means "no cap" in the crawl API. Zero is meaningful for the
122 depth field (single-URL crawl per the crawler contract). Raises
123 ValueError on non-numeric input or negative integers.
124 """
125 if not value:
126 return None
127 n = int(value)
128 if n < 0:
129 raise ValueError
130 return n
132 @staticmethod
133 def _parse_max_pages(value: str) -> int:
134 """Parse the max-pages field. Empty means unlimited (the user cleared it).
136 Returns ``CRAWL_PAGES_UNLIMITED`` (0) for empty input, a positive int for
137 an explicit cap. Raises ValueError on non-numeric or non-positive input.
138 """
139 from lilbee.crawler.models import CRAWL_PAGES_UNLIMITED
141 if not value:
142 return CRAWL_PAGES_UNLIMITED
143 n = int(value)
144 if n <= 0:
145 raise ValueError
146 return n
148 def _validate(self) -> CrawlParams | str:
149 """Validate inputs. Returns CrawlParams on success, error message on failure."""
150 from lilbee.crawler import is_url, require_valid_crawl_url
151 from lilbee.crawler.models import CRAWL_PAGES_UNLIMITED
153 url = self.query_one("#crawl-url-input", Input).value.strip()
154 recursive = self.query_one("#crawl-recursive-checkbox", Checkbox).value
155 use_browser = self.query_one("#crawl-browser-checkbox", Checkbox).value
156 depth_str = self.query_one("#crawl-depth-input", Input).value.strip()
157 max_pages_str = self.query_one("#crawl-max-pages-input", Input).value.strip()
159 render_mode = CrawlRenderMode.BROWSER if use_browser else CrawlRenderMode.HTTP
161 if not url:
162 return msg.CRAWL_DIALOG_URL_REQUIRED
164 if not is_url(url):
165 url = f"https://{url}"
167 try:
168 require_valid_crawl_url(url)
169 except ValueError as exc:
170 return msg.CRAWL_DIALOG_INVALID_URL.format(error=exc)
172 try:
173 login = self._typed_login(url)
174 except ValueError as exc:
175 return msg.CRAWL_DIALOG_INVALID_LOGIN.format(error=exc)
177 depth: int | None = 0
178 max_pages = CRAWL_PAGES_UNLIMITED
179 if recursive:
180 try:
181 # depth=0 means "single URL" per the crawler contract; allow it.
182 depth = self._parse_optional_non_negative_int(depth_str)
183 except ValueError:
184 return msg.CRAWL_DIALOG_INVALID_NUMBER.format(field=msg.CRAWL_DIALOG_DEPTH_LABEL)
185 try:
186 max_pages = self._parse_max_pages(max_pages_str)
187 except ValueError:
188 return msg.CRAWL_DIALOG_INVALID_NUMBER.format(
189 field=msg.CRAWL_DIALOG_MAX_PAGES_LABEL
190 )
192 return crawl_limit_error(depth, max_pages) or CrawlParams(
193 url=url, depth=depth, max_pages=max_pages, render_mode=render_mode, login=login
194 )
196 def _typed_login(self, url: str) -> CrawlLogin:
197 """The login typed here, checked together with the stored login for *url*'s host."""
198 typed = CrawlLogin(
199 *(self.query_one(f"#{field_id}", Input).value for field_id, _, _ in _LOGIN_INPUTS)
200 )
201 crawl_login([url], typed)
202 return typed
204 def _try_submit(self) -> None:
205 """Validate inputs and dismiss with CrawlParams or show an error."""
206 result = self._validate()
207 error_widget = self.query_one("#crawl-error", Static)
208 # _validate returns str (error) or CrawlParams; isinstance disambiguates
209 if isinstance(result, str):
210 error_widget.update(result)
211 return
212 error_widget.update("")
213 self.dismiss(result)
215 def action_cancel(self) -> None:
216 self.dismiss(None)