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.
- android_watcher/__init__.py +10 -0
- android_watcher/catalog/__init__.py +32 -0
- android_watcher/catalog/catalog.toml +531 -0
- android_watcher/cli.py +161 -0
- android_watcher/config.py +262 -0
- android_watcher/detect/__init__.py +1 -0
- android_watcher/detect/_normalize.py +192 -0
- android_watcher/detect/android_sitemap.py +540 -0
- android_watcher/detect/base.py +14 -0
- android_watcher/detect/content.py +99 -0
- android_watcher/detect/feed.py +135 -0
- android_watcher/detect/sitemap.py +203 -0
- android_watcher/doctor.py +125 -0
- android_watcher/fetch.py +162 -0
- android_watcher/group.py +79 -0
- android_watcher/lock.py +32 -0
- android_watcher/models.py +156 -0
- android_watcher/notify/__init__.py +1 -0
- android_watcher/notify/base.py +21 -0
- android_watcher/notify/email.py +52 -0
- android_watcher/notify/html.py +114 -0
- android_watcher/notify/render.py +239 -0
- android_watcher/notify/slack.py +124 -0
- android_watcher/notify/telegram.py +46 -0
- android_watcher/rank.py +84 -0
- android_watcher/registry.py +38 -0
- android_watcher/run.py +283 -0
- android_watcher/schedule.py +488 -0
- android_watcher/seed/__init__.py +45 -0
- android_watcher/seed/seed.sql.gz +0 -0
- android_watcher/store.py +492 -0
- android_watcher/triage/__init__.py +1 -0
- android_watcher/triage/base.py +25 -0
- android_watcher/triage/claude_cli.py +185 -0
- android_watcher/triage/noop.py +24 -0
- android_watcher/tui/__init__.py +1 -0
- android_watcher/tui/app.py +163 -0
- android_watcher/tui/configio.py +215 -0
- android_watcher/tui/screens.py +927 -0
- android_watcher-1.0.0.dist-info/METADATA +310 -0
- android_watcher-1.0.0.dist-info/RECORD +44 -0
- android_watcher-1.0.0.dist-info/WHEEL +4 -0
- android_watcher-1.0.0.dist-info/entry_points.txt +2 -0
- 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()
|