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.
- stage/__init__.py +1 -0
- stage/__main__.py +8 -0
- stage/banner.py +32 -0
- stage/bootstrap/__init__.py +0 -0
- stage/bootstrap/openjobs.py +392 -0
- stage/classify/__init__.py +29 -0
- stage/classify/eligibility.py +115 -0
- stage/classify/internship.py +64 -0
- stage/classify/role.py +91 -0
- stage/classify/scope.py +47 -0
- stage/cli/__init__.py +0 -0
- stage/cli/app.py +4 -0
- stage/cli/commands/__init__.py +8 -0
- stage/cli/commands/discovery.py +294 -0
- stage/cli/commands/insight.py +494 -0
- stage/cli/commands/pipeline.py +337 -0
- stage/cli/commands/postings.py +473 -0
- stage/cli/commands/schedule.py +171 -0
- stage/cli/housekeeping.py +64 -0
- stage/cli/logfile.py +56 -0
- stage/cli/notify.py +170 -0
- stage/cli/options.py +678 -0
- stage/cli/render.py +1398 -0
- stage/cli/runlock.py +74 -0
- stage/cli/schedule.py +702 -0
- stage/cli/schedule_state.py +363 -0
- stage/cli/selection.py +83 -0
- stage/cli/serialize.py +196 -0
- stage/companies.py +542 -0
- stage/data/companies/a.yaml +1289 -0
- stage/data/companies/b.yaml +900 -0
- stage/data/companies/c.yaml +1377 -0
- stage/data/companies/d.yaml +497 -0
- stage/data/companies/e.yaml +519 -0
- stage/data/companies/f.yaml +454 -0
- stage/data/companies/g.yaml +601 -0
- stage/data/companies/h.yaml +446 -0
- stage/data/companies/i.yaml +503 -0
- stage/data/companies/j.yaml +138 -0
- stage/data/companies/k.yaml +278 -0
- stage/data/companies/l.yaml +402 -0
- stage/data/companies/m.yaml +937 -0
- stage/data/companies/n.yaml +549 -0
- stage/data/companies/o.yaml +371 -0
- stage/data/companies/other.yaml +58 -0
- stage/data/companies/p.yaml +825 -0
- stage/data/companies/q.yaml +121 -0
- stage/data/companies/r.yaml +583 -0
- stage/data/companies/s.yaml +1140 -0
- stage/data/companies/t.yaml +817 -0
- stage/data/companies/u.yaml +196 -0
- stage/data/companies/v.yaml +325 -0
- stage/data/companies/w.yaml +353 -0
- stage/data/companies/x.yaml +67 -0
- stage/data/companies/y.yaml +36 -0
- stage/data/companies/z.yaml +146 -0
- stage/data/fonts/DejaVuSans.LICENSE.txt +99 -0
- stage/data/fonts/DejaVuSans.ttf +0 -0
- stage/data/lexicon/company_tokens.yaml +228 -0
- stage/data/lexicon/eligibility.yaml +455 -0
- stage/data/lexicon/inclusive_suffixes.yaml +37 -0
- stage/data/lexicon/internship.yaml +187 -0
- stage/data/lexicon/language.yaml +226 -0
- stage/data/lexicon/locations.yaml +1159 -0
- stage/data/lexicon/roles.yaml +2012 -0
- stage/data/lexicon/terms.yaml +76 -0
- stage/data/lexicon/workday_facets.yaml +27 -0
- stage/data/seed_companies.yaml +198 -0
- stage/dedup/__init__.py +19 -0
- stage/dedup/identity.py +113 -0
- stage/dedup/resolve.py +97 -0
- stage/domain/__init__.py +244 -0
- stage/domain/company.py +49 -0
- stage/domain/coverage.py +86 -0
- stage/domain/custom_board.py +92 -0
- stage/domain/discovery.py +94 -0
- stage/domain/enums.py +114 -0
- stage/domain/events.py +204 -0
- stage/domain/filters.py +27 -0
- stage/domain/health.py +169 -0
- stage/domain/ids.py +48 -0
- stage/domain/job.py +47 -0
- stage/domain/matching.py +15 -0
- stage/domain/priority.py +34 -0
- stage/domain/quarantine.py +39 -0
- stage/domain/rate_state.py +78 -0
- stage/domain/retention.py +20 -0
- stage/domain/rotation.py +46 -0
- stage/domain/signals.py +12 -0
- stage/domain/sync_run.py +35 -0
- stage/domain/text.py +113 -0
- stage/domain/validator.py +14 -0
- stage/domain/visits.py +60 -0
- stage/domain/workday.py +38 -0
- stage/http/__init__.py +58 -0
- stage/http/breaker.py +53 -0
- stage/http/cache.py +44 -0
- stage/http/client.py +725 -0
- stage/http/profiles.py +101 -0
- stage/lexicon.py +370 -0
- stage/normalize/__init__.py +16 -0
- stage/normalize/language.py +47 -0
- stage/normalize/location.py +271 -0
- stage/normalize/terms.py +153 -0
- stage/normalize/urls.py +122 -0
- stage/paths.py +86 -0
- stage/py.typed +0 -0
- stage/services/__init__.py +0 -0
- stage/services/canary.py +120 -0
- stage/services/coverage.py +231 -0
- stage/services/discover.py +747 -0
- stage/services/export.py +274 -0
- stage/services/health.py +237 -0
- stage/services/maintenance.py +225 -0
- stage/services/quarantine.py +20 -0
- stage/services/query.py +86 -0
- stage/services/sync.py +1257 -0
- stage/sources/__init__.py +82 -0
- stage/sources/_text.py +79 -0
- stage/sources/ashby.py +93 -0
- stage/sources/bamboohr.py +80 -0
- stage/sources/base.py +225 -0
- stage/sources/breezy.py +90 -0
- stage/sources/collage.py +60 -0
- stage/sources/community_feeds.py +142 -0
- stage/sources/curated_markdown.py +289 -0
- stage/sources/custom_json.py +610 -0
- stage/sources/espresso.py +154 -0
- stage/sources/feed.py +44 -0
- stage/sources/greenhouse.py +104 -0
- stage/sources/jobbank.py +147 -0
- stage/sources/jobvite.py +133 -0
- stage/sources/lever.py +76 -0
- stage/sources/oracle_cloud.py +187 -0
- stage/sources/platforms.py +609 -0
- stage/sources/quebec_emploi.py +146 -0
- stage/sources/recruitee.py +96 -0
- stage/sources/simplify.py +110 -0
- stage/sources/smartrecruiters.py +216 -0
- stage/sources/speedyapply.py +200 -0
- stage/sources/themuse.py +157 -0
- stage/sources/workable.py +83 -0
- stage/sources/workday.py +524 -0
- stage/sources/zshah.py +99 -0
- stage/storage/__init__.py +29 -0
- stage/storage/migrations/0001_initial.sql +239 -0
- stage/storage/migrations/__init__.py +135 -0
- stage/storage/repository.py +213 -0
- stage/storage/search.py +28 -0
- stage/storage/sqlite_repo.py +1586 -0
- stage/storage/writer.py +249 -0
- stage/tui/__init__.py +0 -0
- stage/tui/app.py +82 -0
- stage/tui/help.py +26 -0
- stage/tui/safe.py +21 -0
- stage/tui/screens/__init__.py +0 -0
- stage/tui/screens/boards.py +186 -0
- stage/tui/screens/postings.py +509 -0
- stage/tui/screens/review.py +209 -0
- stage/tui/screens/splash.py +37 -0
- stage/tui/screens/stats.py +124 -0
- stage/tui/screens/sync.py +194 -0
- stage/tui/state.py +160 -0
- stage/tui/theme.tcss +205 -0
- stage/tui/widgets/__init__.py +0 -0
- stage_cli-1.0.0.dist-info/METADATA +379 -0
- stage_cli-1.0.0.dist-info/RECORD +170 -0
- stage_cli-1.0.0.dist-info/WHEEL +4 -0
- stage_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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()
|