hn-bits 0.4.0 → 0.6.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/README.md +12 -1
- package/dist/api/algolia.js +18 -5
- package/dist/db/db.js +1 -0
- package/dist/db/subscriptions.js +6 -5
- package/dist/index.js +66 -9
- package/dist/lib/schedule.js +53 -0
- package/dist/lib/subscriptionLabel.js +9 -0
- package/dist/ui/SubscriptionForm.js +52 -8
- package/dist/ui/SubscriptionMatches.js +1 -0
- package/dist/ui/SubscriptionsView.js +30 -7
- package/dist/ui/keymap.js +1 -0
- package/dist/watch.js +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -107,7 +107,15 @@ All three keys are required, `telegram.enabled` alone is not enough. With nothin
|
|
|
107
107
|
hn watch --once # one pass: query subscriptions, notify new matches, exit
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
The first `hn sub add` offers to install a cron job for you (every 30 minutes). Manage it anytime via the CLI, or `c` on the Subs tab (which also shows a live installed/not-installed status line):
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
hn schedule status # installed / not installed
|
|
114
|
+
hn schedule install # install now (no-op if already installed)
|
|
115
|
+
hn schedule remove # uninstall
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Or set it up by hand:
|
|
111
119
|
|
|
112
120
|
```
|
|
113
121
|
*/30 * * * * cd /path/to/hn-bits && hn watch --once
|
|
@@ -188,6 +196,9 @@ Same as story list, minus search; `B` removes a bookmark instead of adding one.
|
|
|
188
196
|
| `a` | add subscription |
|
|
189
197
|
| `e` | edit subscription |
|
|
190
198
|
| `d` | delete subscription |
|
|
199
|
+
| `c` | toggle watch cron schedule (install/remove) |
|
|
200
|
+
|
|
201
|
+
Shows a `schedule: installed (every 30 min)` / `schedule: not installed` status line, so you don't need to drop to `hn schedule status` to check.
|
|
191
202
|
|
|
192
203
|
**Sub matches** (opened from Subs via `enter`)
|
|
193
204
|
|
package/dist/api/algolia.js
CHANGED
|
@@ -50,19 +50,32 @@ export async function searchStories(query, page = 0) {
|
|
|
50
50
|
totalHits: res.nbHits,
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
|
+
const DEFAULT_HITS_PER_PAGE = 50;
|
|
53
54
|
/**
|
|
54
55
|
* Recency-ordered search (subscription matching + watcher window rescans),
|
|
55
56
|
* as opposed to searchStories' relevance ordering.
|
|
57
|
+
*
|
|
58
|
+
* Both thresholds are always pushed server-side. With one active it's a flat AND'd
|
|
59
|
+
* numericFilters string, same as before minComments existed. With both active, Algolia's
|
|
60
|
+
* nested-array numericFilters OR syntax (`[a, [b, c]]` = a AND (b OR c)) — verified live against
|
|
61
|
+
* hn.algolia.com — expresses `created_at_i>X AND (points>=N OR num_comments>=M)` in one request.
|
|
56
62
|
*/
|
|
57
63
|
export async function searchRecent(query, options) {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
const minPoints = options.minPoints ?? 0;
|
|
65
|
+
const minComments = options.minComments ?? 0;
|
|
66
|
+
const createdAfterFilter = `created_at_i>${options.createdAfter}`;
|
|
67
|
+
const numericFilters = minPoints > 0 && minComments > 0
|
|
68
|
+
? JSON.stringify([createdAfterFilter, [`points>=${minPoints}`, `num_comments>=${minComments}`]])
|
|
69
|
+
: [
|
|
70
|
+
createdAfterFilter,
|
|
71
|
+
...(minPoints ? [`points>=${minPoints}`] : []),
|
|
72
|
+
...(minComments ? [`num_comments>=${minComments}`] : []),
|
|
73
|
+
].join(',');
|
|
61
74
|
const params = new URLSearchParams({
|
|
62
75
|
query,
|
|
63
76
|
tags: 'story',
|
|
64
|
-
numericFilters
|
|
65
|
-
hitsPerPage: String(options.hitsPerPage ??
|
|
77
|
+
numericFilters,
|
|
78
|
+
hitsPerPage: String(options.hitsPerPage ?? DEFAULT_HITS_PER_PAGE),
|
|
66
79
|
typoTolerance: 'false',
|
|
67
80
|
queryType: 'prefixNone',
|
|
68
81
|
});
|
package/dist/db/db.js
CHANGED
|
@@ -31,6 +31,7 @@ const MIGRATIONS = [
|
|
|
31
31
|
bookmarked_at INTEGER NOT NULL
|
|
32
32
|
);
|
|
33
33
|
`,
|
|
34
|
+
`ALTER TABLE subscriptions ADD COLUMN min_comments INTEGER NOT NULL DEFAULT 0;`,
|
|
34
35
|
];
|
|
35
36
|
export function dbPath() {
|
|
36
37
|
return process.env.HN_BITS_DB || join(homedir(), '.local', 'share', 'hn-bits', 'hn-bits.db');
|
package/dist/db/subscriptions.js
CHANGED
|
@@ -5,6 +5,7 @@ function fromRow(row) {
|
|
|
5
5
|
name: row.name,
|
|
6
6
|
query: row.query,
|
|
7
7
|
minPoints: row.min_points,
|
|
8
|
+
minComments: row.min_comments,
|
|
8
9
|
createdAt: row.created_at,
|
|
9
10
|
lastRunAt: row.last_run_at,
|
|
10
11
|
};
|
|
@@ -17,13 +18,13 @@ function assertNameFree(name, excludeId) {
|
|
|
17
18
|
if (conflict)
|
|
18
19
|
throw new Error(`subscription '${name}' already exists`);
|
|
19
20
|
}
|
|
20
|
-
export function addSubscription(name, query, minPoints) {
|
|
21
|
+
export function addSubscription(name, query, minPoints, minComments = 0) {
|
|
21
22
|
assertNameFree(name);
|
|
22
23
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
23
24
|
const info = openDb()
|
|
24
|
-
.prepare('INSERT INTO subscriptions (name, query, min_points, created_at) VALUES (?, ?, ?, ?)')
|
|
25
|
-
.run(name, query, minPoints, createdAt);
|
|
26
|
-
return { id: Number(info.lastInsertRowid), name, query, minPoints, createdAt, lastRunAt: null };
|
|
25
|
+
.prepare('INSERT INTO subscriptions (name, query, min_points, min_comments, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
26
|
+
.run(name, query, minPoints, minComments, createdAt);
|
|
27
|
+
return { id: Number(info.lastInsertRowid), name, query, minPoints, minComments, createdAt, lastRunAt: null };
|
|
27
28
|
}
|
|
28
29
|
export function listSubscriptions() {
|
|
29
30
|
const rows = openDb().prepare('SELECT * FROM subscriptions ORDER BY created_at ASC').all();
|
|
@@ -36,7 +37,7 @@ export function removeSubscription(name) {
|
|
|
36
37
|
export function updateSubscription(id, fields) {
|
|
37
38
|
assertNameFree(fields.name, id);
|
|
38
39
|
const db = openDb();
|
|
39
|
-
db.prepare('UPDATE subscriptions SET name = ?, query = ?, min_points = ? WHERE id = ?').run(fields.name, fields.query, fields.minPoints, id);
|
|
40
|
+
db.prepare('UPDATE subscriptions SET name = ?, query = ?, min_points = ?, min_comments = ? WHERE id = ?').run(fields.name, fields.query, fields.minPoints, fields.minComments ?? 0, id);
|
|
40
41
|
return fromRow(db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id));
|
|
41
42
|
}
|
|
42
43
|
export function touchLastRun(id, at) {
|
package/dist/index.js
CHANGED
|
@@ -112,25 +112,53 @@ config
|
|
|
112
112
|
}
|
|
113
113
|
});
|
|
114
114
|
const sub = program.command('sub').description('Manage topic subscriptions');
|
|
115
|
+
function parseNonNegativeInt(value, flag) {
|
|
116
|
+
const parsed = Number(value);
|
|
117
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
118
|
+
console.error(`${flag} must be a non-negative integer, got '${value}'`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
|
123
|
+
async function promptInstallSchedule() {
|
|
124
|
+
const readline = await import('node:readline/promises');
|
|
125
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
126
|
+
const answer = await rl.question('No watch schedule installed yet. Install one now (every 30 min)? [y/N] ');
|
|
127
|
+
rl.close();
|
|
128
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
129
|
+
console.log("skipped. Run 'hn schedule install' anytime, or add the cron line yourself (see README).");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const { installScheduledJob } = await import('./lib/schedule.js');
|
|
133
|
+
try {
|
|
134
|
+
const line = installScheduledJob();
|
|
135
|
+
console.log(`installed: ${line}`);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
console.log("couldn't find 'hn' on PATH — add the cron line yourself (see README) or run 'hn schedule install' later.");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
115
141
|
sub
|
|
116
142
|
.command('add <name> <query...>')
|
|
117
143
|
.description('Add a subscription')
|
|
118
144
|
.option('--min-points <n>', 'minimum points threshold', '0')
|
|
145
|
+
.option('--min-comments <n>', 'minimum comments threshold', '0')
|
|
119
146
|
.action(async (name, queryParts, options) => {
|
|
120
|
-
const minPoints =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
process.exit(1);
|
|
124
|
-
}
|
|
125
|
-
const { addSubscription } = await import('./db/subscriptions.js');
|
|
147
|
+
const minPoints = parseNonNegativeInt(options.minPoints, '--min-points');
|
|
148
|
+
const minComments = parseNonNegativeInt(options.minComments, '--min-comments');
|
|
149
|
+
const { addSubscription, listSubscriptions } = await import('./db/subscriptions.js');
|
|
126
150
|
try {
|
|
127
|
-
addSubscription(name, queryParts.join(' '), minPoints);
|
|
151
|
+
addSubscription(name, queryParts.join(' '), minPoints, minComments);
|
|
128
152
|
console.log(`added '${name}'`);
|
|
129
153
|
}
|
|
130
154
|
catch (err) {
|
|
131
155
|
console.error(err.message);
|
|
132
156
|
process.exit(1);
|
|
133
157
|
}
|
|
158
|
+
const { hasScheduledJob } = await import('./lib/schedule.js');
|
|
159
|
+
if (listSubscriptions().length === 1 && !hasScheduledJob()) {
|
|
160
|
+
await promptInstallSchedule();
|
|
161
|
+
}
|
|
134
162
|
});
|
|
135
163
|
sub
|
|
136
164
|
.command('list')
|
|
@@ -138,6 +166,7 @@ sub
|
|
|
138
166
|
.action(async () => {
|
|
139
167
|
const { listSubscriptions } = await import('./db/subscriptions.js');
|
|
140
168
|
const { formatAge } = await import('./lib/format.js');
|
|
169
|
+
const { thresholdLabel } = await import('./lib/subscriptionLabel.js');
|
|
141
170
|
const subs = listSubscriptions();
|
|
142
171
|
if (subs.length === 0) {
|
|
143
172
|
console.log('no subscriptions yet');
|
|
@@ -145,9 +174,9 @@ sub
|
|
|
145
174
|
}
|
|
146
175
|
const nameWidth = Math.max(...subs.map((s) => s.name.length));
|
|
147
176
|
for (const s of subs) {
|
|
148
|
-
const
|
|
177
|
+
const threshold = thresholdLabel(s.minPoints, s.minComments);
|
|
149
178
|
const lastRun = s.lastRunAt == null ? 'never' : `${formatAge(s.lastRunAt)} ago`;
|
|
150
|
-
console.log(`${s.name.padEnd(nameWidth)} "${s.query}" ${
|
|
179
|
+
console.log(`${s.name.padEnd(nameWidth)} "${s.query}" ${threshold} ${lastRun}`);
|
|
151
180
|
}
|
|
152
181
|
});
|
|
153
182
|
sub
|
|
@@ -161,6 +190,34 @@ sub
|
|
|
161
190
|
}
|
|
162
191
|
console.log(`removed '${name}'`);
|
|
163
192
|
});
|
|
193
|
+
const schedule = program.command('schedule').description('Manage the cron job that runs `hn watch --once`');
|
|
194
|
+
schedule
|
|
195
|
+
.command('status')
|
|
196
|
+
.description('Show whether the watch cron job is installed')
|
|
197
|
+
.action(async () => {
|
|
198
|
+
const { hasScheduledJob } = await import('./lib/schedule.js');
|
|
199
|
+
console.log(hasScheduledJob() ? 'installed' : 'not installed');
|
|
200
|
+
});
|
|
201
|
+
schedule
|
|
202
|
+
.command('install')
|
|
203
|
+
.description('Install the watch cron job (no-op if already installed)')
|
|
204
|
+
.action(async () => {
|
|
205
|
+
const { installScheduledJob } = await import('./lib/schedule.js');
|
|
206
|
+
try {
|
|
207
|
+
console.log(`installed: ${installScheduledJob()}`);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
console.error("couldn't find 'hn' on PATH; add the cron line yourself (see README)");
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
schedule
|
|
215
|
+
.command('remove')
|
|
216
|
+
.description('Remove the watch cron job')
|
|
217
|
+
.action(async () => {
|
|
218
|
+
const { removeScheduledJob } = await import('./lib/schedule.js');
|
|
219
|
+
console.log(removeScheduledJob() ? 'removed' : 'not installed');
|
|
220
|
+
});
|
|
164
221
|
program
|
|
165
222
|
.command('watch')
|
|
166
223
|
.description('Check subscriptions for new matches and notify (one-shot; cron owns scheduling)')
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const MARKER = '# hn-bits watch';
|
|
5
|
+
function watchLogPath() {
|
|
6
|
+
return join(homedir(), '.local', 'share', 'hn-bits', 'watch.log');
|
|
7
|
+
}
|
|
8
|
+
function readCrontab() {
|
|
9
|
+
try {
|
|
10
|
+
return execSync('crontab -l', { encoding: 'utf-8' });
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function writeCrontab(content) {
|
|
17
|
+
execSync('crontab -', { input: content, encoding: 'utf-8' });
|
|
18
|
+
}
|
|
19
|
+
function resolveHnPath() {
|
|
20
|
+
return execSync('which hn', { encoding: 'utf-8' }).trim();
|
|
21
|
+
}
|
|
22
|
+
function resolveNodePath() {
|
|
23
|
+
return execSync('which node', { encoding: 'utf-8' }).trim();
|
|
24
|
+
}
|
|
25
|
+
/** Invokes node directly rather than relying on hn's `#!/usr/bin/env node` shebang, since cron's
|
|
26
|
+
* minimal PATH (unlike an interactive shell) usually can't resolve a version-manager-installed node. */
|
|
27
|
+
function buildCronLine(nodePath, hnPath) {
|
|
28
|
+
return `*/30 * * * * ${nodePath} ${hnPath} watch --once >> ${watchLogPath()} 2>&1 ${MARKER}`;
|
|
29
|
+
}
|
|
30
|
+
export function hasScheduledJob() {
|
|
31
|
+
return readCrontab()
|
|
32
|
+
.split('\n')
|
|
33
|
+
.some((line) => line.includes(MARKER));
|
|
34
|
+
}
|
|
35
|
+
/** Idempotent: no-ops if a job is already installed. Throws if `hn` isn't on PATH. */
|
|
36
|
+
export function installScheduledJob() {
|
|
37
|
+
const current = readCrontab();
|
|
38
|
+
const existing = current.split('\n').find((line) => line.includes(MARKER));
|
|
39
|
+
if (existing)
|
|
40
|
+
return existing;
|
|
41
|
+
const line = buildCronLine(resolveNodePath(), resolveHnPath());
|
|
42
|
+
const body = current.trim().length > 0 ? `${current.replace(/\n+$/, '')}\n${line}\n` : `${line}\n`;
|
|
43
|
+
writeCrontab(body);
|
|
44
|
+
return line;
|
|
45
|
+
}
|
|
46
|
+
export function removeScheduledJob() {
|
|
47
|
+
const lines = readCrontab().split('\n');
|
|
48
|
+
const filtered = lines.filter((line) => !line.includes(MARKER));
|
|
49
|
+
if (filtered.length === lines.length)
|
|
50
|
+
return false;
|
|
51
|
+
writeCrontab(filtered.join('\n'));
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** "any" / "≥20 pts" / "≥5 cmts" / "≥20 pts or ≥5 cmts" — shared by CLI and TUI. */
|
|
2
|
+
export function thresholdLabel(minPoints, minComments) {
|
|
3
|
+
const parts = [];
|
|
4
|
+
if (minPoints > 0)
|
|
5
|
+
parts.push(`≥${minPoints} pts`);
|
|
6
|
+
if (minComments > 0)
|
|
7
|
+
parts.push(`≥${minComments} cmts`);
|
|
8
|
+
return parts.length === 0 ? 'any' : parts.join(' or ');
|
|
9
|
+
}
|
|
@@ -2,13 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
4
4
|
import { searchRecent } from '../api/algolia.js';
|
|
5
|
-
import { addSubscription, updateSubscription } from '../db/subscriptions.js';
|
|
5
|
+
import { addSubscription, listSubscriptions, updateSubscription } from '../db/subscriptions.js';
|
|
6
6
|
import { truncateTitle } from '../lib/format.js';
|
|
7
|
+
import { hasScheduledJob, installScheduledJob } from '../lib/schedule.js';
|
|
8
|
+
import { thresholdLabel } from '../lib/subscriptionLabel.js';
|
|
7
9
|
import { useTheme } from './theme.js';
|
|
8
10
|
const PREVIEW_DEBOUNCE_MS = 300;
|
|
9
11
|
const PREVIEW_WINDOW_DAYS = 7;
|
|
10
12
|
const PREVIEW_LIMIT = 5;
|
|
11
|
-
const FIELD_ORDER = ['name', 'query', 'minPoints'];
|
|
13
|
+
const FIELD_ORDER = ['name', 'query', 'minPoints', 'minComments'];
|
|
12
14
|
function windowStart() {
|
|
13
15
|
return Math.floor(Date.now() / 1000) - PREVIEW_WINDOW_DAYS * 24 * 60 * 60;
|
|
14
16
|
}
|
|
@@ -18,9 +20,11 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
18
20
|
const [name, setName] = useState(subscription?.name ?? '');
|
|
19
21
|
const [query, setQuery] = useState(subscription?.query ?? prefillQuery ?? '');
|
|
20
22
|
const [minPoints, setMinPoints] = useState(String(subscription?.minPoints ?? 0));
|
|
23
|
+
const [minComments, setMinComments] = useState(String(subscription?.minComments ?? 0));
|
|
21
24
|
const [field, setField] = useState('name');
|
|
22
25
|
const [error, setError] = useState('');
|
|
23
26
|
const [preview, setPreview] = useState([]);
|
|
27
|
+
const [scheduleConfirm, setScheduleConfirm] = useState(false);
|
|
24
28
|
const debounceRef = useRef(null);
|
|
25
29
|
const previewToken = useRef(0);
|
|
26
30
|
useEffect(() => {
|
|
@@ -36,12 +40,18 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
36
40
|
clearTimeout(debounceRef.current);
|
|
37
41
|
};
|
|
38
42
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
-
}, [query, minPoints]);
|
|
43
|
+
}, [query, minPoints, minComments]);
|
|
40
44
|
async function loadPreview() {
|
|
41
45
|
const myToken = ++previewToken.current;
|
|
42
46
|
const points = Number(minPoints) || 0;
|
|
47
|
+
const comments = Number(minComments) || 0;
|
|
43
48
|
try {
|
|
44
|
-
const results = await searchRecent(query, {
|
|
49
|
+
const results = await searchRecent(query, {
|
|
50
|
+
createdAfter: windowStart(),
|
|
51
|
+
minPoints: points,
|
|
52
|
+
minComments: comments,
|
|
53
|
+
hitsPerPage: PREVIEW_LIMIT,
|
|
54
|
+
});
|
|
45
55
|
if (myToken === previewToken.current)
|
|
46
56
|
setPreview(results);
|
|
47
57
|
}
|
|
@@ -58,18 +68,47 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
58
68
|
if (!trimmedQuery)
|
|
59
69
|
return setError('query is required');
|
|
60
70
|
const points = Number(minPoints) || 0;
|
|
71
|
+
const comments = Number(minComments) || 0;
|
|
61
72
|
try {
|
|
62
73
|
if (mode === 'add')
|
|
63
|
-
addSubscription(trimmedName, trimmedQuery, points);
|
|
74
|
+
addSubscription(trimmedName, trimmedQuery, points, comments);
|
|
64
75
|
else
|
|
65
|
-
updateSubscription(subscription.id, {
|
|
76
|
+
updateSubscription(subscription.id, {
|
|
77
|
+
name: trimmedName,
|
|
78
|
+
query: trimmedQuery,
|
|
79
|
+
minPoints: points,
|
|
80
|
+
minComments: comments,
|
|
81
|
+
});
|
|
82
|
+
if (mode === 'add' && listSubscriptions().length === 1 && !hasScheduledJob()) {
|
|
83
|
+
setScheduleConfirm(true);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
66
86
|
onSave();
|
|
67
87
|
}
|
|
68
88
|
catch (err) {
|
|
69
89
|
setError(err.message);
|
|
70
90
|
}
|
|
71
91
|
}
|
|
92
|
+
function resolveScheduleConfirm(install) {
|
|
93
|
+
if (install) {
|
|
94
|
+
try {
|
|
95
|
+
installScheduledJob();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// hn not on PATH; user can run `hn schedule install` later
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
setScheduleConfirm(false);
|
|
102
|
+
onSave();
|
|
103
|
+
}
|
|
72
104
|
function handleInput(input, key) {
|
|
105
|
+
if (scheduleConfirm) {
|
|
106
|
+
if (input === 'y')
|
|
107
|
+
return resolveScheduleConfirm(true);
|
|
108
|
+
if (input === 'n' || key.escape)
|
|
109
|
+
return resolveScheduleConfirm(false);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
73
112
|
if (key.escape)
|
|
74
113
|
return onCancel();
|
|
75
114
|
if (key.tab) {
|
|
@@ -85,6 +124,8 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
85
124
|
setQuery((v) => v.slice(0, -1));
|
|
86
125
|
if (field === 'minPoints')
|
|
87
126
|
setMinPoints((v) => v.slice(0, -1));
|
|
127
|
+
if (field === 'minComments')
|
|
128
|
+
setMinComments((v) => v.slice(0, -1));
|
|
88
129
|
return;
|
|
89
130
|
}
|
|
90
131
|
if (input && !key.ctrl && !key.meta) {
|
|
@@ -95,10 +136,13 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
95
136
|
if (field === 'minPoints' && /^\d+$/.test(input)) {
|
|
96
137
|
setMinPoints((v) => (v === '0' ? input : v + input));
|
|
97
138
|
}
|
|
139
|
+
if (field === 'minComments' && /^\d+$/.test(input)) {
|
|
140
|
+
setMinComments((v) => (v === '0' ? input : v + input));
|
|
141
|
+
}
|
|
98
142
|
}
|
|
99
143
|
}
|
|
100
144
|
useInput(handleInput);
|
|
101
145
|
const cursor = (f) => (field === f ? '▏' : '');
|
|
102
|
-
const previewLabel = Number(minPoints)
|
|
103
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: mode === 'add' ? 'New subscription' : 'Edit subscription' }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["name: ", name, cursor('name')] }), _jsxs(Text, { children: ["query: ", query, cursor('query')] }), _jsxs(Text, { children: ["min points: ", minPoints, cursor('minPoints')] }), _jsx(Text, { children: " " }), query.trim() && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["preview (last 7 days, ", previewLabel, "):"] }), preview.length === 0 && _jsx(Text, { dimColor: true, children: " no matches" }), preview.map((story, i) => (_jsxs(Text, { children: [' ', i + 1, ". ", truncateTitle(story.title, Math.max(1, columns - 24)), " ", story.score, " pts"] }, story.id)))] })), error && _jsx(Text, { color: theme.colors.error, children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "tab next field \u00B7 enter save \u00B7 esc cancel" })] }));
|
|
146
|
+
const previewLabel = thresholdLabel(Number(minPoints) || 0, Number(minComments) || 0);
|
|
147
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: mode === 'add' ? 'New subscription' : 'Edit subscription' }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["name: ", name, cursor('name')] }), _jsxs(Text, { children: ["query: ", query, cursor('query')] }), _jsxs(Text, { children: ["min points: ", minPoints, cursor('minPoints')] }), _jsxs(Text, { children: ["min comments: ", minComments, cursor('minComments')] }), _jsx(Text, { children: " " }), query.trim() && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["preview (last 7 days, ", previewLabel, "):"] }), preview.length === 0 && _jsx(Text, { dimColor: true, children: " no matches" }), preview.map((story, i) => (_jsxs(Text, { children: [' ', i + 1, ". ", truncateTitle(story.title, Math.max(1, columns - 24)), " ", story.score, " pts"] }, story.id)))] })), error && _jsx(Text, { color: theme.colors.error, children: error }), _jsx(Text, { children: " " }), scheduleConfirm ? (_jsx(Text, { dimColor: true, children: "no watch schedule installed yet - install one now (every 30 min)? y/n" })) : (_jsx(Text, { dimColor: true, children: "tab next field \u00B7 enter save \u00B7 esc cancel" }))] }));
|
|
104
148
|
}
|
|
@@ -44,6 +44,7 @@ export function SubscriptionMatches({ subscription, config, onSelectStory, onBac
|
|
|
44
44
|
const results = await searchRecent(subscription.query, {
|
|
45
45
|
createdAfter: windowStart(),
|
|
46
46
|
minPoints: subscription.minPoints,
|
|
47
|
+
minComments: subscription.minComments,
|
|
47
48
|
});
|
|
48
49
|
setStories(results);
|
|
49
50
|
setStatus('ready');
|
|
@@ -4,13 +4,13 @@ import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
|
4
4
|
import { listSubscriptions, removeSubscription } from '../db/subscriptions.js';
|
|
5
5
|
import { formatAge } from '../lib/format.js';
|
|
6
6
|
import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
|
|
7
|
+
import { hasScheduledJob, installScheduledJob, removeScheduledJob } from '../lib/schedule.js';
|
|
8
|
+
import { thresholdLabel } from '../lib/subscriptionLabel.js';
|
|
7
9
|
import { ensureVisibleLines, sliceByLines } from '../lib/viewport.js';
|
|
8
10
|
import { footerRows, SUBS_KEYS } from './keymap.js';
|
|
9
11
|
import { HEADER_ROWS } from './Layout.js';
|
|
10
12
|
import { useTheme } from './theme.js';
|
|
11
|
-
|
|
12
|
-
return minPoints === 0 ? 'any' : `≥${minPoints} pts`;
|
|
13
|
-
}
|
|
13
|
+
import { useFlash } from './useFlash.js';
|
|
14
14
|
export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange, onTabChange, }) {
|
|
15
15
|
const theme = useTheme();
|
|
16
16
|
const { columns, rows } = useWindowSize();
|
|
@@ -18,6 +18,8 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
|
|
|
18
18
|
const [subs, setSubs] = useState(() => listSubscriptions());
|
|
19
19
|
const [selected, setSelected] = useState(0);
|
|
20
20
|
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
|
21
|
+
const [scheduleInstalled, setScheduleInstalled] = useState(() => hasScheduledJob());
|
|
22
|
+
const [scheduleFlash, flashSchedule] = useFlash();
|
|
21
23
|
const pendingTopJump = useRef(false);
|
|
22
24
|
const topLineRef = useRef(0);
|
|
23
25
|
function reload() {
|
|
@@ -25,6 +27,22 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
|
|
|
25
27
|
setSubs(next);
|
|
26
28
|
setSelected((s) => clampSelection(s, 0, next.length));
|
|
27
29
|
}
|
|
30
|
+
function toggleSchedule() {
|
|
31
|
+
if (scheduleInstalled) {
|
|
32
|
+
removeScheduledJob();
|
|
33
|
+
setScheduleInstalled(false);
|
|
34
|
+
flashSchedule('schedule removed');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
installScheduledJob();
|
|
39
|
+
setScheduleInstalled(true);
|
|
40
|
+
flashSchedule('schedule installed');
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
flashSchedule("couldn't find 'hn' on PATH");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
28
46
|
function handleInput(input, key) {
|
|
29
47
|
if (deleteConfirm) {
|
|
30
48
|
if (input === 'y') {
|
|
@@ -61,12 +79,17 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
|
|
|
61
79
|
return onEdit(subs[selected]);
|
|
62
80
|
if (input === 'd' && subs[selected])
|
|
63
81
|
return setDeleteConfirm(subs[selected].name);
|
|
82
|
+
if (input === 'c')
|
|
83
|
+
return toggleSchedule();
|
|
64
84
|
}
|
|
65
85
|
useInput(handleInput);
|
|
86
|
+
const scheduleStatusText = scheduleInstalled ? 'schedule: installed (every 30 min)' : 'schedule: not installed';
|
|
66
87
|
if (subs.length === 0) {
|
|
67
|
-
return _jsx(Text, { dimColor: true, children: "no subscriptions yet - press a to add" });
|
|
88
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "no subscriptions yet - press a to add" }), _jsx(Text, { dimColor: true, children: scheduleStatusText }), scheduleFlash && _jsx(Text, { dimColor: true, children: scheduleFlash })] }));
|
|
68
89
|
}
|
|
69
|
-
const
|
|
90
|
+
const deleteLineHeight = deleteConfirm ? 1 : 0;
|
|
91
|
+
const scheduleFlashLineHeight = scheduleFlash ? 1 : 0;
|
|
92
|
+
const statusLineHeight = 1 + deleteLineHeight + scheduleFlashLineHeight;
|
|
70
93
|
const listHeight = Math.max(1, bodyHeight - statusLineHeight);
|
|
71
94
|
const heights = subs.map(() => 1);
|
|
72
95
|
const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
|
|
@@ -78,6 +101,6 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
|
|
|
78
101
|
const isSelected = index === selected;
|
|
79
102
|
const marker = isSelected ? theme.glyphs.selection : ' ';
|
|
80
103
|
const background = isSelected ? theme.colors.selectionBackground : undefined;
|
|
81
|
-
return (_jsxs(Text, { backgroundColor: background, children: [marker, " ", sub.name.padEnd(nameWidth), " \"", sub.query, "\" ",
|
|
82
|
-
}), deleteConfirm && _jsxs(Text, { dimColor: true, children: ["delete '", deleteConfirm, "'? y/n"] })] }));
|
|
104
|
+
return (_jsxs(Text, { backgroundColor: background, children: [marker, " ", sub.name.padEnd(nameWidth), " \"", sub.query, "\" ", thresholdLabel(sub.minPoints, sub.minComments).padEnd(19), " added", ' ', formatAge(sub.createdAt), " ago"] }, sub.id));
|
|
105
|
+
}), deleteConfirm && _jsxs(Text, { dimColor: true, children: ["delete '", deleteConfirm, "'? y/n"] }), _jsx(Text, { dimColor: true, children: scheduleStatusText }), scheduleFlash && _jsx(Text, { dimColor: true, children: scheduleFlash })] }));
|
|
83
106
|
}
|
package/dist/ui/keymap.js
CHANGED
package/dist/watch.js
CHANGED
|
@@ -35,7 +35,11 @@ async function notifyStory(sub, story, notifiers) {
|
|
|
35
35
|
async function processSubscription(sub, notifiers, now, dryRun) {
|
|
36
36
|
let stories;
|
|
37
37
|
try {
|
|
38
|
-
stories = await searchRecent(sub.query, {
|
|
38
|
+
stories = await searchRecent(sub.query, {
|
|
39
|
+
createdAfter: windowStart(sub.lastRunAt, now),
|
|
40
|
+
minPoints: sub.minPoints,
|
|
41
|
+
minComments: sub.minComments,
|
|
42
|
+
});
|
|
39
43
|
}
|
|
40
44
|
catch (err) {
|
|
41
45
|
log(`${sub.name}: query failed - ${err.message}`);
|