chameleon-cv 0.1.0
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.
- package/AGENTS.md +186 -0
- package/LICENSE +21 -0
- package/Makefile +136 -0
- package/README.md +356 -0
- package/bin/chameleon.js +136 -0
- package/chameleon.bat +286 -0
- package/chameleon.sh +353 -0
- package/package.json +39 -0
- package/profiles/github_repos.json +1266 -0
- package/requirements.txt +5 -0
- package/scan.sh +12 -0
- package/score.sh +12 -0
- package/scripts/__init__.py +1 -0
- package/scripts/__main_tui__.py +11 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/__main_tui__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ats_ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cache.cpython-313.pyc +0 -0
- package/scripts/__pycache__/config.cpython-312.pyc +0 -0
- package/scripts/__pycache__/config.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cover_letter.cpython-313.pyc +0 -0
- package/scripts/__pycache__/db.cpython-312.pyc +0 -0
- package/scripts/__pycache__/db.cpython-313.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-312.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-313.pyc +0 -0
- package/scripts/__pycache__/question.cpython-313.pyc +0 -0
- package/scripts/__pycache__/render.cpython-313.pyc +0 -0
- package/scripts/__pycache__/review.cpython-313.pyc +0 -0
- package/scripts/__pycache__/setup.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-313.pyc +0 -0
- package/scripts/ats_ghost.py +170 -0
- package/scripts/cache.py +63 -0
- package/scripts/cli.py +109 -0
- package/scripts/config.py +376 -0
- package/scripts/cover_letter.py +105 -0
- package/scripts/db.py +331 -0
- package/scripts/fonts.py +186 -0
- package/scripts/ghost.py +88 -0
- package/scripts/job_matcher.py +636 -0
- package/scripts/job_scanner/__init__.py +8 -0
- package/scripts/job_scanner/__main__.py +6 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/cli.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-313.pyc +0 -0
- package/scripts/job_scanner/base.py +197 -0
- package/scripts/job_scanner/browser.py +244 -0
- package/scripts/job_scanner/cli.py +6 -0
- package/scripts/job_scanner/parsers/__init__.py +24 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/flexjobs.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/indeed.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/linkedin.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/opentoworkremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remote_rocketship.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/web3_career.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/wellfound.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/ycomb.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/adzuna.py +81 -0
- package/scripts/job_scanner/parsers/arc.py +65 -0
- package/scripts/job_scanner/parsers/crypto_careers.py +88 -0
- package/scripts/job_scanner/parsers/dailyremote.py +91 -0
- package/scripts/job_scanner/parsers/flexjobs.py +91 -0
- package/scripts/job_scanner/parsers/himalayas.py +47 -0
- package/scripts/job_scanner/parsers/hn_hiring.py +94 -0
- package/scripts/job_scanner/parsers/indeed.py +98 -0
- package/scripts/job_scanner/parsers/jobicy.py +60 -0
- package/scripts/job_scanner/parsers/jooble.py +68 -0
- package/scripts/job_scanner/parsers/linkedin.py +140 -0
- package/scripts/job_scanner/parsers/opentoworkremote.py +53 -0
- package/scripts/job_scanner/parsers/relocate.py +95 -0
- package/scripts/job_scanner/parsers/remote_rocketship.py +90 -0
- package/scripts/job_scanner/parsers/remoteok.py +63 -0
- package/scripts/job_scanner/parsers/remotive.py +57 -0
- package/scripts/job_scanner/parsers/web3_career.py +92 -0
- package/scripts/job_scanner/parsers/wellfound.py +94 -0
- package/scripts/job_scanner/parsers/weworkremotely.py +60 -0
- package/scripts/job_scanner/parsers/ycomb.py +83 -0
- package/scripts/job_scanner/registry.py +65 -0
- package/scripts/job_scanner/scanner.py +162 -0
- package/scripts/question.py +107 -0
- package/scripts/render.py +110 -0
- package/scripts/review.py +89 -0
- package/scripts/setup.py +139 -0
- package/scripts/tailor_brief.py +379 -0
- package/scripts/tailor_cv.py +746 -0
- package/scripts/tailor_prompts.py +101 -0
- package/scripts/tui/__init__.py +1 -0
- package/scripts/tui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-313.pyc +0 -0
- package/scripts/tui/app.py +1547 -0
- package/scripts/tui/helpers.py +65 -0
- package/scripts/tui/screens.py +642 -0
- package/scripts/tui/widgets.py +276 -0
- package/scripts/tui_app.py +25 -0
- package/scripts/wizard.py +109 -0
- package/tui.bat +50 -0
- package/tui.sh +59 -0
|
@@ -0,0 +1,1547 @@
|
|
|
1
|
+
"""Chameleon TUI — main application class.
|
|
2
|
+
|
|
3
|
+
Scan, score, search, edit, tailor, diff, settings.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import atexit
|
|
8
|
+
import json
|
|
9
|
+
import signal
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TextIO
|
|
15
|
+
|
|
16
|
+
from textual import on, work
|
|
17
|
+
from textual.app import App, ComposeResult
|
|
18
|
+
from textual.binding import Binding
|
|
19
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
20
|
+
from textual.widgets import (
|
|
21
|
+
DataTable, Footer, Header, Input, RichLog, Static, TabbedContent, TabPane, Tree,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# ── Project root on sys.path (needed for scripts.* imports) ───────────────
|
|
25
|
+
_CHAMELEON = Path(__file__).resolve().parent.parent.parent
|
|
26
|
+
if str(_CHAMELEON) not in sys.path:
|
|
27
|
+
sys.path.insert(0, str(_CHAMELEON))
|
|
28
|
+
|
|
29
|
+
from scripts.config import config
|
|
30
|
+
from scripts.db import (
|
|
31
|
+
compute_job_id, delete_job, get_all_jobs, get_tailored_cvs,
|
|
32
|
+
save_jobs, save_tailored_cv, update_job_score,
|
|
33
|
+
)
|
|
34
|
+
from scripts.job_matcher import load_profile, score_job, extract_job_signals, find_matching_repos
|
|
35
|
+
from scripts.tailor_cv import kill_all_processes as kill_tailor_processes, tailor_and_render
|
|
36
|
+
from scripts.tailor_brief import (
|
|
37
|
+
analyze_seniority, analyze_experience_relevance, analyze_repos_for_jd,
|
|
38
|
+
find_gaps, load_cv_yaml, generate_brief, render_brief_text,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from scripts.tui.helpers import make_diff, safe_str, score_badge, score_bar, score_summary, truncate
|
|
42
|
+
from scripts.tui.screens import (
|
|
43
|
+
ConfirmQuitScreen, CustomScanScreen, DeleteConfirmScreen, HelpScreen, PasteJobScreen,
|
|
44
|
+
SetupWizardScreen,
|
|
45
|
+
)
|
|
46
|
+
from scripts.tui.widgets import NanoEditor, SettingsPane
|
|
47
|
+
|
|
48
|
+
# ── Paths (from config, not hardcoded) ─────────────────────────────────────
|
|
49
|
+
PROFILE_PATH = config.profile_path()
|
|
50
|
+
CV_PATH = config.master_cv_path()
|
|
51
|
+
SCANS_DIR = config.scans_dir()
|
|
52
|
+
ANALYSES_DIR = config.analyses_dir()
|
|
53
|
+
TEMPLATES_DIR = config.templates_dir()
|
|
54
|
+
VENV_PYTHON = config.venv_python()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
58
|
+
# TeeStream — duplicate stdout to terminal AND TUI log in real-time
|
|
59
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
60
|
+
class TeeStream:
|
|
61
|
+
"""Writes to both the real stdout and a TUI log callback, line by line."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, real_stdout: TextIO, log_cb) -> None:
|
|
64
|
+
self._real = real_stdout
|
|
65
|
+
self._log = log_cb
|
|
66
|
+
self._buf = ""
|
|
67
|
+
|
|
68
|
+
def _real_open(self) -> bool:
|
|
69
|
+
"""Check whether the real stream accepts writes (handles missing .closed)."""
|
|
70
|
+
real = self._real
|
|
71
|
+
if real is None:
|
|
72
|
+
return False
|
|
73
|
+
try:
|
|
74
|
+
return not real.closed # type: ignore[union-attr]
|
|
75
|
+
except AttributeError:
|
|
76
|
+
return True # no .closed attr => assume open (e.g. Textual's _PrintCapture)
|
|
77
|
+
|
|
78
|
+
def write(self, text: str) -> None:
|
|
79
|
+
if self._real_open():
|
|
80
|
+
try:
|
|
81
|
+
self._real.write(text)
|
|
82
|
+
self._real.flush()
|
|
83
|
+
except (BrokenPipeError, OSError, AttributeError):
|
|
84
|
+
pass
|
|
85
|
+
self._buf += text
|
|
86
|
+
while "\n" in self._buf:
|
|
87
|
+
line, self._buf = self._buf.split("\n", 1)
|
|
88
|
+
if line.strip():
|
|
89
|
+
try:
|
|
90
|
+
self._log(f"[dim]tailor|[/dim] {line}")
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
def flush(self) -> None:
|
|
95
|
+
if self._real_open():
|
|
96
|
+
try:
|
|
97
|
+
self._real.flush()
|
|
98
|
+
except (BrokenPipeError, OSError, AttributeError):
|
|
99
|
+
pass
|
|
100
|
+
if self._buf.strip():
|
|
101
|
+
try:
|
|
102
|
+
self._log(f"[dim]tailor|[/dim] {self._buf.strip()}")
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
self._buf = ""
|
|
106
|
+
|
|
107
|
+
def fileno(self) -> int:
|
|
108
|
+
return self._real.fileno()
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
pass # don't close the real stdout — we don't own it
|
|
112
|
+
|
|
113
|
+
def isatty(self) -> bool:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
118
|
+
# MAIN APP
|
|
119
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
120
|
+
class ChameleonTUI(App):
|
|
121
|
+
"""Chameleon Job TUI — scan, score, search, edit, tailor, diff, settings."""
|
|
122
|
+
|
|
123
|
+
TITLE = "Chameleon \u2014 Job Matcher TUI"
|
|
124
|
+
SUB_TITLE = "Scan \u00b7 Score \u00b7 Search \u00b7 Tailor \u00b7 Diff \u00b7 Settings"
|
|
125
|
+
|
|
126
|
+
CSS = """
|
|
127
|
+
Screen { background: $surface; }
|
|
128
|
+
|
|
129
|
+
/* ── Search bar ── */
|
|
130
|
+
#search_bar { dock: top; height: 3; background: $surface-darken-1; padding: 0 1; layout: horizontal; border-bottom: solid $primary-darken-2; }
|
|
131
|
+
#search_bar Input { width: 1fr; }
|
|
132
|
+
#search_count { width: auto; content-align: right middle; padding: 0 1 0 0; color: $text-muted; min-width: 8; }
|
|
133
|
+
|
|
134
|
+
/* ── Status + hints ── */
|
|
135
|
+
#status_line { dock: bottom; height: 1; background: $secondary-darken-2; color: $text-muted; padding: 0 1; }
|
|
136
|
+
#key_hints { dock: bottom; height: 1; background: $primary-darken-1; color: $text; padding: 0 1; text-style: dim; }
|
|
137
|
+
|
|
138
|
+
/* ── Tabs ── */
|
|
139
|
+
#main_tabs { height: 1fr; }
|
|
140
|
+
TabbedContent > TabPane { padding: 0; }
|
|
141
|
+
|
|
142
|
+
/* ── Jobs tab ── */
|
|
143
|
+
#job_table { height: 1fr; }
|
|
144
|
+
#detail_pane { width: 1fr; height: 1fr; border: tall $primary-darken-1; background: $surface-darken-1; }
|
|
145
|
+
|
|
146
|
+
/* ── Editor tab ── */
|
|
147
|
+
#editor_container { height: 1fr; }
|
|
148
|
+
#editor_placeholder { width: 100%; height: 100%; content-align: center middle; text-align: center; color: $text-muted; padding: 2 4; }
|
|
149
|
+
|
|
150
|
+
/* ── Profile tab ── */
|
|
151
|
+
#profile_tree { height: 1fr; }
|
|
152
|
+
|
|
153
|
+
/* ── Scanner tab ── */
|
|
154
|
+
#scan_log { height: 1fr; }
|
|
155
|
+
|
|
156
|
+
/* ── Tailored tab ── */
|
|
157
|
+
#tailored_table { height: 1fr; }
|
|
158
|
+
#diff_view { width: 1fr; height: 1fr; border: tall $success-darken-1; background: $surface-darken-1; }
|
|
159
|
+
#diff_stats { dock: top; height: 3; background: $success-darken-2; color: $text; padding: 0 1; text-style: bold; }
|
|
160
|
+
#diff_stats Static { width: auto; }
|
|
161
|
+
|
|
162
|
+
/* ── Empty states ── */
|
|
163
|
+
.empty-state {
|
|
164
|
+
width: 100%; height: 100%;
|
|
165
|
+
content-align: center middle; text-align: center;
|
|
166
|
+
color: $text-muted; padding: 2 4;
|
|
167
|
+
}
|
|
168
|
+
.empty-state .title { text-style: bold; color: $text; margin-bottom: 1; }
|
|
169
|
+
.empty-state .hint { color: $text-muted; margin-top: 0; }
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
BINDINGS = [
|
|
173
|
+
Binding("ctrl+q", "quit", "Quit"),
|
|
174
|
+
Binding("ctrl+s", "scan_jobs", "Scan"),
|
|
175
|
+
Binding("ctrl+r", "rescore", "Score"),
|
|
176
|
+
Binding("ctrl+e", "edit_job", "Edit"),
|
|
177
|
+
Binding("ctrl+t", "tailor_cv", "Tailor"),
|
|
178
|
+
Binding("ctrl+n", "paste_job", "Paste"),
|
|
179
|
+
Binding("ctrl+d", "delete_job", "Delete"),
|
|
180
|
+
Binding("ctrl+o", "open_url", "URL"),
|
|
181
|
+
Binding("f1", "help_screen", "Help"),
|
|
182
|
+
Binding("f2", "scan_with_options", "Scan+"),
|
|
183
|
+
Binding("f3", "setup_wizard", "Setup"),
|
|
184
|
+
Binding("f5", "refresh", "Refresh"),
|
|
185
|
+
Binding("slash", "focus_search", "Search"),
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
def __init__(self) -> None:
|
|
189
|
+
super().__init__()
|
|
190
|
+
try:
|
|
191
|
+
self.profile = load_profile(PROFILE_PATH)
|
|
192
|
+
except Exception:
|
|
193
|
+
self.profile = {
|
|
194
|
+
"repos": [],
|
|
195
|
+
"skills_summary": {"languages": {}, "tech_stack": [], "domains": [], "infrastructure": []},
|
|
196
|
+
}
|
|
197
|
+
self.jobs: list[dict] = []
|
|
198
|
+
self.scored_jobs: list[dict] = []
|
|
199
|
+
self.filtered_jobs: list[dict] = []
|
|
200
|
+
self.current_job_idx: int | None = None
|
|
201
|
+
self._scanning = False
|
|
202
|
+
self._rescoreing = False
|
|
203
|
+
self._tailor_running = False
|
|
204
|
+
self._scan_count = 0
|
|
205
|
+
self._scan_total = 0
|
|
206
|
+
self._search_query = ""
|
|
207
|
+
self.tailored_cvs: list[dict] = []
|
|
208
|
+
self.current_tailored_idx: int | None = None
|
|
209
|
+
self._setup_status = self._check_setup()
|
|
210
|
+
|
|
211
|
+
# ── Subprocess kill registry (scanner-level) ─────
|
|
212
|
+
self._subprocs: list[subprocess.Popen] = []
|
|
213
|
+
self._subprocs_lock = threading.Lock()
|
|
214
|
+
self._shutting_down = False
|
|
215
|
+
|
|
216
|
+
# ── Kill all child processes on exit ─────────────
|
|
217
|
+
atexit.register(self._emergency_cleanup)
|
|
218
|
+
|
|
219
|
+
# ── Trap SIGINT (Ctrl+C) to clean up before die ──
|
|
220
|
+
signal.signal(signal.SIGINT, self._handle_sigint)
|
|
221
|
+
|
|
222
|
+
# ── SIGPIPE protection (common in piped CLI usage) ──
|
|
223
|
+
try:
|
|
224
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
225
|
+
except AttributeError:
|
|
226
|
+
pass # SIGPIPE not available on Windows
|
|
227
|
+
|
|
228
|
+
# ── Crash-safe subprocess tracking ─────────────────────────────────
|
|
229
|
+
def _track_proc(self, proc: subprocess.Popen) -> None:
|
|
230
|
+
with self._subprocs_lock:
|
|
231
|
+
self._subprocs.append(proc)
|
|
232
|
+
|
|
233
|
+
def _untrack_proc(self, proc: subprocess.Popen) -> None:
|
|
234
|
+
with self._subprocs_lock:
|
|
235
|
+
try:
|
|
236
|
+
self._subprocs.remove(proc)
|
|
237
|
+
except ValueError:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
def _cleanup_subprocesses(self) -> None:
|
|
241
|
+
"""Kill all TUI-tracked subprocesses (scanner, etc.) plus tailor processes.
|
|
242
|
+
Safe to call during atexit / early init — uses no TUI widgets."""
|
|
243
|
+
lock = getattr(self, "_subprocs_lock", None)
|
|
244
|
+
if lock is not None:
|
|
245
|
+
with lock:
|
|
246
|
+
for proc in getattr(self, "_subprocs", []):
|
|
247
|
+
try:
|
|
248
|
+
proc.kill()
|
|
249
|
+
proc.wait(timeout=3)
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
try:
|
|
253
|
+
self._subprocs.clear()
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
kill_tailor_processes()
|
|
257
|
+
|
|
258
|
+
def _emergency_cleanup(self) -> None:
|
|
259
|
+
"""Called by atexit and Ctrl+C — kills subprocesses only. No TUI widget access."""
|
|
260
|
+
self._shutting_down = True
|
|
261
|
+
self._cleanup_subprocesses()
|
|
262
|
+
|
|
263
|
+
def _handle_sigint(self, signum: int, frame) -> None:
|
|
264
|
+
"""Traps Ctrl+C so we can kill subprocesses before Python exits."""
|
|
265
|
+
self._emergency_cleanup()
|
|
266
|
+
# Restore default handler and re-raise so the process stops
|
|
267
|
+
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
268
|
+
raise KeyboardInterrupt()
|
|
269
|
+
|
|
270
|
+
# ── Crash-safe notifications ────────────────────────────────────────
|
|
271
|
+
def _crash_notify(self, title: str, message: str = "", severity: str = "error") -> None:
|
|
272
|
+
"""Show a crash/debug toast with optional log entry. Never throws."""
|
|
273
|
+
textual_sev = {"error": "error", "warning": "warning", "info": "information"}.get(severity, "error")
|
|
274
|
+
try:
|
|
275
|
+
tag = {"error": "[red]\u26a0 CRASH[/red]",
|
|
276
|
+
"warning": "[yellow]\u26a0 WARNING[/yellow]",
|
|
277
|
+
"info": "[cyan]\u2139[/cyan]"}.get(severity, "[red]\u26a0[/red]")
|
|
278
|
+
line = f"{tag} {title}"
|
|
279
|
+
if message:
|
|
280
|
+
line += f" [dim]\u2014[/dim] {message}"
|
|
281
|
+
self._log(line)
|
|
282
|
+
self.notify(f"{title} {message}", timeout=8, severity=textual_sev)
|
|
283
|
+
except Exception:
|
|
284
|
+
pass # last resort — if notify itself crashes, do nothing
|
|
285
|
+
|
|
286
|
+
def compose(self) -> ComposeResult:
|
|
287
|
+
yield Header()
|
|
288
|
+
with Horizontal(id="search_bar"):
|
|
289
|
+
yield Input(placeholder=" / Search by title, company, tag...", id="search_input")
|
|
290
|
+
yield Static("", id="search_count")
|
|
291
|
+
yield Static("", id="status_line")
|
|
292
|
+
with TabbedContent(id="main_tabs"):
|
|
293
|
+
with TabPane("Jobs", id="tab_jobs"):
|
|
294
|
+
with Horizontal():
|
|
295
|
+
yield DataTable(id="job_table")
|
|
296
|
+
with VerticalScroll(id="detail_pane"):
|
|
297
|
+
yield Static(self._welcome_text(), id="empty_state", classes="empty-state")
|
|
298
|
+
yield RichLog(id="score_detail", auto_scroll=False, markup=True)
|
|
299
|
+
with TabPane("Editor", id="tab_editor"):
|
|
300
|
+
yield Static(self._editor_placeholder_text(), id="editor_placeholder", classes="empty-state")
|
|
301
|
+
yield NanoEditor(id="editor_container")
|
|
302
|
+
with TabPane("Profile", id="tab_profile"):
|
|
303
|
+
yield Tree("GitHub Profile", id="profile_tree")
|
|
304
|
+
with TabPane("Scanner", id="tab_scanner"):
|
|
305
|
+
yield RichLog(id="scan_log", markup=True)
|
|
306
|
+
with TabPane("Tailored", id="tab_tailored"):
|
|
307
|
+
with Horizontal():
|
|
308
|
+
yield DataTable(id="tailored_table")
|
|
309
|
+
with VerticalScroll(id="diff_view"):
|
|
310
|
+
yield Static(self._tailored_placeholder_text(), id="tailored_empty", classes="empty-state")
|
|
311
|
+
yield RichLog(id="diff_log", auto_scroll=False, markup=True)
|
|
312
|
+
with TabPane("Settings", id="tab_settings"):
|
|
313
|
+
yield SettingsPane(id="settings_pane")
|
|
314
|
+
yield Static(self._key_hints_for_tab("tab_jobs"), id="key_hints")
|
|
315
|
+
yield Footer()
|
|
316
|
+
|
|
317
|
+
def _welcome_text(self) -> str:
|
|
318
|
+
s = self._setup_status if hasattr(self, "_setup_status") else {}
|
|
319
|
+
lines = ["[bold]\u2593 Chameleon Job TUI[/bold]", ""]
|
|
320
|
+
|
|
321
|
+
if s:
|
|
322
|
+
if not s["cv_exists"] or not s["profile_exists"]:
|
|
323
|
+
lines.append("[bold yellow]\u26a0 Setup Required[/bold yellow]")
|
|
324
|
+
if not s["cv_exists"]:
|
|
325
|
+
lines.append(f" [red]\u2717[/red] CV: [dim]{s['cv_path']}[/dim] [red]not found[/red]")
|
|
326
|
+
else:
|
|
327
|
+
lines.append(f" [green]\u2713[/green] CV: [dim]{s['cv_name']}[/dim]")
|
|
328
|
+
if not s["profile_exists"]:
|
|
329
|
+
lines.append(f" [red]\u2717[/red] Profile: [dim]{s['profile_path']}[/dim] [red]not found[/red]")
|
|
330
|
+
else:
|
|
331
|
+
lines.append(f" [green]\u2713[/green] Profile: [dim]{s['profile_repo_count']} repos[/dim]")
|
|
332
|
+
lines.append("")
|
|
333
|
+
lines.append("[bold]Press F3 to launch the Setup Wizard[/bold]")
|
|
334
|
+
lines.append("")
|
|
335
|
+
else:
|
|
336
|
+
lines.append(f"[green]\u2713[/green] CV: [dim]{s['cv_name']}[/dim] | [green]\u2713[/green] Profile: [dim]{s['profile_repo_count']} repos[/dim]")
|
|
337
|
+
lines.append("")
|
|
338
|
+
|
|
339
|
+
lines.append("[dim]No jobs loaded yet.[/dim]")
|
|
340
|
+
lines.append("")
|
|
341
|
+
lines.append("[bold]Ctrl+S[/bold] Scan job platforms")
|
|
342
|
+
lines.append("[bold]Ctrl+N[/bold] Paste a job description")
|
|
343
|
+
lines.append("[bold]/[/bold] Search & filter")
|
|
344
|
+
lines.append("")
|
|
345
|
+
lines.append("[dim]Tip: Tailored CVs appear in the Tailored tab with diffs[/dim]")
|
|
346
|
+
return "\n".join(lines)
|
|
347
|
+
|
|
348
|
+
def _editor_placeholder_text(self) -> str:
|
|
349
|
+
return (
|
|
350
|
+
"[dim]\u270e Editor[/dim]\n\n"
|
|
351
|
+
"Select a job and press [bold]Ctrl+E[/bold] to edit its description.\n"
|
|
352
|
+
"Changes are saved with [bold]Ctrl+X[/bold] and trigger a re-score."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def _tailored_placeholder_text(self) -> str:
|
|
356
|
+
return (
|
|
357
|
+
"[dim]\u2702 Tailored CVs[/dim]\n\n"
|
|
358
|
+
"Select a job and press [bold]Ctrl+T[/bold] to tailor your CV.\n"
|
|
359
|
+
"Tailored CVs appear here with diffs showing changes."
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
_TAB_HINTS: dict[str, str] = {
|
|
363
|
+
"tab_jobs": " \u2191\u2193 Navigate Enter Select Ctrl+S Scan Ctrl+E Edit Ctrl+T Tailor Ctrl+D Delete / Search F3 Setup",
|
|
364
|
+
"tab_editor": " Ctrl+X Save&Exit Ctrl+G GoTo Ctrl+S Save Esc Back",
|
|
365
|
+
"tab_profile": " \u2191\u2193 Navigate \u2195 Expand/Collapse F3 Setup",
|
|
366
|
+
"tab_scanner": " (background) Ctrl+S Scan F2 Custom Scan F3 Setup",
|
|
367
|
+
"tab_tailored": " \u2191\u2193 Select Ctrl+T Tailor new Ctrl+R Rescore F3 Setup",
|
|
368
|
+
"tab_settings": " \u2191\u2193 Edit Enter Confirm Tab Next field F3 Setup Wizard",
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
def _key_hints_for_tab(self, tab_id: str) -> str:
|
|
372
|
+
hints = self._TAB_HINTS.get(tab_id, "")
|
|
373
|
+
return f"[bold]F1[/bold] Help [bold]F3[/bold] Setup {hints} [bold]Ctrl+Q[/bold] Quit"
|
|
374
|
+
|
|
375
|
+
# ── Mount ────────────────────────────────────────────────────────────
|
|
376
|
+
def on_mount(self) -> None:
|
|
377
|
+
self._setup_job_table()
|
|
378
|
+
self._setup_tailored_table()
|
|
379
|
+
self._load_profile_tree()
|
|
380
|
+
self._update_status()
|
|
381
|
+
self._load_saved_scans_worker()
|
|
382
|
+
self._active_tab = "tab_jobs"
|
|
383
|
+
|
|
384
|
+
# Launch setup wizard if CV or profile is missing (small delay for mount to finish)
|
|
385
|
+
s = self._setup_status
|
|
386
|
+
if not s.get("cv_exists") or not s.get("profile_exists"):
|
|
387
|
+
self.set_timer(0.3, self._show_setup_wizard)
|
|
388
|
+
elif not s.get("has_repos"):
|
|
389
|
+
self.notify("GitHub profile has no repos. Scoring will be limited. Press F3 for Setup Wizard.", timeout=6)
|
|
390
|
+
elif not s.get("browser_available"):
|
|
391
|
+
self.set_timer(1.0, self._auto_install_playwright)
|
|
392
|
+
|
|
393
|
+
def _show_setup_wizard(self) -> None:
|
|
394
|
+
def _on_result(result: bool | None) -> None:
|
|
395
|
+
if result:
|
|
396
|
+
self._setup_status = self._check_setup()
|
|
397
|
+
self._load_profile_tree()
|
|
398
|
+
self._update_status()
|
|
399
|
+
config.set("setup_wizard_completed", True)
|
|
400
|
+
self.notify("[bold green]\u2714 Setup complete![/bold green]", timeout=4)
|
|
401
|
+
try:
|
|
402
|
+
self.query_one("#empty_state", Static).update(self._welcome_text())
|
|
403
|
+
except Exception:
|
|
404
|
+
pass
|
|
405
|
+
else:
|
|
406
|
+
# Wizard skipped/cancelled — re-check status for stale warnings
|
|
407
|
+
self._setup_status = self._check_setup()
|
|
408
|
+
self._update_status()
|
|
409
|
+
|
|
410
|
+
self.push_screen(SetupWizardScreen(), _on_result)
|
|
411
|
+
|
|
412
|
+
def _auto_install_playwright(self) -> None:
|
|
413
|
+
"""Auto-install Playwright + Chromium in background when missing."""
|
|
414
|
+
import threading
|
|
415
|
+
self.notify("Installing Playwright + Chromium for headless browser automation...", timeout=10)
|
|
416
|
+
|
|
417
|
+
def _install():
|
|
418
|
+
try:
|
|
419
|
+
from scripts.job_scanner.browser import install_playwright
|
|
420
|
+
def _log(msg):
|
|
421
|
+
self.call_from_thread(self.notify, f"[Playwright] {msg}", timeout=6)
|
|
422
|
+
install_playwright(progress_callback=_log)
|
|
423
|
+
self._setup_status = self._check_setup()
|
|
424
|
+
self.call_from_thread(self._update_status)
|
|
425
|
+
except Exception as e:
|
|
426
|
+
self.call_from_thread(self.notify, f"[Playwright] Install failed: {e}", timeout=8)
|
|
427
|
+
|
|
428
|
+
threading.Thread(target=_install, daemon=True).start()
|
|
429
|
+
|
|
430
|
+
@on(TabbedContent.TabActivated, "#main_tabs")
|
|
431
|
+
def on_tab_changed(self, event: TabbedContent.TabActivated) -> None:
|
|
432
|
+
tab_id = event.pane.id or "tab_jobs"
|
|
433
|
+
self._active_tab = tab_id
|
|
434
|
+
try:
|
|
435
|
+
self.query_one("#key_hints", Static).update(self._key_hints_for_tab(tab_id))
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
440
|
+
def _get_selected_job(self) -> dict | None:
|
|
441
|
+
if self.current_job_idx is not None and 0 <= self.current_job_idx < len(self.filtered_jobs):
|
|
442
|
+
return self.filtered_jobs[self.current_job_idx]
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
def _get_selected_tailored(self) -> dict | None:
|
|
446
|
+
if self.current_tailored_idx is not None and 0 <= self.current_tailored_idx < len(self.tailored_cvs):
|
|
447
|
+
return self.tailored_cvs[self.current_tailored_idx]
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
def _log(self, message: str) -> None:
|
|
451
|
+
try:
|
|
452
|
+
self.query_one("#scan_log", RichLog).write(message)
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
455
|
+
|
|
456
|
+
def _check_setup(self) -> dict[str, bool | str]:
|
|
457
|
+
"""Check which data sources are available. Returns status dict."""
|
|
458
|
+
cv_ok = CV_PATH.exists()
|
|
459
|
+
cv_name = CV_PATH.name if cv_ok else ""
|
|
460
|
+
profile_ok = PROFILE_PATH.exists()
|
|
461
|
+
has_repos = len(self.profile.get("repos", [])) > 0 if profile_ok else False
|
|
462
|
+
profile_repo_count = len(self.profile.get("repos", [])) if profile_ok else 0
|
|
463
|
+
# Check browser automation availability
|
|
464
|
+
browser_ok = False
|
|
465
|
+
try:
|
|
466
|
+
from scripts.job_scanner.browser import is_playwright_available
|
|
467
|
+
browser_ok = is_playwright_available()
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
return {
|
|
471
|
+
"cv_exists": cv_ok,
|
|
472
|
+
"cv_path": str(CV_PATH),
|
|
473
|
+
"cv_name": cv_name,
|
|
474
|
+
"profile_exists": profile_ok,
|
|
475
|
+
"profile_path": str(PROFILE_PATH),
|
|
476
|
+
"has_repos": has_repos,
|
|
477
|
+
"profile_repo_count": profile_repo_count,
|
|
478
|
+
"browser_available": browser_ok,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
def _update_status(self) -> None:
|
|
482
|
+
try:
|
|
483
|
+
s = self._setup_status
|
|
484
|
+
n = len(self.filtered_jobs)
|
|
485
|
+
total = len(self.scored_jobs)
|
|
486
|
+
|
|
487
|
+
# ── Setup warnings ──
|
|
488
|
+
setup_warn = ""
|
|
489
|
+
if not s["cv_exists"] and not s["profile_exists"]:
|
|
490
|
+
setup_warn = " \u26a0 [bold red]SETUP NEEDED:[/bold red] Missing CV + Profile "
|
|
491
|
+
elif not s["cv_exists"]:
|
|
492
|
+
setup_warn = f" \u26a0 [bold yellow]SETUP:[/bold yellow] CV not found ({s['cv_path']}) "
|
|
493
|
+
elif not s["profile_exists"]:
|
|
494
|
+
setup_warn = " \u26a0 [bold yellow]SETUP:[/bold yellow] GitHub profile missing "
|
|
495
|
+
elif s["profile_exists"] and not s["has_repos"]:
|
|
496
|
+
setup_warn = " \u26a0 [bold yellow]SETUP:[/bold yellow] Profile has no repos "
|
|
497
|
+
|
|
498
|
+
# Browser status
|
|
499
|
+
browser_status = ""
|
|
500
|
+
if s.get("browser_available"):
|
|
501
|
+
browser_status = " \U0001f310 [green]Browser[/green] "
|
|
502
|
+
else:
|
|
503
|
+
browser_status = " \U0001f310 [dim]No browser[/dim] "
|
|
504
|
+
setup_warn += browser_status
|
|
505
|
+
|
|
506
|
+
# Job count with filtering info
|
|
507
|
+
if self._search_query:
|
|
508
|
+
count_str = f" Jobs: [bold]{n}[/bold]/{total}"
|
|
509
|
+
else:
|
|
510
|
+
count_str = f" Jobs: {total}"
|
|
511
|
+
|
|
512
|
+
# Selected job
|
|
513
|
+
sel = ""
|
|
514
|
+
job = self._get_selected_job()
|
|
515
|
+
if job:
|
|
516
|
+
score = job.get("score", 0)
|
|
517
|
+
sel = f" \u2502 {truncate(job['company'], 20)} \u2014 {truncate(job['title'], 30)} {score_badge(score)}"
|
|
518
|
+
|
|
519
|
+
# Activity indicators
|
|
520
|
+
activity = ""
|
|
521
|
+
if self._scanning:
|
|
522
|
+
pct = int((self._scan_count / max(self._scan_total, 1)) * 100)
|
|
523
|
+
activity = f" \u2502 [bold cyan]\u25b6 Scanning[/bold cyan] {self._scan_count}/{self._scan_total} ({pct}%)"
|
|
524
|
+
elif self._tailor_running:
|
|
525
|
+
activity = " \u2502 [bold green]\u2702 Tailoring...[/bold green]"
|
|
526
|
+
elif self._rescoreing:
|
|
527
|
+
activity = " \u2502 [bold yellow]\u25b6 Scoring...[/bold yellow]"
|
|
528
|
+
|
|
529
|
+
# Search info
|
|
530
|
+
search = ""
|
|
531
|
+
if self._search_query:
|
|
532
|
+
search = f' \u2502 [dim]filter: "{self._search_query}"[/dim]'
|
|
533
|
+
try:
|
|
534
|
+
self.query_one("#search_count", Static).update(f"[dim]{n} match{'es' if n != 1 else ''}[/dim]")
|
|
535
|
+
except Exception:
|
|
536
|
+
pass
|
|
537
|
+
else:
|
|
538
|
+
try:
|
|
539
|
+
self.query_one("#search_count", Static).update("")
|
|
540
|
+
except Exception:
|
|
541
|
+
pass
|
|
542
|
+
|
|
543
|
+
# Tailored count
|
|
544
|
+
tailored = ""
|
|
545
|
+
if self.tailored_cvs:
|
|
546
|
+
tailored = f" \u2502 [green]\u2702 {len(self.tailored_cvs)} tailored[/green]"
|
|
547
|
+
|
|
548
|
+
self.query_one("#status_line", Static).update(
|
|
549
|
+
f"{setup_warn}{count_str}{sel}{activity}{search}{tailored}"
|
|
550
|
+
)
|
|
551
|
+
except Exception:
|
|
552
|
+
pass
|
|
553
|
+
|
|
554
|
+
# ── Job table ────────────────────────────────────────────────────────
|
|
555
|
+
def _setup_job_table(self) -> None:
|
|
556
|
+
try:
|
|
557
|
+
table = self.query_one("#job_table", DataTable)
|
|
558
|
+
table.add_columns("Score", "Company", "Role", "Remote", "Source")
|
|
559
|
+
table.cursor_type = "row"
|
|
560
|
+
except Exception:
|
|
561
|
+
pass
|
|
562
|
+
|
|
563
|
+
def _refresh_job_table(self) -> None:
|
|
564
|
+
try:
|
|
565
|
+
table = self.query_one("#job_table", DataTable)
|
|
566
|
+
table.clear()
|
|
567
|
+
if self._search_query:
|
|
568
|
+
q = self._search_query.lower()
|
|
569
|
+
self.filtered_jobs = [
|
|
570
|
+
j for j in self.scored_jobs
|
|
571
|
+
if q in j["title"].lower() or q in j["company"].lower()
|
|
572
|
+
or q in " ".join(j.get("tags", [])).lower()
|
|
573
|
+
or q in j.get("source", "").lower()
|
|
574
|
+
or q in j.get("description", "").lower()
|
|
575
|
+
]
|
|
576
|
+
else:
|
|
577
|
+
self.filtered_jobs = list(self.scored_jobs)
|
|
578
|
+
for job in self.filtered_jobs:
|
|
579
|
+
badge = score_badge(job["score"])
|
|
580
|
+
remote = "[green]Y[/green]" if job.get("remote") else "[dim]N[/dim]"
|
|
581
|
+
table.add_row(
|
|
582
|
+
badge,
|
|
583
|
+
truncate(job["company"], 22),
|
|
584
|
+
truncate(job["title"], 38),
|
|
585
|
+
remote,
|
|
586
|
+
job.get("source", "?")[:12],
|
|
587
|
+
)
|
|
588
|
+
try:
|
|
589
|
+
empty = self.query_one("#empty_state", Static)
|
|
590
|
+
empty.display = not bool(self.scored_jobs)
|
|
591
|
+
except Exception:
|
|
592
|
+
pass
|
|
593
|
+
self._update_status()
|
|
594
|
+
except Exception:
|
|
595
|
+
pass
|
|
596
|
+
|
|
597
|
+
# ── Tailored table ──────────────────────────────────────────────────
|
|
598
|
+
def _setup_tailored_table(self) -> None:
|
|
599
|
+
try:
|
|
600
|
+
table = self.query_one("#tailored_table", DataTable)
|
|
601
|
+
table.add_columns("Company", "Role", "Score", "PDF")
|
|
602
|
+
table.cursor_type = "row"
|
|
603
|
+
except Exception:
|
|
604
|
+
pass
|
|
605
|
+
|
|
606
|
+
def _refresh_tailored_table(self) -> None:
|
|
607
|
+
try:
|
|
608
|
+
table = self.query_one("#tailored_table", DataTable)
|
|
609
|
+
table.clear()
|
|
610
|
+
for cv in self.tailored_cvs:
|
|
611
|
+
score = cv.get("score", 0)
|
|
612
|
+
badge = score_badge(score)
|
|
613
|
+
pdf_name = Path(cv.get("pdf_path", "")).name if cv.get("pdf_path") else "-"
|
|
614
|
+
table.add_row(
|
|
615
|
+
truncate(cv.get("company", "?"), 22),
|
|
616
|
+
truncate(cv.get("title", "?"), 30),
|
|
617
|
+
badge,
|
|
618
|
+
truncate(pdf_name, 30),
|
|
619
|
+
)
|
|
620
|
+
try:
|
|
621
|
+
empty = self.query_one("#tailored_empty", Static)
|
|
622
|
+
empty.display = not bool(self.tailored_cvs)
|
|
623
|
+
except Exception:
|
|
624
|
+
pass
|
|
625
|
+
self._update_status()
|
|
626
|
+
except Exception:
|
|
627
|
+
pass
|
|
628
|
+
|
|
629
|
+
def _show_tailored_diff(self, idx: int) -> None:
|
|
630
|
+
if idx < 0 or idx >= len(self.tailored_cvs):
|
|
631
|
+
return
|
|
632
|
+
self.current_tailored_idx = idx
|
|
633
|
+
cv = self.tailored_cvs[idx]
|
|
634
|
+
try:
|
|
635
|
+
self.query_one("#tailored_empty", Static).display = False
|
|
636
|
+
except Exception:
|
|
637
|
+
pass
|
|
638
|
+
try:
|
|
639
|
+
diff_log = self.query_one("#diff_log", RichLog)
|
|
640
|
+
diff_log.clear()
|
|
641
|
+
|
|
642
|
+
# ── Header card ──
|
|
643
|
+
score = cv.get("score", 0)
|
|
644
|
+
diff_log.write(f"[bold]{'━' * 60}[/bold]")
|
|
645
|
+
diff_log.write(f"[bold green]\u2702 {cv.get('company', '?')} \u2014 {cv.get('title', '?')}[/bold green]")
|
|
646
|
+
diff_log.write(f" {score_summary(score)} {score_bar(score, width=20)}")
|
|
647
|
+
pdf_name = Path(cv.get("pdf_path", "")).name if cv.get("pdf_path") else "-"
|
|
648
|
+
yaml_name = Path(cv.get("yaml_path", "")).name if cv.get("yaml_path") else "-"
|
|
649
|
+
diff_log.write(f" [dim]PDF:[/dim] {pdf_name}")
|
|
650
|
+
diff_log.write(f" [dim]YAML:[/dim] {yaml_name}")
|
|
651
|
+
diff_log.write(f"[bold]{'━' * 60}[/bold]")
|
|
652
|
+
diff_log.write("")
|
|
653
|
+
|
|
654
|
+
# ── Diff ──
|
|
655
|
+
original_path = config.master_cv_path()
|
|
656
|
+
tailored_path = Path(cv.get("yaml_path", ""))
|
|
657
|
+
if not tailored_path.exists():
|
|
658
|
+
diff_log.write("[red]\u2717 Tailored YAML not found on disk[/red]")
|
|
659
|
+
return
|
|
660
|
+
original_text = original_path.read_text() if original_path.exists() else "(original not found)"
|
|
661
|
+
tailored_text = tailored_path.read_text()
|
|
662
|
+
diff_lines = make_diff(original_text, tailored_text, original_path.stem + ".yaml")
|
|
663
|
+
if not diff_lines:
|
|
664
|
+
diff_log.write("[dim]\u2714 No differences (identical to original)[/dim]")
|
|
665
|
+
return
|
|
666
|
+
|
|
667
|
+
added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
|
|
668
|
+
removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))
|
|
669
|
+
total = added + removed
|
|
670
|
+
diff_log.write(f"[bold] Diff:[/bold] [green]+{added}[/green] added [red]-{removed}[/red] removed [dim]({total} changes)[/dim]\n")
|
|
671
|
+
|
|
672
|
+
for line in diff_lines:
|
|
673
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
674
|
+
diff_log.write(f"[bold]{line}[/bold]")
|
|
675
|
+
elif line.startswith("@@"):
|
|
676
|
+
diff_log.write(f"[bold cyan]{line}[/bold cyan]")
|
|
677
|
+
elif line.startswith("+"):
|
|
678
|
+
diff_log.write(f"[green]{line}[/green]")
|
|
679
|
+
elif line.startswith("-"):
|
|
680
|
+
diff_log.write(f"[red]{line}[/red]")
|
|
681
|
+
else:
|
|
682
|
+
diff_log.write(line)
|
|
683
|
+
except Exception as e:
|
|
684
|
+
try:
|
|
685
|
+
self.query_one("#diff_log", RichLog).write(f"[red]Error reading diff: {e}[/red]")
|
|
686
|
+
except Exception:
|
|
687
|
+
pass
|
|
688
|
+
|
|
689
|
+
# ── Profile tree ─────────────────────────────────────────────────────
|
|
690
|
+
def _load_profile_tree(self) -> None:
|
|
691
|
+
try:
|
|
692
|
+
tree = self.query_one("#profile_tree", Tree)
|
|
693
|
+
tree.root.expand()
|
|
694
|
+
s = self._setup_status
|
|
695
|
+
|
|
696
|
+
if not s["profile_exists"]:
|
|
697
|
+
tree.root.label = "[red]\u26a0 Profile Not Found[/red]"
|
|
698
|
+
tree.root.add_leaf(f"File: {s['profile_path']}")
|
|
699
|
+
tree.root.add_leaf("[dim]Place a GitHub profile JSON at the path above[/dim]")
|
|
700
|
+
tree.root.add_leaf("[dim]Run a scan in the Scanner tab or use Settings to change path[/dim]")
|
|
701
|
+
return
|
|
702
|
+
|
|
703
|
+
user = self.profile.get("user", "github_user")
|
|
704
|
+
repos = self.profile.get("repos", [])
|
|
705
|
+
tree.root.label = f"{user} \u2014 GitHub Profile ({len(repos)} repos)"
|
|
706
|
+
|
|
707
|
+
if not repos and not any([
|
|
708
|
+
self.profile.get("skills_summary", {}).get("languages", {}),
|
|
709
|
+
self.profile.get("skills_summary", {}).get("tech_stack", []),
|
|
710
|
+
]):
|
|
711
|
+
tree.root.add_leaf("[yellow]\u26a0 No repo data or skills found[/yellow]")
|
|
712
|
+
tree.root.add_leaf("[dim]The profile file exists but contains no repos[/dim]")
|
|
713
|
+
tree.root.add_leaf("[dim]Re-generate your GitHub profile or check the file format[/dim]")
|
|
714
|
+
return
|
|
715
|
+
|
|
716
|
+
langs = self.profile.get("skills_summary", {}).get("languages", {})
|
|
717
|
+
if langs:
|
|
718
|
+
lang_branch = tree.root.add(f"Languages ({len(langs)})")
|
|
719
|
+
for lang, count in sorted(langs.items(), key=lambda x: -x[1])[:10]:
|
|
720
|
+
lang_branch.add_leaf(f"{lang} ({count} repos)")
|
|
721
|
+
if len(langs) > 10:
|
|
722
|
+
lang_branch.add_leaf(f"[dim]... {len(langs) - 10} more[/dim]")
|
|
723
|
+
techs = self.profile.get("skills_summary", {}).get("tech_stack", [])
|
|
724
|
+
if techs:
|
|
725
|
+
tech_branch = tree.root.add(f"Tech Stack ({len(techs)})")
|
|
726
|
+
for tech in sorted(techs)[:15]:
|
|
727
|
+
tech_branch.add_leaf(tech)
|
|
728
|
+
if len(techs) > 15:
|
|
729
|
+
tech_branch.add_leaf(f"[dim]... {len(techs) - 15} more[/dim]")
|
|
730
|
+
doms = self.profile.get("skills_summary", {}).get("domains", [])
|
|
731
|
+
if doms:
|
|
732
|
+
dom_branch = tree.root.add(f"Domains ({len(doms)})")
|
|
733
|
+
for dom in doms:
|
|
734
|
+
dom_branch.add_leaf(dom)
|
|
735
|
+
infra = self.profile.get("skills_summary", {}).get("infrastructure", [])
|
|
736
|
+
if infra:
|
|
737
|
+
infra_branch = tree.root.add(f"Infrastructure ({len(infra)})")
|
|
738
|
+
for i in infra:
|
|
739
|
+
infra_branch.add_leaf(i)
|
|
740
|
+
if repos:
|
|
741
|
+
rbranch = tree.root.add(f"All Repos ({len(repos)})")
|
|
742
|
+
for repo in repos:
|
|
743
|
+
lang = repo.get("language") or "?"
|
|
744
|
+
desc = truncate(repo.get("summary") or "", 60)
|
|
745
|
+
rbranch.add_leaf(f"[{lang}] {repo['name']} \u2014 {desc}")
|
|
746
|
+
except Exception:
|
|
747
|
+
pass
|
|
748
|
+
|
|
749
|
+
# ── Load saved scans + tailored CVs from SQLite (background) ───────
|
|
750
|
+
@work(thread=True, exclusive=True, group="load_scans")
|
|
751
|
+
def _load_saved_scans_worker(self) -> None:
|
|
752
|
+
job_count = 0
|
|
753
|
+
scan_count = 0
|
|
754
|
+
analysis_count = 0
|
|
755
|
+
tailored_count = 0
|
|
756
|
+
|
|
757
|
+
db_jobs = get_all_jobs(limit=500)
|
|
758
|
+
for job in db_jobs:
|
|
759
|
+
self.call_from_thread(self._add_job_from_data, job, source=job.get("source", "db"), persist=False)
|
|
760
|
+
job_count += 1
|
|
761
|
+
|
|
762
|
+
if SCANS_DIR.exists():
|
|
763
|
+
for f in sorted(SCANS_DIR.glob("*.json"))[-5:]:
|
|
764
|
+
try:
|
|
765
|
+
data = json.loads(f.read_text())
|
|
766
|
+
items = data if isinstance(data, list) else data.get("jobs", [])
|
|
767
|
+
for job in items:
|
|
768
|
+
if not any(j.get("title", "").lower() == job.get("title", "").lower()
|
|
769
|
+
and j.get("company", "").lower() == job.get("company", "").lower()
|
|
770
|
+
for j in db_jobs):
|
|
771
|
+
self.call_from_thread(self._add_job_from_data, job, source=f.stem)
|
|
772
|
+
scan_count += 1
|
|
773
|
+
except Exception as e:
|
|
774
|
+
self.call_from_thread(self._log, f"[red]Skip {f.name}: {e}[/red]")
|
|
775
|
+
|
|
776
|
+
if ANALYSES_DIR.exists():
|
|
777
|
+
for f in sorted(ANALYSES_DIR.glob("*.json"))[-10:]:
|
|
778
|
+
try:
|
|
779
|
+
data = json.loads(f.read_text())
|
|
780
|
+
if not any(j.get("title", "").lower() == data.get("role_title", "").lower()
|
|
781
|
+
and j.get("company", "").lower() == data.get("company_name", "").lower()
|
|
782
|
+
for j in db_jobs):
|
|
783
|
+
self.call_from_thread(self._add_job_from_data, data, source="analysis")
|
|
784
|
+
analysis_count += 1
|
|
785
|
+
except Exception:
|
|
786
|
+
pass
|
|
787
|
+
|
|
788
|
+
db_tailored = get_tailored_cvs(limit=50)
|
|
789
|
+
for tc in db_tailored:
|
|
790
|
+
self.call_from_thread(self._add_tailored_cv_from_db, tc)
|
|
791
|
+
tailored_count += 1
|
|
792
|
+
|
|
793
|
+
if db_tailored:
|
|
794
|
+
self.call_from_thread(self._refresh_tailored_table)
|
|
795
|
+
|
|
796
|
+
total = job_count + scan_count + analysis_count + tailored_count
|
|
797
|
+
if total == 0:
|
|
798
|
+
self.call_from_thread(self._log, "[dim]No saved jobs. Press [bold]Ctrl+S[/bold] to scan or [bold]Ctrl+N[/bold] to paste a job.[/dim]")
|
|
799
|
+
else:
|
|
800
|
+
parts = []
|
|
801
|
+
if job_count:
|
|
802
|
+
parts.append(f"{job_count} jobs")
|
|
803
|
+
if scan_count:
|
|
804
|
+
parts.append(f"{scan_count} from scans")
|
|
805
|
+
if analysis_count:
|
|
806
|
+
parts.append(f"{analysis_count} from analyses")
|
|
807
|
+
if tailored_count:
|
|
808
|
+
parts.append(f"{tailored_count} tailored CVs")
|
|
809
|
+
self.call_from_thread(self._log, f"[green]Loaded {', '.join(parts)}.[/green]")
|
|
810
|
+
self.call_from_thread(self._show_loaded_jobs)
|
|
811
|
+
self.call_from_thread(self._rescore_all)
|
|
812
|
+
|
|
813
|
+
# ── Add job (memory + SQLite) ───────────────────────────────────────
|
|
814
|
+
def _add_job_from_data(self, data: dict, source: str = "scan", persist: bool = True) -> None:
|
|
815
|
+
if not isinstance(data, dict):
|
|
816
|
+
return
|
|
817
|
+
title = safe_str(data.get("title") or data.get("role_title") or data.get("job_title"), "Unknown")
|
|
818
|
+
company = safe_str(data.get("company") or data.get("company_name"), "Unknown")
|
|
819
|
+
key = f"{title.lower().strip()}|{company.lower().strip()}"
|
|
820
|
+
for existing in self.jobs:
|
|
821
|
+
ek = f"{existing['title'].lower().strip()}|{existing['company'].lower().strip()}"
|
|
822
|
+
if ek == key:
|
|
823
|
+
return
|
|
824
|
+
tags = data.get("tags", [])
|
|
825
|
+
if isinstance(tags, str):
|
|
826
|
+
tags = [t.strip() for t in tags.split(",") if t.strip()]
|
|
827
|
+
job = {
|
|
828
|
+
"title": title,
|
|
829
|
+
"company": company,
|
|
830
|
+
"url": data.get("url") or data.get("apply_url") or "",
|
|
831
|
+
"location": data.get("location") or "Remote",
|
|
832
|
+
"description": data.get("description") or data.get("jd_text") or data.get("raw_text") or "",
|
|
833
|
+
"salary": data.get("salary") or data.get("salary_range") or "",
|
|
834
|
+
"source": source,
|
|
835
|
+
"remote": bool(data.get("remote", False)),
|
|
836
|
+
"tags": tags if isinstance(tags, list) else [],
|
|
837
|
+
"score": data.get("score", 0),
|
|
838
|
+
"score_detail": data.get("score_detail") or {},
|
|
839
|
+
}
|
|
840
|
+
self.jobs.append(job)
|
|
841
|
+
if persist:
|
|
842
|
+
try:
|
|
843
|
+
save_jobs([job])
|
|
844
|
+
except Exception:
|
|
845
|
+
pass
|
|
846
|
+
|
|
847
|
+
# ── Display loaded jobs immediately (before background scoring) ────
|
|
848
|
+
def _show_loaded_jobs(self) -> None:
|
|
849
|
+
self.scored_jobs = sorted(self.jobs, key=lambda j: -j.get("score", 0))
|
|
850
|
+
self._refresh_job_table()
|
|
851
|
+
|
|
852
|
+
# ── Scoring (background) ────────────────────────────────────────────
|
|
853
|
+
def _rescore_all(self) -> None:
|
|
854
|
+
if self._rescoreing:
|
|
855
|
+
return
|
|
856
|
+
self._rescoreing = True
|
|
857
|
+
self._update_status()
|
|
858
|
+
self._rescore_all_worker()
|
|
859
|
+
|
|
860
|
+
@work(thread=True, exclusive=True, group="rescore")
|
|
861
|
+
def _rescore_all_worker(self) -> None:
|
|
862
|
+
for job in self.jobs:
|
|
863
|
+
try:
|
|
864
|
+
text = job.get("description") or f"{job.get('title', '')} {job.get('company', '')}"
|
|
865
|
+
result = score_job(text, self.profile, CV_PATH)
|
|
866
|
+
job["score"] = result.get("total_score", 0)
|
|
867
|
+
job["score_detail"] = result
|
|
868
|
+
except Exception:
|
|
869
|
+
job["score"] = 0
|
|
870
|
+
job["score_detail"] = {}
|
|
871
|
+
try:
|
|
872
|
+
job_id = compute_job_id(job.get("title", ""), job.get("company", ""), job.get("url", ""), job.get("source", ""))
|
|
873
|
+
update_job_score(job_id, job["score"], job["score_detail"])
|
|
874
|
+
except Exception:
|
|
875
|
+
pass
|
|
876
|
+
|
|
877
|
+
def _finish() -> None:
|
|
878
|
+
self.scored_jobs = sorted(self.jobs, key=lambda j: -j.get("score", 0))
|
|
879
|
+
self._rescoreing = False
|
|
880
|
+
self._refresh_job_table()
|
|
881
|
+
self.notify(f"Scored {len(self.jobs)} jobs", timeout=2)
|
|
882
|
+
self.call_from_thread(_finish)
|
|
883
|
+
|
|
884
|
+
# ── Job detail ───────────────────────────────────────────────────────
|
|
885
|
+
def _enrich_job(self, job: dict) -> dict:
|
|
886
|
+
"""Compute enriched analysis for a job (gaps, experience fit, repo angles, seniority).
|
|
887
|
+
Caches the result on the job dict so subsequent views are instant.
|
|
888
|
+
Handles missing CV/profile gracefully with clear placeholders.
|
|
889
|
+
"""
|
|
890
|
+
if job.get("_enriched"):
|
|
891
|
+
return job
|
|
892
|
+
try:
|
|
893
|
+
s = self._setup_status
|
|
894
|
+
desc = job.get("description", "")
|
|
895
|
+
if not desc:
|
|
896
|
+
job["_enriched"] = True
|
|
897
|
+
job["_gaps"] = []
|
|
898
|
+
job["_exp_fit"] = []
|
|
899
|
+
job["_repo_angles"] = []
|
|
900
|
+
job["_seniority_detail"] = {}
|
|
901
|
+
job["_setup_notes"] = []
|
|
902
|
+
return job
|
|
903
|
+
|
|
904
|
+
setup_notes: list[str] = []
|
|
905
|
+
if not s["cv_exists"]:
|
|
906
|
+
setup_notes.append(f"No master CV at {s['cv_path']} \u2014 scoring limited to profile match only")
|
|
907
|
+
if s["profile_exists"] and not s["has_repos"]:
|
|
908
|
+
setup_notes.append("GitHub profile has no repos \u2014 repo matching unavailable")
|
|
909
|
+
|
|
910
|
+
signals = extract_job_signals(desc, self.profile)
|
|
911
|
+
cv = load_cv_yaml(CV_PATH) if s["cv_exists"] else {}
|
|
912
|
+
job["_seniority_detail"] = analyze_seniority(desc)
|
|
913
|
+
job["_exp_fit"] = analyze_experience_relevance(cv, signals, desc) if cv else []
|
|
914
|
+
job["_gaps"] = find_gaps(signals, cv) if cv else []
|
|
915
|
+
job["_repo_angles"] = analyze_repos_for_jd(self.profile, signals) if s["has_repos"] else []
|
|
916
|
+
job["_setup_notes"] = setup_notes
|
|
917
|
+
job["_enriched"] = True
|
|
918
|
+
except Exception:
|
|
919
|
+
job["_enriched"] = True
|
|
920
|
+
for k in ("_gaps", "_exp_fit", "_repo_angles", "_seniority_detail", "_setup_notes"):
|
|
921
|
+
job.setdefault(k, [] if k != "_seniority_detail" else {})
|
|
922
|
+
return job
|
|
923
|
+
|
|
924
|
+
def _show_job_detail(self, idx: int) -> None:
|
|
925
|
+
if idx < 0 or idx >= len(self.filtered_jobs):
|
|
926
|
+
return
|
|
927
|
+
self.current_job_idx = idx
|
|
928
|
+
job = self.filtered_jobs[idx]
|
|
929
|
+
try:
|
|
930
|
+
self.query_one("#empty_state", Static).display = False
|
|
931
|
+
except Exception:
|
|
932
|
+
pass
|
|
933
|
+
try:
|
|
934
|
+
detail = self.query_one("#score_detail", RichLog)
|
|
935
|
+
detail.clear()
|
|
936
|
+
job = self._enrich_job(job)
|
|
937
|
+
|
|
938
|
+
total = job.get("score", 0)
|
|
939
|
+
sd = job.get("score_detail", {})
|
|
940
|
+
bd = sd.get("breakdown", {})
|
|
941
|
+
gaps = job.get("_gaps", [])
|
|
942
|
+
exp_fit = job.get("_exp_fit", [])
|
|
943
|
+
repo_angles = job.get("_repo_angles", [])
|
|
944
|
+
sen_detail = job.get("_seniority_detail", {})
|
|
945
|
+
|
|
946
|
+
def _bar(val: int, mx: int = 25, label: str = "") -> str:
|
|
947
|
+
filled = int((val / mx) * 12)
|
|
948
|
+
return f" {label:<13} {'█' * filled}{'░' * (12 - filled)} {val}/{mx}"
|
|
949
|
+
|
|
950
|
+
def _indicator(val: int, mx: int = 25) -> str:
|
|
951
|
+
ratio = val / max(mx, 1)
|
|
952
|
+
if ratio >= 0.8:
|
|
953
|
+
return "[green]\u2713[/green]"
|
|
954
|
+
elif ratio >= 0.4:
|
|
955
|
+
return "[yellow]\u25b3[/yellow]"
|
|
956
|
+
return "[red]\u2717[/red]"
|
|
957
|
+
|
|
958
|
+
# ═══════════════════════════════════════════════
|
|
959
|
+
# CARD: Job Header
|
|
960
|
+
# ═══════════════════════════════════════════════
|
|
961
|
+
detail.write(f"[bold]{'━' * 52}[/bold]")
|
|
962
|
+
detail.write(f"[bold blue]{job.get('title', 'Unknown')}[/bold blue]")
|
|
963
|
+
detail.write(f"[bold]{job.get('company', 'Unknown')}[/bold] [dim]\u2014[/dim] {job.get('location', 'Unknown')}")
|
|
964
|
+
if job.get("salary"):
|
|
965
|
+
detail.write(f"[dim]\u25c8[/dim] Salary: {job['salary']}")
|
|
966
|
+
remote_tag = "[green]\u2611 Remote[/green]" if job.get("remote") else "[dim]\u2610 On-site[/dim]"
|
|
967
|
+
sen_tag = f"[bold cyan]{sen_detail.get('level', '?').upper()}[/bold cyan]" if sen_detail else ""
|
|
968
|
+
detail.write(f"[dim]\u25c8[/dim] {remote_tag} [dim]\u25c8[/dim] {sen_tag} [dim]\u25c8[/dim] Source: {job.get('source', '?')}")
|
|
969
|
+
if job.get("url"):
|
|
970
|
+
detail.write(f"[dim]\u25c8[/dim] URL: [link={job['url']}]{truncate(job['url'], 50)}[/link]")
|
|
971
|
+
if job.get("tags"):
|
|
972
|
+
detail.write(f"[dim]\u25c8[/dim] Tags: [dim]{', '.join(job['tags'][:8])}[/dim]")
|
|
973
|
+
detail.write(f"[bold]{'━' * 52}[/bold]")
|
|
974
|
+
|
|
975
|
+
# ═══════════════════════════════════════════════
|
|
976
|
+
# CARD: Setup Notes (missing CV/profile)
|
|
977
|
+
# ═══════════════════════════════════════════════
|
|
978
|
+
setup_notes = job.get("_setup_notes", [])
|
|
979
|
+
if setup_notes:
|
|
980
|
+
detail.write("")
|
|
981
|
+
for note in setup_notes:
|
|
982
|
+
detail.write(f" [yellow]\u26a0 {note}[/yellow]")
|
|
983
|
+
|
|
984
|
+
# ═══════════════════════════════════════════════
|
|
985
|
+
# CARD: Overall Score
|
|
986
|
+
# ═══════════════════════════════════════════════
|
|
987
|
+
detail.write("")
|
|
988
|
+
detail.write(f" {score_summary(total)}")
|
|
989
|
+
detail.write(f" {score_bar(total, width=28)}")
|
|
990
|
+
detail.write("")
|
|
991
|
+
|
|
992
|
+
# ═══════════════════════════════════════════════
|
|
993
|
+
# CARD: Score Breakdown
|
|
994
|
+
# ═══════════════════════════════════════════════
|
|
995
|
+
detail.write("[bold] Score Breakdown:[/bold]")
|
|
996
|
+
cats = [("Languages", bd.get("languages", 0)),
|
|
997
|
+
("Frameworks", bd.get("frameworks", 0)),
|
|
998
|
+
("Domains", bd.get("domains", 0)),
|
|
999
|
+
("CV Alignment", bd.get("cv_alignment", 0))]
|
|
1000
|
+
for label, val in cats:
|
|
1001
|
+
detail.write(f" {_bar(val, label=label)} {_indicator(val)}")
|
|
1002
|
+
|
|
1003
|
+
# ═══════════════════════════════════════════════
|
|
1004
|
+
# CARD: Matched Signals
|
|
1005
|
+
# ═══════════════════════════════════════════════
|
|
1006
|
+
detail.write("")
|
|
1007
|
+
detail.write("[bold] Matched Signals:[/bold]")
|
|
1008
|
+
matched_langs = sd.get("matched_languages", [])
|
|
1009
|
+
matched_fws = sd.get("matched_frameworks", [])
|
|
1010
|
+
matched_doms = sd.get("matched_domains", [])
|
|
1011
|
+
if matched_langs:
|
|
1012
|
+
detail.write(f" [green]\u2713[/green] Languages: {', '.join(matched_langs[:6])}")
|
|
1013
|
+
if matched_fws:
|
|
1014
|
+
detail.write(f" [green]\u2713[/green] Frameworks: {', '.join(matched_fws[:6])}")
|
|
1015
|
+
if matched_doms:
|
|
1016
|
+
detail.write(f" [yellow]\u25b3[/yellow] Domains: {', '.join(matched_doms[:4])}")
|
|
1017
|
+
|
|
1018
|
+
max_kw = config.get("max_cv_keywords_in_detail", 5)
|
|
1019
|
+
matched_cv = sd.get("matched_cv_keywords", [])[:max_kw]
|
|
1020
|
+
if matched_cv:
|
|
1021
|
+
detail.write(f" [green]\u2713[/green] CV Match: {', '.join(matched_cv)}")
|
|
1022
|
+
|
|
1023
|
+
repos = sd.get("matching_repos", [])
|
|
1024
|
+
if repos:
|
|
1025
|
+
detail.write(f" [green]\u2713[/green] Repos: {', '.join(repos[:4])}")
|
|
1026
|
+
|
|
1027
|
+
# ═══════════════════════════════════════════════
|
|
1028
|
+
# CARD: Gap Analysis
|
|
1029
|
+
# ═══════════════════════════════════════════════
|
|
1030
|
+
if gaps:
|
|
1031
|
+
detail.write("")
|
|
1032
|
+
detail.write(f"[bold] Missing Gaps ({len(gaps)}):[/bold]")
|
|
1033
|
+
high_gaps = [g for g in gaps if g.get("severity") == "high"]
|
|
1034
|
+
med_gaps = [g for g in gaps if g.get("severity") != "high"]
|
|
1035
|
+
for g in (high_gaps + med_gaps)[:8]:
|
|
1036
|
+
sev = "[red]\u2717[/red]" if g.get("severity") == "high" else "[yellow]\u25b3[/yellow]"
|
|
1037
|
+
cat = g.get("category", "?")[:8]
|
|
1038
|
+
mit = g.get("mitigation", "")[:30]
|
|
1039
|
+
detail.write(f" {sev} [bold]{g['term']}[/bold] [dim]({cat})[/dim] [dim]\u2014[/dim] [italic]{mit}[/italic]")
|
|
1040
|
+
if len(gaps) > 8:
|
|
1041
|
+
detail.write(f" [dim]... and {len(gaps) - 8} more gaps[/dim]")
|
|
1042
|
+
else:
|
|
1043
|
+
detail.write("")
|
|
1044
|
+
detail.write(" [bold] Missing Gaps:[/bold] [green]\u2714 None detected[/green]")
|
|
1045
|
+
|
|
1046
|
+
# ═══════════════════════════════════════════════
|
|
1047
|
+
# CARD: Experience Fit
|
|
1048
|
+
# ═══════════════════════════════════════════════
|
|
1049
|
+
if exp_fit:
|
|
1050
|
+
detail.write("")
|
|
1051
|
+
detail.write(f"[bold] Experience Fit ({len(exp_fit)} entries):[/bold]")
|
|
1052
|
+
for exp in exp_fit[:5]:
|
|
1053
|
+
score = exp.get("relevance_score", 0)
|
|
1054
|
+
col = "green" if score >= 60 else "yellow" if score >= 30 else "dim"
|
|
1055
|
+
kws = ", ".join(exp["keyword_matches"][:4]) if exp.get("keyword_matches") else ""
|
|
1056
|
+
detail.write(f" [{col}]\u2605[/{col}] [bold]{exp['company']}[/bold] \u2014 {exp['position']}")
|
|
1057
|
+
detail.write(f" Fit: [bold]{score}[/bold]/100 Keywords: [dim]{kws}[/dim]")
|
|
1058
|
+
|
|
1059
|
+
# ═══════════════════════════════════════════════
|
|
1060
|
+
# CARD: Repo Angles (CV suggestions)
|
|
1061
|
+
# ═══════════════════════════════════════════════
|
|
1062
|
+
if repo_angles:
|
|
1063
|
+
detail.write("")
|
|
1064
|
+
detail.write(f"[bold] Repo Matches ({len(repo_angles)}):[/bold]")
|
|
1065
|
+
for ra in repo_angles[:4]:
|
|
1066
|
+
detail.write(f" [green]\u2605[/green] [bold]{ra['repo_name']}[/bold] [dim]({ra.get('language', '?')})[/dim]")
|
|
1067
|
+
matched = ", ".join(ra.get("matched_terms", [])[:4])
|
|
1068
|
+
if matched:
|
|
1069
|
+
detail.write(f" [dim]{matched}[/dim]")
|
|
1070
|
+
if ra.get("suggested_highlight_for_cv"):
|
|
1071
|
+
detail.write(f" [italic]{ra['suggested_highlight_for_cv'][:80]}[/italic]")
|
|
1072
|
+
|
|
1073
|
+
# ═══════════════════════════════════════════════
|
|
1074
|
+
# CARD: Seniority & Context
|
|
1075
|
+
# ═══════════════════════════════════════════════
|
|
1076
|
+
if sen_detail:
|
|
1077
|
+
detail.write("")
|
|
1078
|
+
detail.write("[bold] Role Context:[/bold]")
|
|
1079
|
+
yrs = sen_detail.get("years_required", 0)
|
|
1080
|
+
level = sen_detail.get("level", "?").title()
|
|
1081
|
+
leadership = sen_detail.get("leadership", False)
|
|
1082
|
+
mentorship = sen_detail.get("mentorship", False)
|
|
1083
|
+
autonomy = sen_detail.get("autonomy", False)
|
|
1084
|
+
detail.write(f" Level: [bold]{level}[/bold] Years req: [bold]{yrs}[/bold]")
|
|
1085
|
+
extras = []
|
|
1086
|
+
if leadership:
|
|
1087
|
+
extras.append("[green]\u2713[/green] Leadership")
|
|
1088
|
+
if mentorship:
|
|
1089
|
+
extras.append("[green]\u2713[/green] Mentorship")
|
|
1090
|
+
if autonomy:
|
|
1091
|
+
extras.append("[yellow]\u25b3[/yellow] Autonomy")
|
|
1092
|
+
if extras:
|
|
1093
|
+
detail.write(f" {', '.join(extras)}")
|
|
1094
|
+
|
|
1095
|
+
# ═══════════════════════════════════════════════
|
|
1096
|
+
# CARD: Tailoring Recommendation
|
|
1097
|
+
# ═══════════════════════════════════════════════
|
|
1098
|
+
rec = sd.get("recommendation", "")
|
|
1099
|
+
if rec:
|
|
1100
|
+
detail.write("")
|
|
1101
|
+
detail.write(" [bold green]\u25b6[/bold green] [bold]Tailoring Rec:[/bold]")
|
|
1102
|
+
detail.write(f" [italic]{rec}[/italic]")
|
|
1103
|
+
|
|
1104
|
+
# ═══════════════════════════════════════════════
|
|
1105
|
+
# CARD: Quick Tailor Action Summary
|
|
1106
|
+
# ═══════════════════════════════════════════════
|
|
1107
|
+
actions: list[str] = []
|
|
1108
|
+
if gaps:
|
|
1109
|
+
n_high = len([g for g in gaps if g.get("severity") == "high"])
|
|
1110
|
+
actions.append(f"Address {n_high} high-severity gap{'s' if n_high != 1 else ''} (omit/reframe)")
|
|
1111
|
+
if exp_fit and exp_fit[0].get("suggested_order_priority", 0) > 1:
|
|
1112
|
+
actions.append(f"Reorder experience: lead with {exp_fit[0]['company']}")
|
|
1113
|
+
if repo_angles:
|
|
1114
|
+
actions.append(f"Incorporate {repo_angles[0]['repo_name']} as project evidence")
|
|
1115
|
+
if sd.get("matched_cv_keywords"):
|
|
1116
|
+
matched_set = set(sd.get("matched_cv_keywords", []))
|
|
1117
|
+
jd_lower = (job.get("description", "") or "").lower()
|
|
1118
|
+
missing_ats = [kw for kw in (sd.get("matched_languages", []) + sd.get("matched_frameworks", []))
|
|
1119
|
+
if kw not in matched_set and kw in jd_lower]
|
|
1120
|
+
if missing_ats:
|
|
1121
|
+
actions.append(f"Add {missing_ats[0]} to CV keywords for ATS")
|
|
1122
|
+
if actions:
|
|
1123
|
+
detail.write("")
|
|
1124
|
+
detail.write(" [bold]Actions:[/bold]")
|
|
1125
|
+
for a in actions[:4]:
|
|
1126
|
+
detail.write(f" [dim]\u2192[/dim] [italic]{a}[/italic]")
|
|
1127
|
+
|
|
1128
|
+
# ═══════════════════════════════════════════════
|
|
1129
|
+
# CARD: Description (collapsed)
|
|
1130
|
+
# ═══════════════════════════════════════════════
|
|
1131
|
+
desc = job.get("description", "")
|
|
1132
|
+
if desc:
|
|
1133
|
+
detail.write("")
|
|
1134
|
+
detail.write(f"[dim]Raw JD ({len(desc)} chars):[/dim]")
|
|
1135
|
+
detail.write(f"[dim]{desc[:1200]}[/dim]")
|
|
1136
|
+
except Exception:
|
|
1137
|
+
pass
|
|
1138
|
+
self._update_status()
|
|
1139
|
+
|
|
1140
|
+
# ── Search ───────────────────────────────────────────────────────────
|
|
1141
|
+
@on(Input.Changed, "#search_input")
|
|
1142
|
+
def on_search_changed(self, event: Input.Changed) -> None:
|
|
1143
|
+
self._search_query = event.value.strip()
|
|
1144
|
+
self.current_job_idx = None
|
|
1145
|
+
self._refresh_job_table()
|
|
1146
|
+
try:
|
|
1147
|
+
self.query_one("#score_detail", RichLog).clear()
|
|
1148
|
+
except Exception:
|
|
1149
|
+
pass
|
|
1150
|
+
|
|
1151
|
+
@on(Input.Submitted, "#search_input")
|
|
1152
|
+
def on_search_submitted(self, event: Input.Submitted) -> None:
|
|
1153
|
+
if self.filtered_jobs:
|
|
1154
|
+
self.current_job_idx = 0
|
|
1155
|
+
self._show_job_detail(0)
|
|
1156
|
+
try:
|
|
1157
|
+
self.query_one("#job_table", DataTable).focus()
|
|
1158
|
+
except Exception:
|
|
1159
|
+
pass
|
|
1160
|
+
|
|
1161
|
+
def action_focus_search(self) -> None:
|
|
1162
|
+
try:
|
|
1163
|
+
self.query_one("#main_tabs", TabbedContent).active = "tab_jobs"
|
|
1164
|
+
self.query_one("#search_input", Input).focus()
|
|
1165
|
+
except Exception:
|
|
1166
|
+
pass
|
|
1167
|
+
|
|
1168
|
+
# ── Events: table ────────────────────────────────────────────────────
|
|
1169
|
+
@on(DataTable.RowSelected, "#job_table")
|
|
1170
|
+
def on_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
1171
|
+
if event.cursor_row is not None:
|
|
1172
|
+
self._show_job_detail(event.cursor_row)
|
|
1173
|
+
|
|
1174
|
+
@on(DataTable.RowSelected, "#tailored_table")
|
|
1175
|
+
def on_tailored_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
1176
|
+
if event.cursor_row is not None:
|
|
1177
|
+
self._show_tailored_diff(event.cursor_row)
|
|
1178
|
+
|
|
1179
|
+
# ── Actions ──────────────────────────────────────────────────────────
|
|
1180
|
+
def action_quit(self) -> None:
|
|
1181
|
+
self.push_screen(ConfirmQuitScreen(), self._on_quit_result)
|
|
1182
|
+
|
|
1183
|
+
def _on_quit_result(self, result: bool | None) -> None:
|
|
1184
|
+
if result is True:
|
|
1185
|
+
self._emergency_cleanup()
|
|
1186
|
+
self.exit()
|
|
1187
|
+
|
|
1188
|
+
def action_scan_jobs(self) -> None:
|
|
1189
|
+
if self._scanning:
|
|
1190
|
+
self.notify("Scan already in progress", timeout=2)
|
|
1191
|
+
return
|
|
1192
|
+
try:
|
|
1193
|
+
self.query_one("#main_tabs", TabbedContent).active = "tab_scanner"
|
|
1194
|
+
except Exception:
|
|
1195
|
+
pass
|
|
1196
|
+
self._scanning = True
|
|
1197
|
+
queries = config.scan_queries()
|
|
1198
|
+
platforms = config.scan_platforms()
|
|
1199
|
+
self._scan_count = 0
|
|
1200
|
+
self._scan_total = len(queries)
|
|
1201
|
+
self._update_status()
|
|
1202
|
+
self._log(f"[bold]{'━' * 50}[/bold]")
|
|
1203
|
+
self._log(f"[bold cyan]\u25b6 Starting scan[/bold cyan]")
|
|
1204
|
+
self._log(f" Platforms: [bold]{platforms}[/bold]")
|
|
1205
|
+
self._log(f" Queries: {', '.join(f'[{q}]' for q in queries)}")
|
|
1206
|
+
self._log(f"[bold]{'━' * 50}[/bold]")
|
|
1207
|
+
self._start_scan_worker(queries, platforms)
|
|
1208
|
+
|
|
1209
|
+
@work(thread=True, exclusive=True, group="scanner")
|
|
1210
|
+
def _start_scan_worker(self, queries: list[str], platforms: str) -> None:
|
|
1211
|
+
limit = config.scan_limit()
|
|
1212
|
+
timeout = config.scan_timeout()
|
|
1213
|
+
try:
|
|
1214
|
+
for query in queries:
|
|
1215
|
+
if self._shutting_down:
|
|
1216
|
+
self.call_from_thread(self._log, "[red] Aborting scan — app is shutting down[/red]")
|
|
1217
|
+
break
|
|
1218
|
+
self.call_from_thread(self._scan_count_increment, query)
|
|
1219
|
+
try:
|
|
1220
|
+
proc = subprocess.Popen(
|
|
1221
|
+
[
|
|
1222
|
+
str(VENV_PYTHON), "-m", "scripts.job_scanner.scanner",
|
|
1223
|
+
"--query", query,
|
|
1224
|
+
"--platforms", platforms,
|
|
1225
|
+
"--limit", str(limit),
|
|
1226
|
+
"--json",
|
|
1227
|
+
],
|
|
1228
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
1229
|
+
text=True, cwd=str(_CHAMELEON),
|
|
1230
|
+
)
|
|
1231
|
+
self._track_proc(proc)
|
|
1232
|
+
try:
|
|
1233
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
1234
|
+
except subprocess.TimeoutExpired:
|
|
1235
|
+
proc.kill()
|
|
1236
|
+
proc.wait(timeout=5)
|
|
1237
|
+
self.call_from_thread(self._log, f"[red] X {query}: timeout ({timeout}s)[/red]")
|
|
1238
|
+
continue
|
|
1239
|
+
finally:
|
|
1240
|
+
self._untrack_proc(proc)
|
|
1241
|
+
|
|
1242
|
+
if proc.returncode != 0:
|
|
1243
|
+
err = (stderr or "").strip()[:200]
|
|
1244
|
+
self.call_from_thread(self._log, f"[red] X {query}: {err or 'exit code ' + str(proc.returncode)}[/red]")
|
|
1245
|
+
continue
|
|
1246
|
+
stdout = (stdout or "").strip()
|
|
1247
|
+
if stdout:
|
|
1248
|
+
data = json.loads(stdout)
|
|
1249
|
+
jobs = data if isinstance(data, list) else data.get("jobs", [])
|
|
1250
|
+
for job in jobs:
|
|
1251
|
+
self.call_from_thread(self._add_job_from_data, job, source=f"scan:{query}")
|
|
1252
|
+
self.call_from_thread(self._log, f"[green] OK {query}: {len(jobs)} found[/green]")
|
|
1253
|
+
else:
|
|
1254
|
+
self.call_from_thread(self._log, f"[yellow] -> {query}: no results[/yellow]")
|
|
1255
|
+
except json.JSONDecodeError as e:
|
|
1256
|
+
self.call_from_thread(self._log, f"[red] X {query}: bad JSON: {e}[/red]")
|
|
1257
|
+
except Exception as e:
|
|
1258
|
+
self.call_from_thread(self._log, f"[red] X {query}: {e}[/red]")
|
|
1259
|
+
finally:
|
|
1260
|
+
self._scanning = False
|
|
1261
|
+
self.call_from_thread(self._update_status)
|
|
1262
|
+
|
|
1263
|
+
def _finish() -> None:
|
|
1264
|
+
self._show_loaded_jobs()
|
|
1265
|
+
self._rescore_all()
|
|
1266
|
+
self._log(f"[bold]{'━' * 50}[/bold]")
|
|
1267
|
+
self._log("[bold green]\u2714 Scan complete[/bold green]")
|
|
1268
|
+
self._log(f"[bold]{'━' * 50}[/bold]")
|
|
1269
|
+
self.notify("Scan complete", timeout=3)
|
|
1270
|
+
self.call_from_thread(_finish)
|
|
1271
|
+
|
|
1272
|
+
def _scan_count_increment(self, query: str) -> None:
|
|
1273
|
+
self._scan_count += 1
|
|
1274
|
+
self._update_status()
|
|
1275
|
+
self._log(f"[dim] -> {query}...[/dim]")
|
|
1276
|
+
|
|
1277
|
+
def action_rescore(self) -> None:
|
|
1278
|
+
self._rescore_all()
|
|
1279
|
+
|
|
1280
|
+
def action_edit_job(self) -> None:
|
|
1281
|
+
self._open_in_editor()
|
|
1282
|
+
|
|
1283
|
+
def action_tailor_cv(self) -> None:
|
|
1284
|
+
self._tailor_current()
|
|
1285
|
+
|
|
1286
|
+
def action_paste_job(self) -> None:
|
|
1287
|
+
self.push_screen(PasteJobScreen(), self._on_paste_result)
|
|
1288
|
+
|
|
1289
|
+
def action_delete_job(self) -> None:
|
|
1290
|
+
self._delete_current()
|
|
1291
|
+
|
|
1292
|
+
def action_open_url(self) -> None:
|
|
1293
|
+
self._open_url()
|
|
1294
|
+
|
|
1295
|
+
# ── Shutdown lifecycle ──────────────────────────────────────────────
|
|
1296
|
+
def on_unmount(self) -> None:
|
|
1297
|
+
"""Textual lifecycle hook — fires when the app is being torn down."""
|
|
1298
|
+
self._emergency_cleanup()
|
|
1299
|
+
|
|
1300
|
+
def action_help_screen(self) -> None:
|
|
1301
|
+
self.push_screen(HelpScreen())
|
|
1302
|
+
|
|
1303
|
+
def action_setup_wizard(self) -> None:
|
|
1304
|
+
"""Re-open the setup wizard (F3)."""
|
|
1305
|
+
from scripts.tui.screens import SetupWizardScreen
|
|
1306
|
+
|
|
1307
|
+
def _on_result(result: bool | None) -> None:
|
|
1308
|
+
if result:
|
|
1309
|
+
self._setup_status = self._check_setup()
|
|
1310
|
+
self._load_profile_tree()
|
|
1311
|
+
self._update_status()
|
|
1312
|
+
config.set("setup_wizard_completed", True)
|
|
1313
|
+
self.notify("[bold green]\u2714 Setup complete![/bold green]", timeout=4)
|
|
1314
|
+
try:
|
|
1315
|
+
self.query_one("#empty_state", Static).update(self._welcome_text())
|
|
1316
|
+
except Exception:
|
|
1317
|
+
pass
|
|
1318
|
+
else:
|
|
1319
|
+
self._setup_status = self._check_setup()
|
|
1320
|
+
self._update_status()
|
|
1321
|
+
|
|
1322
|
+
self.push_screen(SetupWizardScreen(), _on_result)
|
|
1323
|
+
|
|
1324
|
+
def action_refresh(self) -> None:
|
|
1325
|
+
self._rescore_all()
|
|
1326
|
+
|
|
1327
|
+
def action_scan_with_options(self) -> None:
|
|
1328
|
+
self.push_screen(CustomScanScreen())
|
|
1329
|
+
|
|
1330
|
+
# ── Editor ───────────────────────────────────────────────────────────
|
|
1331
|
+
def _open_in_editor(self) -> None:
|
|
1332
|
+
job = self._get_selected_job()
|
|
1333
|
+
if not job:
|
|
1334
|
+
self.notify("No job selected", timeout=2)
|
|
1335
|
+
return
|
|
1336
|
+
try:
|
|
1337
|
+
self.query_one("#main_tabs", TabbedContent).active = "tab_editor"
|
|
1338
|
+
try:
|
|
1339
|
+
self.query_one("#editor_placeholder", Static).display = False
|
|
1340
|
+
except Exception:
|
|
1341
|
+
pass
|
|
1342
|
+
editor = self.query_one("#editor_container", NanoEditor)
|
|
1343
|
+
text = job.get("description") or f"{job.get('title', '')} at {job.get('company', '')}\n\n{job.get('url', '')}"
|
|
1344
|
+
safe_co = job.get("company", "Unknown").replace(" ", "_").replace("/", "_")[:20]
|
|
1345
|
+
safe_title = job.get("title", "Unknown").replace(" ", "_").replace("/", "_")[:30]
|
|
1346
|
+
editor.load_text(text, filename=f"{safe_co}_{safe_title}.txt")
|
|
1347
|
+
except Exception:
|
|
1348
|
+
pass
|
|
1349
|
+
|
|
1350
|
+
def _save_editor_content(self) -> None:
|
|
1351
|
+
job = self._get_selected_job()
|
|
1352
|
+
if not job:
|
|
1353
|
+
return
|
|
1354
|
+
try:
|
|
1355
|
+
editor = self.query_one("#editor_container", NanoEditor)
|
|
1356
|
+
text = editor.get_text()
|
|
1357
|
+
job["description"] = text
|
|
1358
|
+
self._rescore_all()
|
|
1359
|
+
self.notify(f"Saved & re-scoring: {job.get('title', '?')}", timeout=2)
|
|
1360
|
+
except Exception as e:
|
|
1361
|
+
self.notify(f"Save failed: {e}", timeout=3)
|
|
1362
|
+
|
|
1363
|
+
# ── Tailor (background) ──────────────────────────────────────────────
|
|
1364
|
+
def _tailor_current(self) -> None:
|
|
1365
|
+
job = self._get_selected_job()
|
|
1366
|
+
if not job:
|
|
1367
|
+
self.notify("No job selected", timeout=2)
|
|
1368
|
+
return
|
|
1369
|
+
if self._tailor_running:
|
|
1370
|
+
self.notify("[yellow]Tailor already in progress — wait for it to finish[/yellow]", timeout=4)
|
|
1371
|
+
return
|
|
1372
|
+
self._tailor_running = True
|
|
1373
|
+
try:
|
|
1374
|
+
self.query_one("#main_tabs", TabbedContent).active = "tab_scanner"
|
|
1375
|
+
except Exception:
|
|
1376
|
+
pass
|
|
1377
|
+
try:
|
|
1378
|
+
self.query_one("#scan_log", RichLog).clear()
|
|
1379
|
+
except Exception:
|
|
1380
|
+
pass
|
|
1381
|
+
self._log(f"[bold]{'━' * 50}[/bold]")
|
|
1382
|
+
self._log(f"[bold cyan]\u2702 Tailoring CV:[/bold cyan] {job.get('title', '?')} at {job.get('company', '?')}")
|
|
1383
|
+
self._log(f" [bold]Score:[/bold] {job.get('score', 0)}/100")
|
|
1384
|
+
self._log(f"[bold]{'━' * 50}[/bold]")
|
|
1385
|
+
self._log("[yellow]Starting tailor pipeline — progress will appear below in real-time...[/yellow]")
|
|
1386
|
+
self.notify("[bold]Tailoring CV...[/bold] check Scanner tab for progress", timeout=5)
|
|
1387
|
+
self._run_tailor_worker(job)
|
|
1388
|
+
|
|
1389
|
+
@work(thread=True, exclusive=True, group="tailor")
|
|
1390
|
+
def _run_tailor_worker(self, job: dict) -> None:
|
|
1391
|
+
# Tee stdout so print(flush=True) from tailor_and_render appears in TUI log
|
|
1392
|
+
old_stdout = sys.stdout
|
|
1393
|
+
tee = TeeStream(sys.stdout, lambda line: None if self._shutting_down else self.call_from_thread(self._log, line))
|
|
1394
|
+
sys.stdout = tee
|
|
1395
|
+
try:
|
|
1396
|
+
ok, msg, path = tailor_and_render(
|
|
1397
|
+
job.get("description", ""),
|
|
1398
|
+
company=job.get("company", ""),
|
|
1399
|
+
title=job.get("title", ""),
|
|
1400
|
+
)
|
|
1401
|
+
if ok:
|
|
1402
|
+
yaml_path = ""
|
|
1403
|
+
pdf_path = ""
|
|
1404
|
+
for line in msg.split("\n"):
|
|
1405
|
+
trimmed = line.strip()
|
|
1406
|
+
if trimmed.startswith("YAML:"):
|
|
1407
|
+
yaml_path = trimmed.split(":", 1)[1].strip()
|
|
1408
|
+
elif trimmed.startswith("PDF:"):
|
|
1409
|
+
pdf_path = trimmed.split(":", 1)[1].strip()
|
|
1410
|
+
self.call_from_thread(self._add_tailored_cv, job, yaml_path, pdf_path)
|
|
1411
|
+
self.call_from_thread(self._log, "[bold green]\u2714 CV tailored successfully![/bold green]")
|
|
1412
|
+
self.call_from_thread(self._log, f"[green]{msg}[/green]")
|
|
1413
|
+
self.call_from_thread(self._crash_notify, "Tailored", "Check Tailored tab", severity="info")
|
|
1414
|
+
else:
|
|
1415
|
+
self.call_from_thread(self._crash_notify, "Tailor failed", msg[:200], severity="warning")
|
|
1416
|
+
except Exception as e:
|
|
1417
|
+
import traceback
|
|
1418
|
+
tb = traceback.format_exc()
|
|
1419
|
+
self.call_from_thread(self._crash_notify, "Tailor crashed", str(e)[:120], severity="error")
|
|
1420
|
+
self.call_from_thread(self._log, f"[red]{tb}[/red]")
|
|
1421
|
+
finally:
|
|
1422
|
+
sys.stdout = old_stdout
|
|
1423
|
+
self._tailor_running = False
|
|
1424
|
+
self.call_from_thread(self._update_status)
|
|
1425
|
+
|
|
1426
|
+
def _add_tailored_cv_from_db(self, tc: dict) -> None:
|
|
1427
|
+
key = f"{tc.get('company', '').lower().strip()}|{tc.get('title', '').lower().strip()}"
|
|
1428
|
+
for existing in self.tailored_cvs:
|
|
1429
|
+
ek = f"{existing.get('company', '').lower().strip()}|{existing.get('title', '').lower().strip()}"
|
|
1430
|
+
if ek == key:
|
|
1431
|
+
return
|
|
1432
|
+
self.tailored_cvs.append(tc)
|
|
1433
|
+
|
|
1434
|
+
def _add_tailored_cv(self, job: dict, yaml_path: str, pdf_path: str) -> None:
|
|
1435
|
+
key = f"{job.get('company', '').lower().strip()}|{job.get('title', '').lower().strip()}"
|
|
1436
|
+
for existing in self.tailored_cvs:
|
|
1437
|
+
ek = f"{existing.get('company', '').lower().strip()}|{existing.get('title', '').lower().strip()}"
|
|
1438
|
+
if ek == key:
|
|
1439
|
+
existing["yaml_path"] = yaml_path
|
|
1440
|
+
existing["pdf_path"] = pdf_path
|
|
1441
|
+
existing["score"] = job.get("score", 0)
|
|
1442
|
+
self._refresh_tailored_table()
|
|
1443
|
+
self._switch_to_tailored()
|
|
1444
|
+
return
|
|
1445
|
+
tc_entry = {
|
|
1446
|
+
"title": job.get("title", "Unknown"), "company": job.get("company", "Unknown"),
|
|
1447
|
+
"score": job.get("score", 0), "yaml_path": yaml_path, "pdf_path": pdf_path,
|
|
1448
|
+
"description": job.get("description", ""), "url": job.get("url", ""),
|
|
1449
|
+
"location": job.get("location", "Remote"), "remote": job.get("remote", False),
|
|
1450
|
+
"salary": job.get("salary", ""), "tags": job.get("tags", []),
|
|
1451
|
+
"source": job.get("source", "tailored"),
|
|
1452
|
+
}
|
|
1453
|
+
self.tailored_cvs.append(tc_entry)
|
|
1454
|
+
job_id = compute_job_id(job.get("title", ""), job.get("company", ""), job.get("url", ""), job.get("source", ""))
|
|
1455
|
+
try:
|
|
1456
|
+
save_tailored_cv(
|
|
1457
|
+
job_id=job_id,
|
|
1458
|
+
title=tc_entry["title"],
|
|
1459
|
+
company=tc_entry["company"],
|
|
1460
|
+
score=tc_entry["score"],
|
|
1461
|
+
yaml_path=str(yaml_path),
|
|
1462
|
+
pdf_path=str(pdf_path),
|
|
1463
|
+
description=tc_entry["description"],
|
|
1464
|
+
url=tc_entry["url"],
|
|
1465
|
+
location=tc_entry["location"],
|
|
1466
|
+
remote=tc_entry["remote"],
|
|
1467
|
+
salary=tc_entry["salary"],
|
|
1468
|
+
tags=tc_entry["tags"],
|
|
1469
|
+
source=tc_entry["source"],
|
|
1470
|
+
)
|
|
1471
|
+
except Exception as exc:
|
|
1472
|
+
self._log(f"[yellow]DB save warning: {exc}[/yellow]")
|
|
1473
|
+
self._refresh_tailored_table()
|
|
1474
|
+
self._switch_to_tailored()
|
|
1475
|
+
|
|
1476
|
+
def _switch_to_tailored(self) -> None:
|
|
1477
|
+
try:
|
|
1478
|
+
self.query_one("#main_tabs", TabbedContent).active = "tab_tailored"
|
|
1479
|
+
self.notify("[bold green]\u2702 Switched to Tailored tab[/bold green]", timeout=3)
|
|
1480
|
+
except Exception as exc:
|
|
1481
|
+
self._log(f"[red]Error switching to Tailored tab: {exc}[/red]")
|
|
1482
|
+
return
|
|
1483
|
+
if self.tailored_cvs:
|
|
1484
|
+
try:
|
|
1485
|
+
table = self.query_one("#tailored_table", DataTable)
|
|
1486
|
+
last = len(self.tailored_cvs) - 1
|
|
1487
|
+
table.move_cursor(row=last, column=0)
|
|
1488
|
+
self._show_tailored_diff(last)
|
|
1489
|
+
except Exception as exc:
|
|
1490
|
+
self._log(f"[red]Error selecting tailored row: {exc}[/red]")
|
|
1491
|
+
|
|
1492
|
+
# ── Delete (memory + SQLite) ────────────────────────────────────────
|
|
1493
|
+
def _delete_current(self) -> None:
|
|
1494
|
+
job = self._get_selected_job()
|
|
1495
|
+
if not job:
|
|
1496
|
+
self.notify("No job selected", timeout=2)
|
|
1497
|
+
return
|
|
1498
|
+
|
|
1499
|
+
def on_confirm(confirmed: bool | None) -> None:
|
|
1500
|
+
if not confirmed:
|
|
1501
|
+
return
|
|
1502
|
+
self.jobs = [j for j in self.jobs if j is not job]
|
|
1503
|
+
self.scored_jobs = [j for j in self.scored_jobs if j is not job]
|
|
1504
|
+
self.filtered_jobs = [j for j in self.filtered_jobs if j is not job]
|
|
1505
|
+
job_id = compute_job_id(job.get("title", ""), job.get("company", ""), job.get("url", ""), job.get("source", ""))
|
|
1506
|
+
try:
|
|
1507
|
+
delete_job(job_id)
|
|
1508
|
+
except Exception:
|
|
1509
|
+
pass
|
|
1510
|
+
if self.current_job_idx is not None:
|
|
1511
|
+
if self.current_job_idx >= len(self.filtered_jobs):
|
|
1512
|
+
self.current_job_idx = len(self.filtered_jobs) - 1 if self.filtered_jobs else None
|
|
1513
|
+
self._refresh_job_table()
|
|
1514
|
+
try:
|
|
1515
|
+
self.query_one("#score_detail", RichLog).clear()
|
|
1516
|
+
except Exception:
|
|
1517
|
+
pass
|
|
1518
|
+
self.notify(f"Deleted: {job.get('title', '?')}", timeout=2)
|
|
1519
|
+
|
|
1520
|
+
self.push_screen(
|
|
1521
|
+
DeleteConfirmScreen(job.get("title", "?"), job.get("company", "?")),
|
|
1522
|
+
on_confirm,
|
|
1523
|
+
)
|
|
1524
|
+
|
|
1525
|
+
# ── Open URL ─────────────────────────────────────────────────────────
|
|
1526
|
+
def _open_url(self) -> None:
|
|
1527
|
+
job = self._get_selected_job()
|
|
1528
|
+
if not job:
|
|
1529
|
+
self.notify("No job selected", timeout=2)
|
|
1530
|
+
return
|
|
1531
|
+
url = job.get("url")
|
|
1532
|
+
if not url:
|
|
1533
|
+
self.notify("No URL for this job", timeout=2)
|
|
1534
|
+
return
|
|
1535
|
+
try:
|
|
1536
|
+
import webbrowser
|
|
1537
|
+
webbrowser.open(url)
|
|
1538
|
+
self.notify(f"Opened: {truncate(url, 40)}", timeout=2)
|
|
1539
|
+
except Exception as e:
|
|
1540
|
+
self.notify(f"Failed: {e}", timeout=3)
|
|
1541
|
+
|
|
1542
|
+
# ── Paste ────────────────────────────────────────────────────────────
|
|
1543
|
+
def _on_paste_result(self, result: dict | None) -> None:
|
|
1544
|
+
if result:
|
|
1545
|
+
self._add_job_from_data(result, source="pasted")
|
|
1546
|
+
self._rescore_all()
|
|
1547
|
+
self.notify(f"Added: {result.get('title', 'Unknown')}", timeout=3)
|