stage-cli 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 (170) hide show
  1. stage/__init__.py +1 -0
  2. stage/__main__.py +8 -0
  3. stage/banner.py +32 -0
  4. stage/bootstrap/__init__.py +0 -0
  5. stage/bootstrap/openjobs.py +392 -0
  6. stage/classify/__init__.py +29 -0
  7. stage/classify/eligibility.py +115 -0
  8. stage/classify/internship.py +64 -0
  9. stage/classify/role.py +91 -0
  10. stage/classify/scope.py +47 -0
  11. stage/cli/__init__.py +0 -0
  12. stage/cli/app.py +4 -0
  13. stage/cli/commands/__init__.py +8 -0
  14. stage/cli/commands/discovery.py +294 -0
  15. stage/cli/commands/insight.py +494 -0
  16. stage/cli/commands/pipeline.py +337 -0
  17. stage/cli/commands/postings.py +473 -0
  18. stage/cli/commands/schedule.py +171 -0
  19. stage/cli/housekeeping.py +64 -0
  20. stage/cli/logfile.py +56 -0
  21. stage/cli/notify.py +170 -0
  22. stage/cli/options.py +678 -0
  23. stage/cli/render.py +1398 -0
  24. stage/cli/runlock.py +74 -0
  25. stage/cli/schedule.py +702 -0
  26. stage/cli/schedule_state.py +363 -0
  27. stage/cli/selection.py +83 -0
  28. stage/cli/serialize.py +196 -0
  29. stage/companies.py +542 -0
  30. stage/data/companies/a.yaml +1289 -0
  31. stage/data/companies/b.yaml +900 -0
  32. stage/data/companies/c.yaml +1377 -0
  33. stage/data/companies/d.yaml +497 -0
  34. stage/data/companies/e.yaml +519 -0
  35. stage/data/companies/f.yaml +454 -0
  36. stage/data/companies/g.yaml +601 -0
  37. stage/data/companies/h.yaml +446 -0
  38. stage/data/companies/i.yaml +503 -0
  39. stage/data/companies/j.yaml +138 -0
  40. stage/data/companies/k.yaml +278 -0
  41. stage/data/companies/l.yaml +402 -0
  42. stage/data/companies/m.yaml +937 -0
  43. stage/data/companies/n.yaml +549 -0
  44. stage/data/companies/o.yaml +371 -0
  45. stage/data/companies/other.yaml +58 -0
  46. stage/data/companies/p.yaml +825 -0
  47. stage/data/companies/q.yaml +121 -0
  48. stage/data/companies/r.yaml +583 -0
  49. stage/data/companies/s.yaml +1140 -0
  50. stage/data/companies/t.yaml +817 -0
  51. stage/data/companies/u.yaml +196 -0
  52. stage/data/companies/v.yaml +325 -0
  53. stage/data/companies/w.yaml +353 -0
  54. stage/data/companies/x.yaml +67 -0
  55. stage/data/companies/y.yaml +36 -0
  56. stage/data/companies/z.yaml +146 -0
  57. stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
  58. stage/data/fonts/DejaVuSans.ttf +0 -0
  59. stage/data/lexicon/company_tokens.yaml +228 -0
  60. stage/data/lexicon/eligibility.yaml +455 -0
  61. stage/data/lexicon/inclusive_suffixes.yaml +37 -0
  62. stage/data/lexicon/internship.yaml +187 -0
  63. stage/data/lexicon/language.yaml +226 -0
  64. stage/data/lexicon/locations.yaml +1159 -0
  65. stage/data/lexicon/roles.yaml +2012 -0
  66. stage/data/lexicon/terms.yaml +76 -0
  67. stage/data/lexicon/workday_facets.yaml +27 -0
  68. stage/data/seed_companies.yaml +198 -0
  69. stage/dedup/__init__.py +19 -0
  70. stage/dedup/identity.py +113 -0
  71. stage/dedup/resolve.py +97 -0
  72. stage/domain/__init__.py +244 -0
  73. stage/domain/company.py +49 -0
  74. stage/domain/coverage.py +86 -0
  75. stage/domain/custom_board.py +92 -0
  76. stage/domain/discovery.py +94 -0
  77. stage/domain/enums.py +114 -0
  78. stage/domain/events.py +204 -0
  79. stage/domain/filters.py +27 -0
  80. stage/domain/health.py +169 -0
  81. stage/domain/ids.py +48 -0
  82. stage/domain/job.py +47 -0
  83. stage/domain/matching.py +15 -0
  84. stage/domain/priority.py +34 -0
  85. stage/domain/quarantine.py +39 -0
  86. stage/domain/rate_state.py +78 -0
  87. stage/domain/retention.py +20 -0
  88. stage/domain/rotation.py +46 -0
  89. stage/domain/signals.py +12 -0
  90. stage/domain/sync_run.py +35 -0
  91. stage/domain/text.py +113 -0
  92. stage/domain/validator.py +14 -0
  93. stage/domain/visits.py +60 -0
  94. stage/domain/workday.py +38 -0
  95. stage/http/__init__.py +58 -0
  96. stage/http/breaker.py +53 -0
  97. stage/http/cache.py +44 -0
  98. stage/http/client.py +725 -0
  99. stage/http/profiles.py +101 -0
  100. stage/lexicon.py +370 -0
  101. stage/normalize/__init__.py +16 -0
  102. stage/normalize/language.py +47 -0
  103. stage/normalize/location.py +271 -0
  104. stage/normalize/terms.py +153 -0
  105. stage/normalize/urls.py +122 -0
  106. stage/paths.py +86 -0
  107. stage/py.typed +0 -0
  108. stage/services/__init__.py +0 -0
  109. stage/services/canary.py +120 -0
  110. stage/services/coverage.py +231 -0
  111. stage/services/discover.py +747 -0
  112. stage/services/export.py +274 -0
  113. stage/services/health.py +237 -0
  114. stage/services/maintenance.py +225 -0
  115. stage/services/quarantine.py +20 -0
  116. stage/services/query.py +86 -0
  117. stage/services/sync.py +1257 -0
  118. stage/sources/__init__.py +82 -0
  119. stage/sources/_text.py +79 -0
  120. stage/sources/ashby.py +93 -0
  121. stage/sources/bamboohr.py +80 -0
  122. stage/sources/base.py +225 -0
  123. stage/sources/breezy.py +90 -0
  124. stage/sources/collage.py +60 -0
  125. stage/sources/community_feeds.py +142 -0
  126. stage/sources/curated_markdown.py +289 -0
  127. stage/sources/custom_json.py +610 -0
  128. stage/sources/espresso.py +154 -0
  129. stage/sources/feed.py +44 -0
  130. stage/sources/greenhouse.py +104 -0
  131. stage/sources/jobbank.py +147 -0
  132. stage/sources/jobvite.py +133 -0
  133. stage/sources/lever.py +76 -0
  134. stage/sources/oracle_cloud.py +187 -0
  135. stage/sources/platforms.py +609 -0
  136. stage/sources/quebec_emploi.py +146 -0
  137. stage/sources/recruitee.py +96 -0
  138. stage/sources/simplify.py +110 -0
  139. stage/sources/smartrecruiters.py +216 -0
  140. stage/sources/speedyapply.py +200 -0
  141. stage/sources/themuse.py +157 -0
  142. stage/sources/workable.py +83 -0
  143. stage/sources/workday.py +524 -0
  144. stage/sources/zshah.py +99 -0
  145. stage/storage/__init__.py +29 -0
  146. stage/storage/migrations/0001_initial.sql +239 -0
  147. stage/storage/migrations/__init__.py +135 -0
  148. stage/storage/repository.py +213 -0
  149. stage/storage/search.py +28 -0
  150. stage/storage/sqlite_repo.py +1586 -0
  151. stage/storage/writer.py +249 -0
  152. stage/tui/__init__.py +0 -0
  153. stage/tui/app.py +82 -0
  154. stage/tui/help.py +26 -0
  155. stage/tui/safe.py +21 -0
  156. stage/tui/screens/__init__.py +0 -0
  157. stage/tui/screens/boards.py +186 -0
  158. stage/tui/screens/postings.py +509 -0
  159. stage/tui/screens/review.py +209 -0
  160. stage/tui/screens/splash.py +37 -0
  161. stage/tui/screens/stats.py +124 -0
  162. stage/tui/screens/sync.py +194 -0
  163. stage/tui/state.py +160 -0
  164. stage/tui/theme.tcss +205 -0
  165. stage/tui/widgets/__init__.py +0 -0
  166. stage_cli-1.0.0.dist-info/METADATA +379 -0
  167. stage_cli-1.0.0.dist-info/RECORD +170 -0
  168. stage_cli-1.0.0.dist-info/WHEEL +4 -0
  169. stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
  170. stage_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,509 @@
1
+ from dataclasses import replace
2
+ from datetime import UTC, datetime
3
+ from typing import TYPE_CHECKING
4
+
5
+ from textual import work
6
+ from textual.app import ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import Horizontal
9
+ from textual.screen import Screen
10
+ from textual.timer import Timer
11
+ from textual.widgets import DataTable, Footer, Header, Input, OptionList, Static
12
+
13
+ from stage.domain import DEFAULT_WINDOW_DAYS, ExportFormat
14
+ from stage.domain.text import sanitize
15
+ from stage.normalize.location import display_location
16
+ from stage.tui.help import HelpOverlay
17
+ from stage.tui.safe import cell, quoted, told
18
+ from stage.tui.state import (
19
+ DEBOUNCE_SECONDS,
20
+ PAGE_SIZE,
21
+ FilterState,
22
+ filter_rows,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from stage.domain import Job
27
+ from stage.storage import AsyncRepository
28
+
29
+ COLUMNS = ("#", "Age", "Company", "Title", "Location")
30
+
31
+ HELP_TEXT = """[b]Find[/b]
32
+ / search titles and bodies
33
+ f filters, arrows then enter
34
+ c clear filters and search
35
+ r reload from the database
36
+
37
+ [b]Act on a row[/b]
38
+ up down move between postings
39
+ space mark a row
40
+ w expand the description
41
+ o open marked rows, or this one
42
+ e export what you see
43
+ g change the export format
44
+
45
+ [b]How many rows[/b]
46
+ m show 200 more
47
+ M show 200 fewer
48
+
49
+ [b]Other screens[/b]
50
+ y sync
51
+ t statistics
52
+ v review
53
+ b board health
54
+ A about
55
+ ctrl+t change the theme
56
+ q quit
57
+
58
+ [dim]? closes this[/dim]"""
59
+ NARROW = 80
60
+
61
+
62
+ def age_label(first_seen: datetime, now: datetime) -> str:
63
+ days = (now - first_seen).days
64
+ return "new" if days <= 1 else f"{days}d"
65
+
66
+
67
+ class PostingsScreen(HelpOverlay, Screen[None]):
68
+ HELP_TEXT = HELP_TEXT
69
+ BINDINGS = [
70
+ Binding("slash", "search", "search"),
71
+ Binding("f", "filters", "filter"),
72
+ Binding("space", "mark", "mark", show=False),
73
+ Binding("o", "open", "open"),
74
+ Binding("w", "expand", "read", show=False),
75
+ Binding("e", "export", "export"),
76
+ Binding("question_mark", "help", "keys"),
77
+ Binding("c", "clear", "clear", show=False),
78
+ Binding("r", "reload", "reload", show=False),
79
+ Binding("m", "more", "load more", show=False),
80
+ Binding("M", "fewer", "load fewer", show=False),
81
+ Binding("g", "cycle_format", "export format", show=False),
82
+ Binding("t", "stats", "stats", show=False),
83
+ Binding("v", "review", "review", show=False),
84
+ Binding("b", "boards", "boards", show=False),
85
+ Binding("y", "sync", "sync", show=False),
86
+ Binding("A", "about", "about", show=False),
87
+ ]
88
+
89
+ def __init__(self) -> None:
90
+ super().__init__()
91
+ self.state = FilterState()
92
+ self._jobs: tuple[Job, ...] = ()
93
+ self._export_format = "csv"
94
+ self._expanded = False
95
+ self._total = 0
96
+ self._pending: Timer | None = None
97
+ self._marked: set[str] = set()
98
+ self._arming_export = False
99
+ self._rows: tuple[tuple[str, str, str, bool], ...] = ()
100
+
101
+ @property
102
+ def repository(self) -> "AsyncRepository | None":
103
+ from stage.tui.app import StageApp
104
+
105
+ app = self.app
106
+ return app.repository if isinstance(app, StageApp) else None
107
+
108
+ def compose(self) -> ComposeResult:
109
+ yield Header()
110
+ yield Static("", id="chips")
111
+ yield OptionList(id="filters", classes="hidden")
112
+ yield Horizontal(
113
+ Input(placeholder="search titles, employers, descriptions", id="search"),
114
+ id="search-bar",
115
+ )
116
+ yield DataTable(id="results", cursor_type="row", zebra_stripes=False)
117
+ yield Static("", id="detail")
118
+ yield Static("", id="help")
119
+ yield Footer()
120
+
121
+ def on_mount(self) -> None:
122
+ table = self.query_one("#results", DataTable)
123
+ table.add_column("#", width=4)
124
+ table.add_column("Age", width=5)
125
+ table.add_column("Company", width=22)
126
+ table.add_column("Title", width=self._title_width())
127
+ table.add_column("Location", width=18)
128
+ self._render_filters()
129
+ table.focus()
130
+ self.refresh_results()
131
+
132
+ def _title_width(self) -> int:
133
+ fixed = 4 + 5 + 22 + 18 + 12
134
+ return max(24, self.app.size.width - fixed)
135
+
136
+ def _set_busy(self, busy: bool) -> None:
137
+ self.query_one("#chips", Static).set_class(busy, "busy")
138
+ if busy:
139
+ self.query_one("#chips", Static).update("[dim]searching…[/dim]")
140
+
141
+ def _render_chips(self) -> None:
142
+ if self._arming_export:
143
+ self.query_one("#chips", Static).update(
144
+ f"Export every match as [b]{self._export_format}[/b]? "
145
+ "[dim]e to confirm, g for another format, escape to cancel[/dim]"
146
+ )
147
+ return
148
+ parts = [f"[reverse] {quoted(value)} [/reverse]" for _, value in self.state.active]
149
+ if self.state.query:
150
+ parts.insert(0, f'[reverse] "{quoted(self.state.query)}" [/reverse]')
151
+ if self.state.only_new:
152
+ parts.append("[reverse] new [/reverse]")
153
+ if self.state.last_days != DEFAULT_WINDOW_DAYS:
154
+ window = "any date" if self.state.last_days == 0 else f"{self.state.last_days}d"
155
+ parts.append(f"[reverse] {window} [/reverse]")
156
+ shown = " ".join(parts) if parts else "[dim]all postings[/dim]"
157
+ count = (
158
+ f"[b]{len(self._jobs)}[/b] of [b]{self._total}[/b]"
159
+ if len(self._jobs) < self._total
160
+ else f"[b]{self._total}[/b] posting(s)"
161
+ )
162
+ marked = f" [b]{len(self._marked)}[/b] marked" if self._marked else ""
163
+ clear = (
164
+ " [dim]c to clear[/dim]"
165
+ if self.state.active or self.state.query or self._is_narrowed()
166
+ else ""
167
+ )
168
+ self.query_one("#chips", Static).update(f"{shown} {count}{marked}{clear}")
169
+
170
+ def _is_narrowed(self) -> bool:
171
+ return self.state.only_new or self.state.last_days != DEFAULT_WINDOW_DAYS
172
+
173
+ def _render_filters(self) -> None:
174
+ panel = self.query_one("#filters", OptionList)
175
+ highlighted = panel.highlighted
176
+ job = self._selected()
177
+ self._rows = filter_rows(self.state, job.company if job else None)
178
+ panel.clear_options()
179
+ for _, _, label, chosen in self._rows:
180
+ panel.add_option(cell(f"{'[x]' if chosen else '[ ]'} {label}"))
181
+ if highlighted is not None and self._rows:
182
+ panel.highlighted = min(highlighted, len(self._rows) - 1)
183
+
184
+ @work(exclusive=True)
185
+ async def refresh_results(self) -> None:
186
+ from stage.services.query import list_jobs, search_jobs
187
+
188
+ repo = self.repository
189
+ if repo is None:
190
+ return
191
+ self._set_busy(True)
192
+ filters = self.state.as_filters()
193
+ if self.state.only_new:
194
+ since = await repo.previous_sync_at()
195
+ if since is not None:
196
+ filters = replace(filters, first_seen_after=since)
197
+ if self.state.query.strip():
198
+ listing = await search_jobs(
199
+ repo, self.state.query, filters, window_days=self.state.window_days
200
+ )
201
+ else:
202
+ listing = await list_jobs(repo, filters, window_days=self.state.window_days)
203
+ self._jobs = listing.jobs
204
+ self._total = listing.total_matching
205
+ self._fill(listing.jobs)
206
+ self._set_busy(False)
207
+
208
+ def _fill(self, jobs: "tuple[Job, ...]") -> None:
209
+ table = self.query_one("#results", DataTable)
210
+ table.clear()
211
+ now = datetime.now(UTC)
212
+ width = self._title_width()
213
+ for position, job in enumerate(jobs, start=1):
214
+ marker = "*" if job.id in self._marked else " "
215
+ table.add_row(
216
+ cell(f"{marker}{position}"),
217
+ cell(age_label(job.first_seen, now)),
218
+ cell(job.company, 20),
219
+ cell(job.title_raw, width),
220
+ cell(display_location(job.location_raw) or "-", 18),
221
+ )
222
+ self._render_chips()
223
+ self._show_detail()
224
+
225
+ def _selected(self) -> "Job | None":
226
+ table = self.query_one("#results", DataTable)
227
+ row = table.cursor_row
228
+ if not self._jobs or row < 0 or row >= len(self._jobs):
229
+ return None
230
+ return self._jobs[row]
231
+
232
+ def _show_detail(self) -> None:
233
+ from stage.domain.text import summary
234
+
235
+ job = self._selected()
236
+ panel = self.query_one("#detail", Static)
237
+ if job is None:
238
+ panel.update(
239
+ "[dim]Nothing selected. Press [b]/[/b] to search, "
240
+ "[b]f[/b] for filters, [b]?[/b] for help.[/dim]"
241
+ )
242
+ return
243
+ facts = " [dim]|[/dim] ".join(
244
+ quoted(part)
245
+ for part in (
246
+ display_location(job.location_raw) or None,
247
+ job.role.value if job.role.value != "unknown" else None,
248
+ job.term if job.term != "unknown" else None,
249
+ job.language.value.upper() if job.language.value != "unknown" else None,
250
+ age_label(job.first_seen, datetime.now(UTC)),
251
+ )
252
+ if part
253
+ )
254
+ body = quoted(summary(job.description, len(job.description) if self._expanded else 360))
255
+ if not body:
256
+ body = "[dim]This board publishes no description. Press o to open it.[/dim]"
257
+ elif not self._expanded and len(job.description) > 360:
258
+ body = f"{body}[dim] … press w for the full description[/dim]"
259
+ panel.update(
260
+ f"[b]{quoted(job.title_raw)}[/b]\n"
261
+ f"[$blue]{quoted(job.company)}[/$blue] {facts}\n\n{body}"
262
+ )
263
+
264
+ def on_data_table_row_highlighted(self) -> None:
265
+ self._show_detail()
266
+
267
+ def on_input_changed(self, event: Input.Changed) -> None:
268
+ timer = self._pending
269
+ if timer is not None:
270
+ timer.stop()
271
+ self.state.query = event.value
272
+ self.state.limit = PAGE_SIZE
273
+ self._pending = self.set_timer(DEBOUNCE_SECONDS, self.refresh_results)
274
+
275
+ def on_input_submitted(self) -> None:
276
+ self._leave_search()
277
+
278
+ def on_key(self, event: object) -> None:
279
+ if getattr(event, "key", "") != "escape":
280
+ return
281
+ if self.close_help():
282
+ stop = getattr(event, "stop", None)
283
+ if callable(stop):
284
+ stop()
285
+ return
286
+ if self._arming_export:
287
+ self._arming_export = False
288
+ self._render_chips()
289
+ stop = getattr(event, "stop", None)
290
+ if callable(stop):
291
+ stop()
292
+ return
293
+ if not self.query_one("#filters", OptionList).has_class("hidden"):
294
+ self._leave_filters()
295
+ stop = getattr(event, "stop", None)
296
+ if callable(stop):
297
+ stop()
298
+ return
299
+ if not self.query_one("#search-bar").has_class("visible"):
300
+ return
301
+ self._leave_search()
302
+ stop = getattr(event, "stop", None)
303
+ if callable(stop):
304
+ stop()
305
+
306
+ def _leave_search(self) -> None:
307
+ self.query_one("#search-bar").remove_class("visible")
308
+ self.query_one("#results", DataTable).focus()
309
+
310
+ def action_search(self) -> None:
311
+ self.query_one("#search-bar").add_class("visible")
312
+ self.query_one("#search", Input).focus()
313
+
314
+ def action_expand(self) -> None:
315
+ self._expanded = not self._expanded
316
+ self.query_one("#detail").set_class(self._expanded, "expanded")
317
+ self._show_detail()
318
+
319
+ def action_filters(self) -> None:
320
+ panel = self.query_one("#filters", OptionList)
321
+ if panel.has_class("hidden"):
322
+ self.close_help()
323
+ self._render_filters()
324
+ panel.remove_class("hidden")
325
+ if panel.option_count and panel.highlighted is None:
326
+ panel.highlighted = 0
327
+ panel.focus()
328
+ return
329
+ self._leave_filters()
330
+
331
+ def _leave_filters(self) -> None:
332
+ self.query_one("#filters", OptionList).add_class("hidden")
333
+ self.query_one("#results", DataTable).focus()
334
+
335
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
336
+ index = event.option_index
337
+ if index < 0 or index >= len(self._rows):
338
+ return
339
+ name, value, _, _ = self._rows[index]
340
+ self.state.choose(name, value)
341
+ self._render_filters()
342
+ self.refresh_results()
343
+
344
+ def action_clear(self) -> None:
345
+ self.state.clear()
346
+ self._marked.clear()
347
+ self.query_one("#search", Input).value = ""
348
+ self._render_filters()
349
+ self.refresh_results()
350
+
351
+ def action_reload(self) -> None:
352
+ self.refresh_results()
353
+
354
+ def action_more(self) -> None:
355
+ if len(self._jobs) >= self._total:
356
+ told(self, "Every match is already listed.", "warning")
357
+ return
358
+ self.state.widen()
359
+ self.refresh_results()
360
+
361
+ def action_fewer(self) -> None:
362
+ if self.state.show_all:
363
+ self.state.show_all = False
364
+ self.state.limit = PAGE_SIZE
365
+ self.refresh_results()
366
+ return
367
+ if not self.state.narrow():
368
+ return
369
+ self.refresh_results()
370
+
371
+ def action_mark(self) -> None:
372
+ job = self._selected()
373
+ if job is None:
374
+ told(self, "Nothing selected.", "warning")
375
+ return
376
+ table = self.query_one("#results", DataTable)
377
+ row = table.cursor_row
378
+ if job.id in self._marked:
379
+ self._marked.discard(job.id)
380
+ else:
381
+ self._marked.add(job.id)
382
+ self._fill(self._jobs)
383
+ if 0 <= row < len(self._jobs):
384
+ table.move_cursor(row=row)
385
+
386
+ def _open_targets(self) -> "tuple[Job, ...]":
387
+ if self._marked:
388
+ return tuple(job for job in self._jobs if job.id in self._marked)
389
+ job = self._selected()
390
+ return (job,) if job is not None else ()
391
+
392
+ def action_open(self) -> None:
393
+ import webbrowser
394
+
395
+ from stage.domain import web_url
396
+
397
+ targets = self._open_targets()
398
+ if not targets:
399
+ told(self, "Nothing selected.", "warning")
400
+ return
401
+ opened = 0
402
+ refused = 0
403
+ for job in targets:
404
+ url = web_url(job.apply_url_raw)
405
+ if url is None:
406
+ refused += 1
407
+ continue
408
+ if not webbrowser.open(url):
409
+ told(self, "No browser available on this machine.", "warning")
410
+ return
411
+ opened += 1
412
+ if refused:
413
+ self.notify(
414
+ f"{refused} posting(s) had an apply link that is not a plain "
415
+ "http or https address, so they were not opened.",
416
+ severity="error",
417
+ )
418
+ if not opened:
419
+ return
420
+ if opened == 1 and len(targets) == 1:
421
+ told(self, f"Opened {sanitize(targets[0].company)}")
422
+ return
423
+ told(self, f"Opened {opened} posting(s)")
424
+
425
+ def action_cycle_format(self) -> None:
426
+ order = [item.value for item in ExportFormat]
427
+ index = (order.index(self._export_format) + 1) % len(order)
428
+ self._export_format = order[index]
429
+ if self._arming_export:
430
+ self._render_chips()
431
+ return
432
+ told(self, f"Export format is now {self._export_format}")
433
+
434
+ def action_export(self) -> None:
435
+ if not self._jobs:
436
+ told(self, "Nothing to export.", "warning")
437
+ return
438
+ if not self._arming_export:
439
+ self.close_help()
440
+ self._arming_export = True
441
+ self._render_chips()
442
+ return
443
+ self._arming_export = False
444
+ self._render_chips()
445
+ self._write_export()
446
+
447
+ @work(exclusive=True)
448
+ async def _write_export(self) -> None:
449
+ from datetime import UTC, datetime
450
+
451
+ from stage.services.export import ExportError, export_jobs, export_root
452
+
453
+ repo = self.repository
454
+ if repo is None or not self._jobs:
455
+ told(self, "Nothing to export.", "warning")
456
+ return
457
+ fmt = ExportFormat(self._export_format)
458
+ root = export_root()
459
+ stamp = datetime.now(UTC).astimezone().strftime("%Y%m%d-%H%M%S")
460
+ try:
461
+ result = await export_jobs(
462
+ repo,
463
+ self.state.as_filters(),
464
+ fmt=fmt,
465
+ destination=root / f"stage-{stamp}.{fmt.value}",
466
+ force=True,
467
+ window_days=self.state.window_days,
468
+ query=self.state.query,
469
+ )
470
+ except (ExportError, OSError) as exc:
471
+ told(self, f"Could not write the export: {sanitize(str(exc))}", "error")
472
+ return
473
+ told(self, f"Exported {result.count} posting(s) to {sanitize(str(result.path))}")
474
+
475
+ def action_stats(self) -> None:
476
+ from stage.tui.screens.stats import StatsScreen
477
+
478
+ self.app.push_screen(StatsScreen())
479
+
480
+ def action_review(self) -> None:
481
+ from stage.tui.screens.review import ReviewScreen
482
+
483
+ self.app.push_screen(ReviewScreen())
484
+
485
+ def action_boards(self) -> None:
486
+ from stage.tui.screens.boards import BoardsScreen
487
+
488
+ self.app.push_screen(BoardsScreen())
489
+
490
+ def action_sync(self) -> None:
491
+ from stage.tui.screens.sync import SyncScreen
492
+
493
+ self.app.push_screen(SyncScreen())
494
+
495
+ def action_about(self) -> None:
496
+ from stage.tui.screens.splash import SplashScreen
497
+
498
+ app = self.app
499
+ summary = getattr(app, "summary", "")
500
+ app.push_screen(SplashScreen(summary or "Stage", dismiss_after=None))
501
+
502
+ def dismiss_overlays(self) -> None:
503
+ if not self.query_one("#filters", OptionList).has_class("hidden"):
504
+ self._leave_filters()
505
+ if self.query_one("#search-bar").has_class("visible"):
506
+ self._leave_search()
507
+ if self._arming_export:
508
+ self._arming_export = False
509
+ self._render_chips()