android-watcher 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. android_watcher/__init__.py +10 -0
  2. android_watcher/catalog/__init__.py +32 -0
  3. android_watcher/catalog/catalog.toml +531 -0
  4. android_watcher/cli.py +161 -0
  5. android_watcher/config.py +262 -0
  6. android_watcher/detect/__init__.py +1 -0
  7. android_watcher/detect/_normalize.py +192 -0
  8. android_watcher/detect/android_sitemap.py +540 -0
  9. android_watcher/detect/base.py +14 -0
  10. android_watcher/detect/content.py +99 -0
  11. android_watcher/detect/feed.py +135 -0
  12. android_watcher/detect/sitemap.py +203 -0
  13. android_watcher/doctor.py +125 -0
  14. android_watcher/fetch.py +162 -0
  15. android_watcher/group.py +79 -0
  16. android_watcher/lock.py +32 -0
  17. android_watcher/models.py +156 -0
  18. android_watcher/notify/__init__.py +1 -0
  19. android_watcher/notify/base.py +21 -0
  20. android_watcher/notify/email.py +52 -0
  21. android_watcher/notify/html.py +114 -0
  22. android_watcher/notify/render.py +239 -0
  23. android_watcher/notify/slack.py +124 -0
  24. android_watcher/notify/telegram.py +46 -0
  25. android_watcher/rank.py +84 -0
  26. android_watcher/registry.py +38 -0
  27. android_watcher/run.py +283 -0
  28. android_watcher/schedule.py +488 -0
  29. android_watcher/seed/__init__.py +45 -0
  30. android_watcher/seed/seed.sql.gz +0 -0
  31. android_watcher/store.py +492 -0
  32. android_watcher/triage/__init__.py +1 -0
  33. android_watcher/triage/base.py +25 -0
  34. android_watcher/triage/claude_cli.py +185 -0
  35. android_watcher/triage/noop.py +24 -0
  36. android_watcher/tui/__init__.py +1 -0
  37. android_watcher/tui/app.py +163 -0
  38. android_watcher/tui/configio.py +215 -0
  39. android_watcher/tui/screens.py +927 -0
  40. android_watcher-1.0.0.dist-info/METADATA +310 -0
  41. android_watcher-1.0.0.dist-info/RECORD +44 -0
  42. android_watcher-1.0.0.dist-info/WHEEL +4 -0
  43. android_watcher-1.0.0.dist-info/entry_points.txt +2 -0
  44. android_watcher-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,927 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+ from rich.text import Text
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Center
10
+ from textual.screen import Screen
11
+ from textual.widgets import Input, OptionList, Static
12
+ from textual.widgets.option_list import Option
13
+
14
+ from android_watcher.catalog import load_catalog
15
+ from android_watcher.config import Config
16
+ from android_watcher.models import Source
17
+
18
+ NONE_SENTINEL = "__none__"
19
+
20
+ _DETECTORS = ("content", "feed", "sitemap", "android_sitemap")
21
+ _CATEGORIES = (
22
+ "platform-release",
23
+ "api-reference",
24
+ "tooling",
25
+ "guides",
26
+ "dev-blog",
27
+ "design",
28
+ "news",
29
+ )
30
+
31
+ _SECRET_NOTE = "Saved to a 0600 file. Use ${ENV_VAR} to reference a secret without storing it"
32
+
33
+ _WEEKDAYS = (
34
+ ("mon", "Monday"),
35
+ ("tue", "Tuesday"),
36
+ ("wed", "Wednesday"),
37
+ ("thu", "Thursday"),
38
+ ("fri", "Friday"),
39
+ ("sat", "Saturday"),
40
+ ("sun", "Sunday"),
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Field:
46
+ """One editable row in a field menu."""
47
+
48
+ key: str
49
+ label: str
50
+ kind: str # "text" | "int" | "secret" | "enum"
51
+ choices: tuple[str, ...] = ()
52
+ help: str = ""
53
+
54
+
55
+ def _bold(text: str) -> Text:
56
+ return Text(text, style="bold")
57
+
58
+
59
+ def _heading(title: str, subtitle: str) -> list[Static]:
60
+ """The centered heading + subheading shared by every screen."""
61
+ return [
62
+ Static(Text(title, justify="center", style="bold"), id="title"),
63
+ Static(Text(subtitle, justify="center"), id="help"),
64
+ ]
65
+
66
+
67
+ def _quit_hint() -> Static:
68
+ """The persistent bottom line shown on every screen."""
69
+ return Static("q or ctrl+c to quit", id="quit")
70
+
71
+
72
+ def _add_trailing(listing: OptionList, label: str, option_id: str) -> None:
73
+ """Append two blank spacer rows, then a bold action row (Done / Next / Submit)."""
74
+ listing.add_option(Option(Text(" "), id="__sp1__", disabled=True))
75
+ listing.add_option(Option(Text(" "), id="__sp2__", disabled=True))
76
+ listing.add_option(Option(_bold(label), id=option_id))
77
+
78
+
79
+ def _focus_first(listing: OptionList) -> None:
80
+ """Highlight the first selectable option so the user never starts on nothing."""
81
+ if listing.highlighted is not None:
82
+ return
83
+ for i in range(listing.option_count):
84
+ if not listing.get_option_at_index(i).disabled:
85
+ listing.highlighted = i
86
+ return
87
+
88
+
89
+ def resolve_enabled_ids(config: Config) -> set[str]:
90
+ """The catalog source ids currently watched, given the config override rules."""
91
+ catalog = load_catalog()
92
+ ids = set(config.enabled_source_ids)
93
+ if not ids:
94
+ return {s.id for s in catalog if s.enabled}
95
+ if ids == {NONE_SENTINEL}:
96
+ return set()
97
+ return {s.id for s in catalog if s.id in ids}
98
+
99
+
100
+ def watched_count(config: Config) -> int:
101
+ """Number of sources actually watched: resolved catalog ids plus customs."""
102
+ return len(resolve_enabled_ids(config)) + len(config.custom_sources)
103
+
104
+
105
+ class _Nav(Screen):
106
+ """Shared back navigation: left arrow or escape returns to the previous screen.
107
+
108
+ The first screen is the app's default (base) screen, so popping is safe only
109
+ when something was pushed on top of it.
110
+ """
111
+
112
+ BINDINGS = [
113
+ Binding("left", "back", "back", show=False),
114
+ Binding("escape", "back", "back", show=False),
115
+ ]
116
+
117
+ def action_back(self) -> None:
118
+ if len(self.app.screen_stack) > 1:
119
+ self.app.pop_screen()
120
+
121
+
122
+ class FieldMenuScreen(_Nav):
123
+ """A pointer list of fields. Enums cycle in place; text edits inline.
124
+
125
+ Enter selects the highlighted row; the right arrow moves forward. Editing
126
+ happens in an inline input on the same screen, never a new one.
127
+ """
128
+
129
+ TITLE = ""
130
+ HELP = ""
131
+
132
+ BINDINGS = [
133
+ Binding("right", "forward", "forward", show=False),
134
+ Binding("space", "select", "select", show=False),
135
+ ]
136
+
137
+ def __init__(self, config: Config, *, wizard: bool = False) -> None:
138
+ super().__init__()
139
+ self._config = config
140
+ self._wizard = wizard
141
+ self._editing: str | None = None
142
+
143
+ # --- subclass hooks ---------------------------------------------------
144
+ def _fields(self) -> list[Field]:
145
+ raise NotImplementedError
146
+
147
+ def _slot(self, key: str) -> tuple[object, str]:
148
+ raise NotImplementedError
149
+
150
+ def _after_set(self) -> None:
151
+ pass
152
+
153
+ def _validate(self, key: str, value: str) -> str | None:
154
+ return None
155
+
156
+ # --- value access -----------------------------------------------------
157
+ def _field(self, key: str) -> Field:
158
+ return next(f for f in self._fields() if f.key == key)
159
+
160
+ def _get(self, key: str) -> str:
161
+ obj, attr = self._slot(key)
162
+ return str(getattr(obj, attr))
163
+
164
+ def _set(self, key: str, value: str) -> None:
165
+ field = self._field(key)
166
+ obj, attr = self._slot(key)
167
+ if field.kind == "int":
168
+ try:
169
+ setattr(obj, attr, int(value))
170
+ except ValueError:
171
+ return
172
+ else:
173
+ setattr(obj, attr, value)
174
+ self._after_set()
175
+
176
+ # --- rendering --------------------------------------------------------
177
+ def _hint(self) -> str:
178
+ if self._wizard:
179
+ return "↑/↓ move · enter select · → next"
180
+ return "↑/↓ move · enter select · esc back"
181
+
182
+ def compose(self) -> ComposeResult:
183
+ yield from _heading(self.TITLE, self.HELP)
184
+ yield OptionList(id="fields")
185
+ yield Input(id="editor")
186
+ yield Static("", id="status")
187
+ yield Static(self._hint(), id="hint")
188
+ yield _quit_hint()
189
+
190
+ def on_mount(self) -> None:
191
+ self._populate()
192
+
193
+ def _display(self, field: Field) -> str:
194
+ if field.kind == "secret":
195
+ return "••••" if self._get(field.key) else "—"
196
+ return self._get(field.key) or "—"
197
+
198
+ def _populate(self) -> None:
199
+ listing = self.query_one("#fields", OptionList)
200
+ index = listing.highlighted
201
+ listing.clear_options()
202
+ for field in self._fields():
203
+ if field.kind == "sep":
204
+ listing.add_option(
205
+ Option(Text(f"── {field.label} ──", style="dim"), id=field.key, disabled=True)
206
+ )
207
+ continue
208
+ if field.kind == "toggle":
209
+ row = Text()
210
+ if self._get(field.key) == "on":
211
+ row.append("[")
212
+ row.append("✓", style="bold green")
213
+ row.append("] ")
214
+ else:
215
+ row.append("[ ] ")
216
+ row.append(field.label)
217
+ listing.add_option(Option(row, id=field.key))
218
+ continue
219
+ row = Text()
220
+ row.append(f"{field.label} ")
221
+ row.append(self._display(field), style="dim")
222
+ listing.add_option(Option(row, id=field.key))
223
+ _add_trailing(listing, "Next →" if self._wizard else "Done", "__done__")
224
+ if index is not None and index < listing.option_count:
225
+ listing.highlighted = index
226
+ _focus_first(listing)
227
+
228
+ # --- interaction ------------------------------------------------------
229
+ def action_forward(self) -> None:
230
+ self._forward()
231
+
232
+ def action_select(self) -> None:
233
+ listing = self.query_one("#fields", OptionList)
234
+ if listing.highlighted is not None:
235
+ self._activate(listing.get_option_at_index(listing.highlighted).id)
236
+
237
+ def _forward(self) -> None:
238
+ if self._wizard:
239
+ self.app.wizard_next()
240
+ else:
241
+ self.action_back()
242
+
243
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
244
+ self._activate(event.option.id)
245
+
246
+ def _activate(self, key: str | None) -> None:
247
+ if key is None:
248
+ return
249
+ if key == "__done__":
250
+ self._forward()
251
+ return
252
+ field = self._field(key)
253
+ if field.kind == "sep":
254
+ return
255
+ if field.kind == "toggle":
256
+ self._set(key, "off" if self._get(key) == "on" else "on")
257
+ self._populate()
258
+ return
259
+ if field.kind == "enum":
260
+ cur = self._get(key)
261
+ idx = (field.choices.index(cur) + 1) % len(field.choices) if cur in field.choices else 0
262
+ self._set(key, field.choices[idx])
263
+ self._populate()
264
+ return
265
+ self._open_editor(field)
266
+
267
+ def _open_editor(self, field: Field) -> None:
268
+ self._editing = field.key
269
+ editor = self.query_one("#editor", Input)
270
+ editor.password = field.kind == "secret"
271
+ editor.value = self._get(field.key)
272
+ editor.display = True
273
+ note = _SECRET_NOTE if field.kind == "secret" else field.help
274
+ self.query_one("#status", Static).update(note)
275
+ self.query_one("#hint", Static).update("enter save · esc cancel")
276
+ editor.focus()
277
+
278
+ def _close_editor(self) -> None:
279
+ self._editing = None
280
+ editor = self.query_one("#editor", Input)
281
+ editor.display = False
282
+ editor.value = ""
283
+ self.query_one("#status", Static).update("")
284
+ self.query_one("#hint", Static).update(self._hint())
285
+ self._populate()
286
+ self.query_one("#fields", OptionList).focus()
287
+
288
+ def on_input_submitted(self, event: Input.Submitted) -> None:
289
+ if self._editing is None:
290
+ return
291
+ key = self._editing
292
+ error = self._validate(key, event.value)
293
+ if error:
294
+ self.query_one("#status", Static).update(f"⚠ {error}")
295
+ return
296
+ self._set(key, event.value)
297
+ self._close_editor()
298
+
299
+ def action_back(self) -> None:
300
+ if self._editing is not None:
301
+ self._close_editor()
302
+ return
303
+ if len(self.app.screen_stack) > 1:
304
+ self.app.pop_screen()
305
+
306
+
307
+ def _logo() -> Text:
308
+ """Broadcast ripples rising from a source dot, with a detected-change dot.
309
+
310
+ An ASCII echo of assets/logo.svg: green monitoring waves, orange change.
311
+ """
312
+ green = "bold #3ddc84"
313
+ orange = "bold #ff7043"
314
+ logo = Text()
315
+ logo.append(" .·°°°°°°°°°·.", style=green)
316
+ logo.append(" ")
317
+ logo.append("●\n", style=orange)
318
+ logo.append(" ·°°°°°·\n", style=green)
319
+ logo.append(" ·°·\n", style=green)
320
+ logo.append(" ●", style=green)
321
+ return logo
322
+
323
+
324
+ class WelcomeScreen(_Nav):
325
+ """First-run splash: logo, tagline, and a prompt to begin."""
326
+
327
+ BINDINGS = [
328
+ Binding("enter", "begin", "begin", show=False),
329
+ Binding("right", "begin", "begin", show=False),
330
+ Binding("space", "begin", "begin", show=False),
331
+ ]
332
+
333
+ def compose(self) -> ComposeResult:
334
+ yield Center(Static(_logo(), id="logo"))
335
+ yield Static(Text("android-watcher", justify="center", style="bold"), id="title")
336
+ yield Static(
337
+ Text("Watch Google's Android sites. Get an AI-triaged digest", justify="center"),
338
+ id="help",
339
+ )
340
+ yield Static("press enter to begin", id="hint")
341
+ yield _quit_hint()
342
+
343
+ def action_begin(self) -> None:
344
+ self.app.wizard_next()
345
+
346
+ def action_back(self) -> None:
347
+ return
348
+
349
+
350
+ class SourcesGateScreen(_Nav):
351
+ """Wizard gate: show the selected-source count and offer to edit, or move on."""
352
+
353
+ BINDINGS = [
354
+ Binding("right", "forward", "forward", show=False),
355
+ Binding("space", "select", "select", show=False),
356
+ ]
357
+
358
+ def __init__(self, config: Config) -> None:
359
+ super().__init__()
360
+ self._config = config
361
+
362
+ def compose(self) -> ComposeResult:
363
+ yield from _heading("Sources", "")
364
+ yield OptionList(id="gate")
365
+ yield Static("↑/↓ move · enter select · → next", id="hint")
366
+ yield _quit_hint()
367
+
368
+ def on_mount(self) -> None:
369
+ self._refresh()
370
+
371
+ def on_screen_resume(self) -> None:
372
+ self._refresh()
373
+
374
+ def _refresh(self) -> None:
375
+ count = watched_count(self._config)
376
+ self.query_one("#help", Static).update(Text(f"{count} sources selected", justify="center"))
377
+ listing = self.query_one("#gate", OptionList)
378
+ index = listing.highlighted
379
+ listing.clear_options()
380
+ listing.add_option(Option(_bold("Review / edit sources"), id="edit"))
381
+ listing.add_option(Option(Text(" "), id="__sp__", disabled=True))
382
+ listing.add_option(Option(_bold("Next →"), id="next"))
383
+ if index is not None and index < listing.option_count:
384
+ listing.highlighted = index
385
+ _focus_first(listing)
386
+
387
+ def action_forward(self) -> None:
388
+ self.app.wizard_next()
389
+
390
+ def action_select(self) -> None:
391
+ listing = self.query_one("#gate", OptionList)
392
+ if listing.highlighted is not None:
393
+ self._activate(listing.get_option_at_index(listing.highlighted).id)
394
+
395
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
396
+ self._activate(event.option.id)
397
+
398
+ def _activate(self, option_id: str | None) -> None:
399
+ if option_id == "edit":
400
+ self.app.push_screen(SourcesScreen(self._config))
401
+ elif option_id == "next":
402
+ self.app.wizard_next()
403
+
404
+
405
+ class MainMenuScreen(_Nav):
406
+ """Top-level pointer menu (reconfigure mode): pick a section, or save and exit."""
407
+
408
+ BINDINGS = [
409
+ Binding("right", "forward", "forward", show=False),
410
+ Binding("space", "forward", "forward", show=False),
411
+ ]
412
+
413
+ def __init__(self, config: Config) -> None:
414
+ super().__init__()
415
+ self._config = config
416
+
417
+ def compose(self) -> ComposeResult:
418
+ yield from _heading("android-watcher", "Configure what to watch and where digests go")
419
+ yield OptionList(id="menu")
420
+ yield Static("↑/↓ move · enter open", id="hint")
421
+ yield _quit_hint()
422
+
423
+ def on_mount(self) -> None:
424
+ self._refresh()
425
+
426
+ def on_screen_resume(self) -> None:
427
+ self._refresh()
428
+
429
+ def action_back(self) -> None:
430
+ return
431
+
432
+ def _summaries(self) -> list[tuple[str, str, str]]:
433
+ c = self._config
434
+ channels = [
435
+ name for name, ch in (("slack", c.slack), ("telegram", c.telegram)) if ch.enabled
436
+ ]
437
+ sched = c.schedule
438
+ when = sched.cron if sched.interval == "cron" else f"{sched.interval} {sched.at}".strip()
439
+ return [
440
+ ("sources", "Sources", f"{watched_count(c)} watched"),
441
+ ("schedule", "Schedule", when),
442
+ ("ai", "AI & Digest", f"{c.ai.mode} · max {c.digest.max_items}"),
443
+ ("channels", "Channels", ", ".join(channels) or "none"),
444
+ ("save", "Save & Exit", ""),
445
+ ]
446
+
447
+ def _refresh(self) -> None:
448
+ menu = self.query_one("#menu", OptionList)
449
+ index = menu.highlighted
450
+ menu.clear_options()
451
+ for oid, name, summary in self._summaries():
452
+ row = Text()
453
+ row.append(f"{name:<14}")
454
+ if summary:
455
+ row.append(summary, style="dim")
456
+ menu.add_option(Option(row, id=oid))
457
+ if index is not None:
458
+ menu.highlighted = index
459
+ _focus_first(menu)
460
+
461
+ def action_forward(self) -> None:
462
+ menu = self.query_one("#menu", OptionList)
463
+ if menu.highlighted is not None:
464
+ self._activate(menu.get_option_at_index(menu.highlighted).id)
465
+
466
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
467
+ self._activate(event.option.id)
468
+
469
+ def _activate(self, option_id: str | None) -> None:
470
+ match option_id:
471
+ case "sources":
472
+ self.app.push_screen(SourcesScreen(self._config))
473
+ case "schedule":
474
+ self.app.push_screen(ScheduleScreen(self._config))
475
+ case "ai":
476
+ self.app.push_screen(AIScreen(self._config))
477
+ case "channels":
478
+ self.app.push_screen(ChannelsScreen(self._config))
479
+ case "save":
480
+ self.action_save()
481
+
482
+ def action_save(self) -> list[str]:
483
+ return self.app.save_and_exit()
484
+
485
+
486
+ class SourcesScreen(_Nav):
487
+ """Pointer list of sources: space toggles, enter/right moves on, 'a' adds."""
488
+
489
+ BINDINGS = [
490
+ Binding("space", "toggle", "toggle", show=False),
491
+ Binding("a", "toggle_all", "toggle all", show=False),
492
+ Binding("right", "forward", "forward", show=False),
493
+ ]
494
+
495
+ def __init__(self, config: Config, *, wizard: bool = False) -> None:
496
+ super().__init__()
497
+ self._config = config
498
+ self._wizard = wizard
499
+ self.enabled_ids: set[str] = resolve_enabled_ids(config) | {
500
+ s.id for s in config.custom_sources
501
+ }
502
+ self._by_id: dict[str, Source] = {}
503
+
504
+ def _all_sources(self) -> list[Source]:
505
+ return [*load_catalog(), *self._config.custom_sources]
506
+
507
+ def _row(self, src: Source) -> Text:
508
+ on = src.id in self.enabled_ids
509
+ row = Text()
510
+ if on:
511
+ row.append("[")
512
+ row.append("✓", style="bold green")
513
+ row.append("] ")
514
+ else:
515
+ row.append("[ ] ")
516
+ row.append(src.name)
517
+ row.append(f" {src.url}", style="dim")
518
+ return row
519
+
520
+ def compose(self) -> ComposeResult:
521
+ yield Static(Text("Sources", justify="center", style="bold"), id="title")
522
+ yield Static(Text("", justify="center"), id="help")
523
+ yield OptionList(id="src-list")
524
+ hint = (
525
+ "↑/↓ move · space toggle · a all · → next"
526
+ if self._wizard
527
+ else "↑/↓ move · space toggle · a all · esc back"
528
+ )
529
+ yield Static(hint, id="hint")
530
+ yield _quit_hint()
531
+
532
+ def on_mount(self) -> None:
533
+ self._populate()
534
+
535
+ def on_screen_resume(self) -> None:
536
+ self.enabled_ids |= {s.id for s in self._config.custom_sources}
537
+ self._populate()
538
+
539
+ def _populate(self) -> None:
540
+ listing = self.query_one("#src-list", OptionList)
541
+ index = listing.highlighted
542
+ listing.clear_options()
543
+ self._by_id = {}
544
+ for src in self._all_sources():
545
+ self._by_id[src.id] = src
546
+ listing.add_option(Option(self._row(src), id=src.id))
547
+ _add_trailing(listing, "Next →" if self._wizard else "Done", "__done__")
548
+ if index is not None and index < listing.option_count:
549
+ listing.highlighted = index
550
+ _focus_first(listing)
551
+ self._update_count()
552
+
553
+ def _update_count(self) -> None:
554
+ total = len(self._by_id)
555
+ selected = sum(1 for sid in self._by_id if sid in self.enabled_ids)
556
+ self.query_one("#help", Static).update(
557
+ Text(f"{selected} of {total} selected", justify="center")
558
+ )
559
+
560
+ def _toggle(self, sid: str | None) -> None:
561
+ if sid is None or sid not in self._by_id:
562
+ return
563
+ if sid in self.enabled_ids:
564
+ self.enabled_ids.discard(sid)
565
+ else:
566
+ self.enabled_ids.add(sid)
567
+ listing = self.query_one("#src-list", OptionList)
568
+ listing.replace_option_prompt(sid, self._row(self._by_id[sid]))
569
+ self._update_count()
570
+
571
+ def action_toggle(self) -> None:
572
+ listing = self.query_one("#src-list", OptionList)
573
+ if listing.highlighted is not None:
574
+ self._toggle(listing.get_option_at_index(listing.highlighted).id)
575
+
576
+ def action_toggle_all(self) -> None:
577
+ all_ids = set(self._by_id)
578
+ if all_ids and all_ids <= self.enabled_ids:
579
+ self.enabled_ids -= all_ids
580
+ else:
581
+ self.enabled_ids |= all_ids
582
+ self._populate()
583
+
584
+ def action_forward(self) -> None:
585
+ self._forward()
586
+
587
+ def _forward(self) -> None:
588
+ if self._wizard:
589
+ self.app.wizard_next()
590
+ else:
591
+ self.action_back()
592
+
593
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
594
+ self._forward()
595
+
596
+ def apply_to_config(self) -> None:
597
+ catalog_ids = {s.id for s in load_catalog()}
598
+ chosen = set(self.enabled_ids)
599
+ if not (chosen & catalog_ids):
600
+ chosen = (chosen - {NONE_SENTINEL}) | {NONE_SENTINEL}
601
+ self._config.enabled_source_ids = chosen
602
+
603
+ def on_screen_suspend(self) -> None:
604
+ self.apply_to_config()
605
+
606
+
607
+ class ScheduleScreen(FieldMenuScreen):
608
+ """Configure when android-watcher checks for changes."""
609
+
610
+ TITLE = "Schedule"
611
+ HELP = "How often android-watcher checks the sites for changes"
612
+
613
+ def _fields(self) -> list[Field]:
614
+ s = self._config.schedule
615
+ fields = [
616
+ Field(
617
+ "interval",
618
+ "Interval",
619
+ "enum",
620
+ ("daily", "weekly", "cron"),
621
+ help="How frequently to run?",
622
+ )
623
+ ]
624
+ if s.interval == "cron":
625
+ fields.append(
626
+ Field("cron", "Cron expression", "text", help="5 fields, e.g. '0 9 * * 1-5'")
627
+ )
628
+ return fields
629
+ if s.interval == "weekly":
630
+ for wd, label in _WEEKDAYS:
631
+ fields.append(Field(f"day_{wd}", label, "toggle"))
632
+ fields.append(
633
+ Field(
634
+ "at",
635
+ "Times (24h HH:MM)",
636
+ "text",
637
+ help="One or more, comma-separated, e.g. 09:00,18:30",
638
+ )
639
+ )
640
+ return fields
641
+
642
+ def _slot(self, key: str) -> tuple[object, str]:
643
+ return (self._config.schedule, key)
644
+
645
+ def _days(self) -> set[str]:
646
+ return {d.strip().lower()[:3] for d in self._config.schedule.days.split(",") if d.strip()}
647
+
648
+ def _get(self, key: str) -> str:
649
+ if key.startswith("day_"):
650
+ return "on" if key[4:] in self._days() else "off"
651
+ return super()._get(key)
652
+
653
+ def _set(self, key: str, value: str) -> None:
654
+ if key.startswith("day_"):
655
+ days = self._days()
656
+ if value == "on":
657
+ days.add(key[4:])
658
+ else:
659
+ days.discard(key[4:])
660
+ self._config.schedule.days = ",".join(wd for wd, _ in _WEEKDAYS if wd in days)
661
+ return
662
+ super()._set(key, value)
663
+
664
+ def _after_set(self) -> None:
665
+ # Keep cron and interval consistent so save never trips the cross-check.
666
+ if self._config.schedule.interval != "cron":
667
+ self._config.schedule.cron = ""
668
+
669
+ def _validate(self, key: str, value: str) -> str | None:
670
+ if key == "at":
671
+ parts = [p.strip() for p in value.split(",") if p.strip()]
672
+ if not parts:
673
+ return "Enter at least one time."
674
+ bad = [p for p in parts if not re.match(r"^([01]\d|2[0-3]):[0-5]\d$", p)]
675
+ if bad:
676
+ return f"Use 24-hour HH:MM: {', '.join(bad)}"
677
+ if key == "cron" and len(value.split()) != 5:
678
+ return "Cron needs 5 space-separated fields"
679
+ return None
680
+
681
+
682
+ class AIScreen(FieldMenuScreen):
683
+ """Configure AI triage and digest settings."""
684
+
685
+ TITLE = "AI & Digest"
686
+ HELP = "Claude reads each change and writes the digest summary"
687
+
688
+ def __init__(self, config: Config, *, wizard: bool = False) -> None:
689
+ super().__init__(config, wizard=wizard)
690
+ if config.ai.model not in ("sonnet", "opus"):
691
+ config.ai.model = "sonnet"
692
+
693
+ def _fields(self) -> list[Field]:
694
+ mode_help = "'off' skips summaries"
695
+ fields = [Field("mode", "AI triage", "enum", ("claude_cli", "off"), help=mode_help)]
696
+ if self._config.ai.mode == "claude_cli":
697
+ fields.append(
698
+ Field(
699
+ "model",
700
+ "Model",
701
+ "enum",
702
+ ("sonnet", "opus"),
703
+ help="sonnet is faster and cheaper; opus is most capable",
704
+ )
705
+ )
706
+ max_help = "Cap total digest items (1-50)"
707
+ fields.append(Field("max", "Max digest items", "int", help=max_help))
708
+ empty_help = "Send 'nothing notable' or skip"
709
+ fields.append(Field("empty", "Empty digest", "enum", ("send", "skip"), help=empty_help))
710
+ return fields
711
+
712
+ def _slot(self, key: str) -> tuple[object, str]:
713
+ match key:
714
+ case "mode":
715
+ return (self._config.ai, "mode")
716
+ case "model":
717
+ return (self._config.ai, "model")
718
+ case "max":
719
+ return (self._config.digest, "max_items")
720
+ case _:
721
+ return (self._config.digest, "empty")
722
+
723
+ def _validate(self, key: str, value: str) -> str | None:
724
+ if key == "max":
725
+ try:
726
+ n = int(value)
727
+ except ValueError:
728
+ return "Enter a whole number"
729
+ if not (1 <= n <= 50):
730
+ return "Must be between 1 and 50"
731
+ return None
732
+
733
+
734
+ class ChannelsScreen(_Nav):
735
+ """Channel hub: tick channels on/off, configure each, then move on."""
736
+
737
+ BINDINGS = [
738
+ Binding("space", "toggle", "toggle", show=False),
739
+ Binding("right", "forward", "forward", show=False),
740
+ ]
741
+
742
+ def __init__(self, config: Config, *, wizard: bool = False) -> None:
743
+ super().__init__()
744
+ self._config = config
745
+ self._wizard = wizard
746
+
747
+ def _channels(self):
748
+ c = self._config
749
+ return (("slack", "Slack", c.slack), ("telegram", "Telegram", c.telegram))
750
+
751
+ def compose(self) -> ComposeResult:
752
+ yield from _heading("Channels", "Where digests are delivered")
753
+ yield OptionList(id="ch-list")
754
+ hint = (
755
+ "↑/↓ move · space on/off · enter configure · → next"
756
+ if self._wizard
757
+ else "↑/↓ move · space on/off · enter configure · esc back"
758
+ )
759
+ yield Static(hint, id="hint")
760
+ yield _quit_hint()
761
+
762
+ def on_mount(self) -> None:
763
+ self._populate()
764
+
765
+ def on_screen_resume(self) -> None:
766
+ self._populate()
767
+
768
+ def _populate(self) -> None:
769
+ listing = self.query_one("#ch-list", OptionList)
770
+ index = listing.highlighted
771
+ listing.clear_options()
772
+ for cid, name, ch in self._channels():
773
+ row = Text()
774
+ row.append("[x] " if ch.enabled else "[ ] ")
775
+ row.append(name)
776
+ listing.add_option(Option(row, id=cid))
777
+ _add_trailing(listing, "Next →" if self._wizard else "Done", "__done__")
778
+ if index is not None and index < listing.option_count:
779
+ listing.highlighted = index
780
+ _focus_first(listing)
781
+
782
+ def action_toggle(self) -> None:
783
+ listing = self.query_one("#ch-list", OptionList)
784
+ if listing.highlighted is None:
785
+ return
786
+ cid = listing.get_option_at_index(listing.highlighted).id
787
+ for oid, _name, ch in self._channels():
788
+ if oid == cid:
789
+ ch.enabled = not ch.enabled
790
+ self._populate()
791
+ return
792
+
793
+ def action_forward(self) -> None:
794
+ self._forward()
795
+
796
+ def _forward(self) -> None:
797
+ if self._wizard:
798
+ self.app.wizard_next()
799
+ else:
800
+ self.action_back()
801
+
802
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
803
+ self._activate(event.option.id)
804
+
805
+ def _activate(self, option_id: str | None) -> None:
806
+ match option_id:
807
+ case "slack":
808
+ self.app.push_screen(SlackScreen(self._config))
809
+ case "telegram":
810
+ self.app.push_screen(TelegramScreen(self._config))
811
+ case "__done__":
812
+ self._forward()
813
+
814
+
815
+ class _ChannelScreen(FieldMenuScreen):
816
+ """A per-channel field menu; editing any field enables the channel."""
817
+
818
+ HELP = "Tokens are saved securely"
819
+
820
+ def _channel(self):
821
+ raise NotImplementedError
822
+
823
+ def _slot(self, key: str) -> tuple[object, str]:
824
+ return (self._channel(), key)
825
+
826
+ def _after_set(self) -> None:
827
+ self._channel().enabled = True
828
+
829
+
830
+ class SlackScreen(_ChannelScreen):
831
+ TITLE = "Slack"
832
+
833
+ def _channel(self):
834
+ return self._config.slack
835
+
836
+ def _fields(self) -> list[Field]:
837
+ return [
838
+ Field(
839
+ "bot_token",
840
+ "Bot token",
841
+ "secret",
842
+ help="Bot token (scopes: chat:write, files:write)",
843
+ ),
844
+ Field(
845
+ "channel",
846
+ "Channels / DMs",
847
+ "text",
848
+ help="Comma-separated: #channel or a user id (Uxxxx) for a DM",
849
+ ),
850
+ ]
851
+
852
+
853
+ class TelegramScreen(_ChannelScreen):
854
+ TITLE = "Telegram"
855
+
856
+ def _channel(self):
857
+ return self._config.telegram
858
+
859
+ def _fields(self) -> list[Field]:
860
+ return [
861
+ Field("bot_token", "Bot token", "secret", help="From @BotFather"),
862
+ Field("chat_id", "Chat IDs", "text", help="Comma-separated user or group chat ids"),
863
+ ]
864
+
865
+
866
+ class ReviewScreen(_Nav):
867
+ """Final wizard step: review every choice, show where it saves, then save."""
868
+
869
+ BINDINGS = [
870
+ Binding("right", "save", "save", show=False),
871
+ Binding("space", "save", "save", show=False),
872
+ ]
873
+
874
+ def __init__(self, config: Config, config_path: str) -> None:
875
+ super().__init__()
876
+ self._config = config
877
+ self._config_path = config_path
878
+
879
+ def compose(self) -> ComposeResult:
880
+ yield from _heading("Review", "Confirm your configuration, then save")
881
+ yield OptionList(id="review")
882
+ yield Static("enter to save · esc back", id="hint")
883
+ yield _quit_hint()
884
+
885
+ def on_mount(self) -> None:
886
+ self._refresh()
887
+
888
+ def on_screen_resume(self) -> None:
889
+ self._refresh()
890
+
891
+ def _summary_lines(self) -> list[str]:
892
+ c = self._config
893
+ s = c.schedule
894
+ if s.interval == "cron":
895
+ when = f"cron {s.cron}"
896
+ elif s.interval == "weekly":
897
+ chosen = {d.strip().lower()[:3] for d in s.days.split(",") if d.strip()}
898
+ days = ", ".join(lbl for wd, lbl in _WEEKDAYS if wd in chosen) or "Monday"
899
+ when = f"weekly on {days} at {s.at}"
900
+ else:
901
+ when = f"daily at {s.at}"
902
+ ai = "off" if c.ai.mode == "off" else f"claude ({c.ai.model})"
903
+ channels = [n for n, ch in (("slack", c.slack), ("telegram", c.telegram)) if ch.enabled]
904
+ channels_str = ", ".join(channels) if channels else "none — pick one to finish!"
905
+ return [
906
+ f"Sources {watched_count(c)} selected",
907
+ f"Schedule {when}",
908
+ f"AI {ai} · max {c.digest.max_items} · empty: {c.digest.empty}",
909
+ f"Channels {channels_str}",
910
+ f"Saved to {self._config_path}",
911
+ ]
912
+
913
+ def _refresh(self) -> None:
914
+ listing = self.query_one("#review", OptionList)
915
+ listing.clear_options()
916
+ for line in self._summary_lines():
917
+ listing.add_option(Option(Text(line, style="dim"), disabled=True))
918
+ listing.add_option(Option(Text(" "), id="__sp__", disabled=True))
919
+ listing.add_option(Option(_bold("Save & finish"), id="save"))
920
+ _focus_first(listing)
921
+
922
+ def action_save(self) -> None:
923
+ self.app.save_and_exit()
924
+
925
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
926
+ if event.option.id == "save":
927
+ self.action_save()