@jsonresume/jobs 0.9.0 → 0.10.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/bin/cli.js +6 -4
- package/package.json +1 -1
- package/src/api.js +1 -1
- package/src/formatters.js +0 -20
- package/src/localApi.js +9 -3
- package/src/tui/App.js +17 -5
- package/src/tui/FilterManager.js +3 -7
- package/src/tui/HelpModal.js +16 -11
- package/src/tui/SearchManager.js +3 -3
- package/src/tui/StatusBar.js +18 -7
- package/src/tui/useAI.js +6 -2
- package/src/tui/PreviewPane.js +0 -68
package/bin/cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Just run: npx @jsonresume/jobs
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const VERSION = '0.
|
|
9
|
+
const VERSION = '0.10.0';
|
|
10
10
|
|
|
11
11
|
const BASE_URL =
|
|
12
12
|
getArg('--base-url') ||
|
|
@@ -118,10 +118,12 @@ function stateIcon(state) {
|
|
|
118
118
|
|
|
119
119
|
async function cmdSearch() {
|
|
120
120
|
const params = new URLSearchParams();
|
|
121
|
-
|
|
122
|
-
params.set('
|
|
121
|
+
const topArg = parseInt(getArg('--top')) || 20;
|
|
122
|
+
params.set('top', String(Math.min(Math.max(1, topArg), 100)));
|
|
123
|
+
params.set('days', String(parseInt(getArg('--days')) || 30));
|
|
123
124
|
if (hasFlag('--remote')) params.set('remote', 'true');
|
|
124
|
-
|
|
125
|
+
const minSalary = parseInt(getArg('--min-salary'));
|
|
126
|
+
if (minSalary > 0) params.set('min_salary', String(minSalary));
|
|
125
127
|
if (getArg('--search')) params.set('search', getArg('--search'));
|
|
126
128
|
|
|
127
129
|
const { jobs } = await api(`/jobs?${params}`);
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -40,7 +40,7 @@ export function createApiClient({ baseUrl, apiKey }) {
|
|
|
40
40
|
markJob: (id, state, feedback) =>
|
|
41
41
|
request(`/jobs/${id}`, {
|
|
42
42
|
method: 'PUT',
|
|
43
|
-
body: JSON.stringify({ state, feedback: feedback ||
|
|
43
|
+
body: JSON.stringify({ state, feedback: feedback || undefined }),
|
|
44
44
|
}),
|
|
45
45
|
fetchMe: () => request('/me'),
|
|
46
46
|
|
package/src/formatters.js
CHANGED
|
@@ -39,26 +39,6 @@ export function truncate(str, len) {
|
|
|
39
39
|
return str.length > len ? str.slice(0, len - 1) + '…' : str;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
* Render a score as a sparkline bar: ████░░░░ 0.72
|
|
44
|
-
* Uses Unicode block characters for sub-character precision.
|
|
45
|
-
*/
|
|
46
|
-
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
47
|
-
|
|
48
|
-
export function scoreBar(score, width = 6) {
|
|
49
|
-
if (typeof score !== 'number' || isNaN(score)) return '░'.repeat(width);
|
|
50
|
-
const clamped = Math.max(0, Math.min(1, score));
|
|
51
|
-
const filled = clamped * width;
|
|
52
|
-
const full = Math.floor(filled);
|
|
53
|
-
const frac = Math.round((filled - full) * 8);
|
|
54
|
-
const empty = width - full - (frac > 0 ? 1 : 0);
|
|
55
|
-
return (
|
|
56
|
-
'█'.repeat(full) +
|
|
57
|
-
(frac > 0 ? BLOCKS[frac] : '') +
|
|
58
|
-
'░'.repeat(Math.max(0, empty))
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
42
|
export function stateLabel(state) {
|
|
63
43
|
const labels = {
|
|
64
44
|
interested: 'Interested',
|
package/src/localApi.js
CHANGED
|
@@ -25,7 +25,13 @@ export function createLocalApiClient({ baseUrl, resume }) {
|
|
|
25
25
|
headers: { 'Content-Type': 'application/json' },
|
|
26
26
|
body: JSON.stringify(body),
|
|
27
27
|
});
|
|
28
|
-
const
|
|
28
|
+
const text = await res.text();
|
|
29
|
+
let data;
|
|
30
|
+
try {
|
|
31
|
+
data = JSON.parse(text);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(`Non-JSON response: ${res.status}`);
|
|
34
|
+
}
|
|
29
35
|
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
30
36
|
|
|
31
37
|
// Overlay local marks onto results
|
|
@@ -44,9 +50,9 @@ export function createLocalApiClient({ baseUrl, resume }) {
|
|
|
44
50
|
return { id };
|
|
45
51
|
},
|
|
46
52
|
|
|
47
|
-
markJob: async (id, state) => {
|
|
53
|
+
markJob: async (id, state, feedback) => {
|
|
48
54
|
setMark(id, state);
|
|
49
|
-
return { id, state };
|
|
55
|
+
return { id, state, feedback };
|
|
50
56
|
},
|
|
51
57
|
|
|
52
58
|
fetchMe: async () => ({ resume, username: 'local' }),
|
package/src/tui/App.js
CHANGED
|
@@ -36,9 +36,10 @@ const TAB_LABELS = {
|
|
|
36
36
|
function InlineSearch({ query, onChange, onSubmit }) {
|
|
37
37
|
return h(
|
|
38
38
|
Box,
|
|
39
|
-
{ paddingX: 1 },
|
|
40
|
-
h(Text, { color: 'yellow', bold: true }, 'Find:
|
|
41
|
-
h(TextInput, { value: query, onChange, onSubmit })
|
|
39
|
+
{ paddingX: 1, gap: 1 },
|
|
40
|
+
h(Text, { color: 'yellow', bold: true }, 'Find:'),
|
|
41
|
+
h(TextInput, { value: query, onChange, onSubmit }),
|
|
42
|
+
h(Text, { dimColor: true }, ' Enter to apply, Esc to clear')
|
|
42
43
|
);
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -106,7 +107,18 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
106
107
|
const jobs = useMemo(() => {
|
|
107
108
|
if (!appliedQuery) return rawJobs;
|
|
108
109
|
const q = appliedQuery.toLowerCase();
|
|
109
|
-
return rawJobs.filter((j) =>
|
|
110
|
+
return rawJobs.filter((j) => {
|
|
111
|
+
const fields = [
|
|
112
|
+
j.title,
|
|
113
|
+
j.company,
|
|
114
|
+
j.description,
|
|
115
|
+
j.remote,
|
|
116
|
+
j.location?.city,
|
|
117
|
+
j.location?.countryCode,
|
|
118
|
+
...(j.skills || []).map((s) => s.name || s),
|
|
119
|
+
];
|
|
120
|
+
return fields.some((f) => f && String(f).toLowerCase().includes(q));
|
|
121
|
+
});
|
|
110
122
|
}, [rawJobs, appliedQuery]);
|
|
111
123
|
|
|
112
124
|
useEffect(() => {
|
|
@@ -215,7 +227,7 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
215
227
|
const handleExport = () => {
|
|
216
228
|
try {
|
|
217
229
|
const filename = exportShortlist(allJobs);
|
|
218
|
-
showToast(`
|
|
230
|
+
showToast(`Saved ./${filename}`, 'export');
|
|
219
231
|
} catch (err) {
|
|
220
232
|
showToast(`Export failed: ${err.message}`, 'error');
|
|
221
233
|
}
|
package/src/tui/FilterManager.js
CHANGED
|
@@ -124,7 +124,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
|
|
|
124
124
|
...next[editIdx],
|
|
125
125
|
value:
|
|
126
126
|
filter.type === 'minSalary' || filter.type === 'days'
|
|
127
|
-
? parseInt(val) ||
|
|
127
|
+
? parseInt(val) || 0
|
|
128
128
|
: val,
|
|
129
129
|
};
|
|
130
130
|
onUpdate({ ...filterState, active: next });
|
|
@@ -132,11 +132,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
|
|
|
132
132
|
},
|
|
133
133
|
})
|
|
134
134
|
),
|
|
135
|
-
h(
|
|
136
|
-
Text,
|
|
137
|
-
{ dimColor: true, marginTop: 1 },
|
|
138
|
-
'Enter to save, Ctrl+C to cancel'
|
|
139
|
-
)
|
|
135
|
+
h(Text, { dimColor: true, marginTop: 1 }, 'Enter to save, Esc to cancel')
|
|
140
136
|
);
|
|
141
137
|
}
|
|
142
138
|
|
|
@@ -165,7 +161,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
|
|
|
165
161
|
if (val.trim()) {
|
|
166
162
|
const value =
|
|
167
163
|
ft.type === 'minSalary' || ft.type === 'days'
|
|
168
|
-
? parseInt(val) ||
|
|
164
|
+
? parseInt(val) || 0
|
|
169
165
|
: val;
|
|
170
166
|
onUpdate({
|
|
171
167
|
...filterState,
|
package/src/tui/HelpModal.js
CHANGED
|
@@ -7,10 +7,12 @@ const SECTIONS = [
|
|
|
7
7
|
keys: [
|
|
8
8
|
['j / ↓', 'Move down'],
|
|
9
9
|
['k / ↑', 'Move up'],
|
|
10
|
-
['
|
|
10
|
+
['g / G', 'Jump to first / last'],
|
|
11
|
+
['Ctrl+U / D', 'Page up / page down'],
|
|
12
|
+
['Enter', 'Open split-pane detail view'],
|
|
11
13
|
['Esc / q', 'Back / quit'],
|
|
12
|
-
['Tab', 'Next
|
|
13
|
-
['Shift+Tab', 'Previous
|
|
14
|
+
['Tab', 'Next tab'],
|
|
15
|
+
['Shift+Tab', 'Previous tab'],
|
|
14
16
|
],
|
|
15
17
|
},
|
|
16
18
|
{
|
|
@@ -20,28 +22,31 @@ const SECTIONS = [
|
|
|
20
22
|
['x', 'Mark applied'],
|
|
21
23
|
['m', 'Mark maybe'],
|
|
22
24
|
['p', 'Mark passed'],
|
|
25
|
+
['v', 'Toggle batch selection'],
|
|
23
26
|
],
|
|
24
27
|
},
|
|
25
28
|
{
|
|
26
29
|
title: 'Search & Filter',
|
|
27
30
|
keys: [
|
|
31
|
+
['n', 'Inline keyword search'],
|
|
28
32
|
['/', 'Search profiles'],
|
|
29
33
|
['f', 'Manage filters'],
|
|
30
|
-
['
|
|
34
|
+
['e', 'Export shortlist to markdown'],
|
|
35
|
+
['R', 'Force refresh (bypass cache)'],
|
|
31
36
|
],
|
|
32
37
|
},
|
|
33
38
|
{
|
|
34
|
-
title: '
|
|
39
|
+
title: 'Detail View',
|
|
35
40
|
keys: [
|
|
36
|
-
['
|
|
37
|
-
['
|
|
41
|
+
['J / K', 'Scroll detail content'],
|
|
42
|
+
['o', 'Open HN post in browser'],
|
|
38
43
|
],
|
|
39
44
|
},
|
|
40
45
|
{
|
|
41
|
-
title: '
|
|
46
|
+
title: 'AI Features (requires OPENAI_API_KEY)',
|
|
42
47
|
keys: [
|
|
43
|
-
['
|
|
44
|
-
['
|
|
48
|
+
['Space', 'AI summary of current job'],
|
|
49
|
+
['S', 'AI batch review of visible jobs'],
|
|
45
50
|
],
|
|
46
51
|
},
|
|
47
52
|
];
|
|
@@ -68,7 +73,7 @@ export default function HelpModal({ onClose }) {
|
|
|
68
73
|
h(
|
|
69
74
|
Box,
|
|
70
75
|
{ justifyContent: 'center', marginBottom: 1 },
|
|
71
|
-
h(Text, { bold: true, color: 'cyan' }, '
|
|
76
|
+
h(Text, { bold: true, color: 'cyan' }, 'Keyboard Shortcuts')
|
|
72
77
|
),
|
|
73
78
|
...SECTIONS.flatMap((section) => [
|
|
74
79
|
h(
|
package/src/tui/SearchManager.js
CHANGED
|
@@ -73,7 +73,7 @@ export default function SearchManager({
|
|
|
73
73
|
h(
|
|
74
74
|
Text,
|
|
75
75
|
{ color: 'magenta' },
|
|
76
|
-
' Creating
|
|
76
|
+
' Creating profile — generating a custom embedding from your resume + search prompt…'
|
|
77
77
|
)
|
|
78
78
|
)
|
|
79
79
|
);
|
|
@@ -110,7 +110,7 @@ export default function SearchManager({
|
|
|
110
110
|
h(
|
|
111
111
|
Text,
|
|
112
112
|
{ dimColor: true, marginTop: 1 },
|
|
113
|
-
'Enter to continue,
|
|
113
|
+
'Enter to continue, Esc to cancel'
|
|
114
114
|
)
|
|
115
115
|
);
|
|
116
116
|
}
|
|
@@ -153,7 +153,7 @@ export default function SearchManager({
|
|
|
153
153
|
h(
|
|
154
154
|
Text,
|
|
155
155
|
{ dimColor: true, marginTop: 1 },
|
|
156
|
-
'Enter to create,
|
|
156
|
+
'Enter to create, Esc to cancel'
|
|
157
157
|
)
|
|
158
158
|
);
|
|
159
159
|
}
|
package/src/tui/StatusBar.js
CHANGED
|
@@ -21,6 +21,10 @@ const KEYS = {
|
|
|
21
21
|
['space', 'AI'],
|
|
22
22
|
['esc', 'back'],
|
|
23
23
|
],
|
|
24
|
+
search: [
|
|
25
|
+
['esc', 'clear search'],
|
|
26
|
+
['enter', 'apply'],
|
|
27
|
+
],
|
|
24
28
|
filters: [
|
|
25
29
|
['j/k', 'nav'],
|
|
26
30
|
['enter', 'edit'],
|
|
@@ -67,14 +71,21 @@ export default function StatusBar({
|
|
|
67
71
|
h(Text, { dimColor: true }, '─'.repeat(Math.max(10, cols - 2)))
|
|
68
72
|
);
|
|
69
73
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
const rightParts = [];
|
|
75
|
+
if (loading)
|
|
76
|
+
rightParts.push(h(Text, { key: 'l', color: 'yellow' }, 'loading…'));
|
|
77
|
+
if (reranking)
|
|
78
|
+
rightParts.push(h(Text, { key: 'r', color: 'magenta' }, 'reranking…'));
|
|
79
|
+
rightParts.push(
|
|
80
|
+
h(Text, { key: 'c', dimColor: true }, `${jobCount}/${totalCount}`)
|
|
77
81
|
);
|
|
82
|
+
if (!aiEnabled) {
|
|
83
|
+
rightParts.push(
|
|
84
|
+
h(Text, { key: 'ai', dimColor: true }, 'set OPENAI_API_KEY for AI')
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const rightInfo = h(Box, { gap: 1 }, ...rightParts);
|
|
78
89
|
|
|
79
90
|
const content = toast
|
|
80
91
|
? h(Box, { paddingX: 1, justifyContent: 'space-between' }, toast, rightInfo)
|
package/src/tui/useAI.js
CHANGED
|
@@ -11,7 +11,9 @@ export function useAI(resume) {
|
|
|
11
11
|
const summarizeJob = useCallback(
|
|
12
12
|
async (job) => {
|
|
13
13
|
if (!hasKey) {
|
|
14
|
-
setError(
|
|
14
|
+
setError(
|
|
15
|
+
'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
|
|
16
|
+
);
|
|
15
17
|
return;
|
|
16
18
|
}
|
|
17
19
|
setLoading(true);
|
|
@@ -57,7 +59,9 @@ export function useAI(resume) {
|
|
|
57
59
|
const batchReview = useCallback(
|
|
58
60
|
async (jobs) => {
|
|
59
61
|
if (!hasKey) {
|
|
60
|
-
setError(
|
|
62
|
+
setError(
|
|
63
|
+
'AI requires OPENAI_API_KEY — run: export OPENAI_API_KEY=sk-...'
|
|
64
|
+
);
|
|
61
65
|
return;
|
|
62
66
|
}
|
|
63
67
|
setLoading(true);
|
package/src/tui/PreviewPane.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { Box, Text } from 'ink';
|
|
2
|
-
import { h } from './h.js';
|
|
3
|
-
import { formatSalary, formatLocation, stateIcon } from '../formatters.js';
|
|
4
|
-
|
|
5
|
-
export default function PreviewPane({ job }) {
|
|
6
|
-
if (!job) return null;
|
|
7
|
-
|
|
8
|
-
const cols = process.stdout.columns || 80;
|
|
9
|
-
const loc = formatLocation(job.location, job.remote);
|
|
10
|
-
const sal = formatSalary(job.salary, job.salary_usd);
|
|
11
|
-
const icon = stateIcon(job.state);
|
|
12
|
-
|
|
13
|
-
const skills = job.skills
|
|
14
|
-
? job.skills
|
|
15
|
-
.slice(0, 8)
|
|
16
|
-
.map((s) => s.name || s)
|
|
17
|
-
.join(' · ')
|
|
18
|
-
: null;
|
|
19
|
-
|
|
20
|
-
const desc = job.description
|
|
21
|
-
? job.description.replace(/\n/g, ' ').slice(0, cols * 2)
|
|
22
|
-
: null;
|
|
23
|
-
|
|
24
|
-
return h(
|
|
25
|
-
Box,
|
|
26
|
-
{
|
|
27
|
-
flexDirection: 'column',
|
|
28
|
-
borderStyle: 'single',
|
|
29
|
-
borderColor: 'gray',
|
|
30
|
-
borderTop: true,
|
|
31
|
-
borderBottom: false,
|
|
32
|
-
borderLeft: false,
|
|
33
|
-
borderRight: false,
|
|
34
|
-
paddingX: 1,
|
|
35
|
-
},
|
|
36
|
-
// Title line
|
|
37
|
-
h(
|
|
38
|
-
Box,
|
|
39
|
-
{ gap: 1 },
|
|
40
|
-
h(Text, { bold: true, color: 'white' }, job.title || '—'),
|
|
41
|
-
h(Text, { dimColor: true }, 'at'),
|
|
42
|
-
h(Text, { bold: true, color: 'cyan' }, job.company || '—'),
|
|
43
|
-
job.state ? h(Text, null, ` ${icon}`) : null
|
|
44
|
-
),
|
|
45
|
-
// Meta line
|
|
46
|
-
h(
|
|
47
|
-
Box,
|
|
48
|
-
{ gap: 2 },
|
|
49
|
-
h(Text, { dimColor: true }, `📍 ${loc}`),
|
|
50
|
-
h(Text, { dimColor: true }, `💰 ${sal}`),
|
|
51
|
-
job.experience
|
|
52
|
-
? h(Text, { dimColor: true }, `📊 ${job.experience}`)
|
|
53
|
-
: null,
|
|
54
|
-
job.url ? h(Text, { color: 'blue', dimColor: true }, 'o:open') : null
|
|
55
|
-
),
|
|
56
|
-
// Skills
|
|
57
|
-
skills
|
|
58
|
-
? h(
|
|
59
|
-
Box,
|
|
60
|
-
null,
|
|
61
|
-
h(Text, { color: 'yellow' }, 'Skills: '),
|
|
62
|
-
h(Text, { dimColor: true }, skills)
|
|
63
|
-
)
|
|
64
|
-
: null,
|
|
65
|
-
// Description snippet
|
|
66
|
-
desc ? h(Text, { dimColor: true, wrap: 'truncate-end' }, desc) : null
|
|
67
|
-
);
|
|
68
|
-
}
|