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

1"""Modal dialog for configuring a web crawl.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6from typing import ClassVar 

7 

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 

13 

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 

19 

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) 

26 

27 

28@dataclass(frozen=True) 

29class CrawlParams: 

30 """Validated crawl parameters returned by CrawlDialog. 

31 

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 """ 

38 

39 url: str 

40 depth: int | None 

41 max_pages: int 

42 render_mode: CrawlRenderMode 

43 login: CrawlLogin = field(default=NO_LOGIN, repr=False) 

44 

45 

46class CrawlDialog(ModalScreen[CrawlParams | None]): 

47 """Modal dialog that collects URL, recursion toggle, and optional caps.""" 

48 

49 CSS_PATH = "crawl_dialog.tcss" 

50 AUTO_FOCUS = "#crawl-url-input" 

51 

52 BINDINGS: ClassVar[list[BindingType]] = [ 

53 Binding("escape", "cancel", "Cancel", show=False), 

54 ] 

55 

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") 

107 

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) 

113 

114 def on_input_submitted(self, _event: Input.Submitted) -> None: 

115 self._try_submit() 

116 

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. 

120 

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 

131 

132 @staticmethod 

133 def _parse_max_pages(value: str) -> int: 

134 """Parse the max-pages field. Empty means unlimited (the user cleared it). 

135 

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 

140 

141 if not value: 

142 return CRAWL_PAGES_UNLIMITED 

143 n = int(value) 

144 if n <= 0: 

145 raise ValueError 

146 return n 

147 

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 

152 

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() 

158 

159 render_mode = CrawlRenderMode.BROWSER if use_browser else CrawlRenderMode.HTTP 

160 

161 if not url: 

162 return msg.CRAWL_DIALOG_URL_REQUIRED 

163 

164 if not is_url(url): 

165 url = f"https://{url}" 

166 

167 try: 

168 require_valid_crawl_url(url) 

169 except ValueError as exc: 

170 return msg.CRAWL_DIALOG_INVALID_URL.format(error=exc) 

171 

172 try: 

173 login = self._typed_login(url) 

174 except ValueError as exc: 

175 return msg.CRAWL_DIALOG_INVALID_LOGIN.format(error=exc) 

176 

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 ) 

191 

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 ) 

195 

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 

203 

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) 

214 

215 def action_cancel(self) -> None: 

216 self.dismiss(None)