hn-bits 0.5.0 → 0.7.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 +13 -2
- 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 +15 -8
- package/dist/lib/subscriptionLabel.js +9 -0
- package/dist/notify/desktop.js +62 -0
- package/dist/ui/SubscriptionForm.js +26 -7
- package/dist/ui/SubscriptionMatches.js +1 -0
- package/dist/ui/SubscriptionsView.js +2 -4
- package/dist/watch.js +22 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,9 +79,9 @@ hn config set ollama.model llama3.2
|
|
|
79
79
|
|
|
80
80
|
Missing fields fall back to the defaults above; invalid JSON degrades to "AI disabled" with a warning rather than crashing the reader.
|
|
81
81
|
|
|
82
|
-
### Subscriptions +
|
|
82
|
+
### Subscriptions + notifications
|
|
83
83
|
|
|
84
|
-
`hn sub` tracks topic queries; `hn watch --once` checks each one, dedups against past matches, and sends new ones to Telegram. No daemon: schedule `hn watch --once` with cron or launchd.
|
|
84
|
+
`hn sub` tracks topic queries; `hn watch --once` checks each one, dedups against past matches, and sends new ones to Telegram and/or macOS desktop notifications. No daemon: schedule `hn watch --once` with cron or launchd.
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
87
|
hn sub add apple "Apple" --min-points 50 # topic name, Algolia query, min score
|
|
@@ -103,6 +103,17 @@ hn config set telegram.enabled true
|
|
|
103
103
|
|
|
104
104
|
All three keys are required, `telegram.enabled` alone is not enough. With nothing enabled, `hn watch --once` exits with code 2.
|
|
105
105
|
|
|
106
|
+
**macOS desktop notifications** (optional, works alone or alongside Telegram) use the [alerter](https://github.com/vjeantet/alerter) binary (`brew install vjeantet/tap/alerter`):
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
hn config set desktopNotifications.enabled true
|
|
110
|
+
hn config set desktopNotifications.timeoutSeconds 10 # optional, default 10
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Clicking a notification opens the story URL (HN discussion for text posts): clicks act only while the notification is on screen (`timeoutSeconds`); stale entries in Notification Center just dismiss. Desktop delivery is best-effort: if `alerter` is missing, the watcher warns and skips it (exit 0, nothing marked seen when it's the only channel).
|
|
114
|
+
|
|
115
|
+
One-time macOS setup: alerter sends as Terminal.app, and macOS silently drops notifications from senders without permission. Check System Settings > Notifications > Terminal is allowed (banner style): there is no prompt and no error when it isn't.
|
|
116
|
+
|
|
106
117
|
```bash
|
|
107
118
|
hn watch --once # one pass: query subscriptions, notify new matches, exit
|
|
108
119
|
```
|
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,6 +112,14 @@ 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
|
+
}
|
|
115
123
|
async function promptInstallSchedule() {
|
|
116
124
|
const readline = await import('node:readline/promises');
|
|
117
125
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -134,15 +142,13 @@ sub
|
|
|
134
142
|
.command('add <name> <query...>')
|
|
135
143
|
.description('Add a subscription')
|
|
136
144
|
.option('--min-points <n>', 'minimum points threshold', '0')
|
|
145
|
+
.option('--min-comments <n>', 'minimum comments threshold', '0')
|
|
137
146
|
.action(async (name, queryParts, options) => {
|
|
138
|
-
const minPoints =
|
|
139
|
-
|
|
140
|
-
console.error(`--min-points must be a non-negative integer, got '${options.minPoints}'`);
|
|
141
|
-
process.exit(1);
|
|
142
|
-
}
|
|
147
|
+
const minPoints = parseNonNegativeInt(options.minPoints, '--min-points');
|
|
148
|
+
const minComments = parseNonNegativeInt(options.minComments, '--min-comments');
|
|
143
149
|
const { addSubscription, listSubscriptions } = await import('./db/subscriptions.js');
|
|
144
150
|
try {
|
|
145
|
-
addSubscription(name, queryParts.join(' '), minPoints);
|
|
151
|
+
addSubscription(name, queryParts.join(' '), minPoints, minComments);
|
|
146
152
|
console.log(`added '${name}'`);
|
|
147
153
|
}
|
|
148
154
|
catch (err) {
|
|
@@ -160,6 +166,7 @@ sub
|
|
|
160
166
|
.action(async () => {
|
|
161
167
|
const { listSubscriptions } = await import('./db/subscriptions.js');
|
|
162
168
|
const { formatAge } = await import('./lib/format.js');
|
|
169
|
+
const { thresholdLabel } = await import('./lib/subscriptionLabel.js');
|
|
163
170
|
const subs = listSubscriptions();
|
|
164
171
|
if (subs.length === 0) {
|
|
165
172
|
console.log('no subscriptions yet');
|
|
@@ -167,9 +174,9 @@ sub
|
|
|
167
174
|
}
|
|
168
175
|
const nameWidth = Math.max(...subs.map((s) => s.name.length));
|
|
169
176
|
for (const s of subs) {
|
|
170
|
-
const
|
|
177
|
+
const threshold = thresholdLabel(s.minPoints, s.minComments);
|
|
171
178
|
const lastRun = s.lastRunAt == null ? 'never' : `${formatAge(s.lastRunAt)} ago`;
|
|
172
|
-
console.log(`${s.name.padEnd(nameWidth)} "${s.query}" ${
|
|
179
|
+
console.log(`${s.name.padEnd(nameWidth)} "${s.query}" ${threshold} ${lastRun}`);
|
|
173
180
|
}
|
|
174
181
|
});
|
|
175
182
|
sub
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { accessSync, constants } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { delimiter, join } from 'node:path';
|
|
5
|
+
import { hnItemUrl } from '../api/firebase.js';
|
|
6
|
+
// Story data reaches the shell only as positional parameters, never spliced
|
|
7
|
+
// into the script, so titles containing quotes or $() cannot inject.
|
|
8
|
+
// $7 is the absolute alerter path: under cron the child PATH lacks it too.
|
|
9
|
+
const WRAPPER_SCRIPT = 'r=$("$7" --title "$1" --subtitle "$2" --message "$3" --actions Open --timeout "$4" --group "$5"); ' +
|
|
10
|
+
'case "$r" in @TIMEOUT|@CLOSED) ;; *) open "$6" ;; esac';
|
|
11
|
+
// Cron runs with PATH=/usr/bin:/bin, which contains neither the Homebrew
|
|
12
|
+
// prefix nor ~/.local/bin, so PATH alone would never find alerter under cron.
|
|
13
|
+
const FALLBACK_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', join(homedir(), '.local', 'bin')];
|
|
14
|
+
export function findAlerter() {
|
|
15
|
+
const pathDirs = (process.env.PATH ?? '').split(delimiter);
|
|
16
|
+
for (const dir of [...pathDirs, ...FALLBACK_DIRS]) {
|
|
17
|
+
if (!dir)
|
|
18
|
+
continue;
|
|
19
|
+
const candidate = join(dir, 'alerter');
|
|
20
|
+
try {
|
|
21
|
+
accessSync(candidate, constants.X_OK);
|
|
22
|
+
return candidate;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// not here, keep scanning
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function wrapperArguments({ subscription, story }, timeoutSeconds, alerterPath) {
|
|
31
|
+
return [
|
|
32
|
+
'sh',
|
|
33
|
+
`🔔 ${subscription.name}`,
|
|
34
|
+
`${story.score} pts · ${story.descendants} comments`,
|
|
35
|
+
story.title,
|
|
36
|
+
String(timeoutSeconds),
|
|
37
|
+
`hn-${subscription.id}-${story.id}`,
|
|
38
|
+
story.url ?? hnItemUrl(story.id),
|
|
39
|
+
alerterPath,
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
/** Returns null (after one stderr warning) when alerter is not on PATH. */
|
|
43
|
+
export function createDesktopNotifier(config) {
|
|
44
|
+
const alerterPath = findAlerter();
|
|
45
|
+
if (alerterPath == null) {
|
|
46
|
+
console.error('desktop: alerter not found (brew install vjeantet/tap/alerter), skipping');
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
name: 'desktop',
|
|
51
|
+
// Fire-and-forget: the detached wrapper waits out the notification;
|
|
52
|
+
// failures log but never reject, so the watcher's markSeen is not blocked.
|
|
53
|
+
send: async (match) => {
|
|
54
|
+
const child = spawn('sh', ['-c', WRAPPER_SCRIPT, ...wrapperArguments(match, config.timeoutSeconds, alerterPath)], {
|
|
55
|
+
detached: true,
|
|
56
|
+
stdio: 'ignore',
|
|
57
|
+
});
|
|
58
|
+
child.on('error', (err) => console.error(`desktop: spawn failed - ${err.message}`));
|
|
59
|
+
child.unref();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -5,11 +5,12 @@ import { searchRecent } from '../api/algolia.js';
|
|
|
5
5
|
import { addSubscription, listSubscriptions, updateSubscription } from '../db/subscriptions.js';
|
|
6
6
|
import { truncateTitle } from '../lib/format.js';
|
|
7
7
|
import { hasScheduledJob, installScheduledJob } from '../lib/schedule.js';
|
|
8
|
+
import { thresholdLabel } from '../lib/subscriptionLabel.js';
|
|
8
9
|
import { useTheme } from './theme.js';
|
|
9
10
|
const PREVIEW_DEBOUNCE_MS = 300;
|
|
10
11
|
const PREVIEW_WINDOW_DAYS = 7;
|
|
11
12
|
const PREVIEW_LIMIT = 5;
|
|
12
|
-
const FIELD_ORDER = ['name', 'query', 'minPoints'];
|
|
13
|
+
const FIELD_ORDER = ['name', 'query', 'minPoints', 'minComments'];
|
|
13
14
|
function windowStart() {
|
|
14
15
|
return Math.floor(Date.now() / 1000) - PREVIEW_WINDOW_DAYS * 24 * 60 * 60;
|
|
15
16
|
}
|
|
@@ -19,6 +20,7 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
19
20
|
const [name, setName] = useState(subscription?.name ?? '');
|
|
20
21
|
const [query, setQuery] = useState(subscription?.query ?? prefillQuery ?? '');
|
|
21
22
|
const [minPoints, setMinPoints] = useState(String(subscription?.minPoints ?? 0));
|
|
23
|
+
const [minComments, setMinComments] = useState(String(subscription?.minComments ?? 0));
|
|
22
24
|
const [field, setField] = useState('name');
|
|
23
25
|
const [error, setError] = useState('');
|
|
24
26
|
const [preview, setPreview] = useState([]);
|
|
@@ -38,12 +40,18 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
38
40
|
clearTimeout(debounceRef.current);
|
|
39
41
|
};
|
|
40
42
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
|
-
}, [query, minPoints]);
|
|
43
|
+
}, [query, minPoints, minComments]);
|
|
42
44
|
async function loadPreview() {
|
|
43
45
|
const myToken = ++previewToken.current;
|
|
44
46
|
const points = Number(minPoints) || 0;
|
|
47
|
+
const comments = Number(minComments) || 0;
|
|
45
48
|
try {
|
|
46
|
-
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
|
+
});
|
|
47
55
|
if (myToken === previewToken.current)
|
|
48
56
|
setPreview(results);
|
|
49
57
|
}
|
|
@@ -60,11 +68,17 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
60
68
|
if (!trimmedQuery)
|
|
61
69
|
return setError('query is required');
|
|
62
70
|
const points = Number(minPoints) || 0;
|
|
71
|
+
const comments = Number(minComments) || 0;
|
|
63
72
|
try {
|
|
64
73
|
if (mode === 'add')
|
|
65
|
-
addSubscription(trimmedName, trimmedQuery, points);
|
|
74
|
+
addSubscription(trimmedName, trimmedQuery, points, comments);
|
|
66
75
|
else
|
|
67
|
-
updateSubscription(subscription.id, {
|
|
76
|
+
updateSubscription(subscription.id, {
|
|
77
|
+
name: trimmedName,
|
|
78
|
+
query: trimmedQuery,
|
|
79
|
+
minPoints: points,
|
|
80
|
+
minComments: comments,
|
|
81
|
+
});
|
|
68
82
|
if (mode === 'add' && listSubscriptions().length === 1 && !hasScheduledJob()) {
|
|
69
83
|
setScheduleConfirm(true);
|
|
70
84
|
return;
|
|
@@ -110,6 +124,8 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
110
124
|
setQuery((v) => v.slice(0, -1));
|
|
111
125
|
if (field === 'minPoints')
|
|
112
126
|
setMinPoints((v) => v.slice(0, -1));
|
|
127
|
+
if (field === 'minComments')
|
|
128
|
+
setMinComments((v) => v.slice(0, -1));
|
|
113
129
|
return;
|
|
114
130
|
}
|
|
115
131
|
if (input && !key.ctrl && !key.meta) {
|
|
@@ -120,10 +136,13 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
120
136
|
if (field === 'minPoints' && /^\d+$/.test(input)) {
|
|
121
137
|
setMinPoints((v) => (v === '0' ? input : v + input));
|
|
122
138
|
}
|
|
139
|
+
if (field === 'minComments' && /^\d+$/.test(input)) {
|
|
140
|
+
setMinComments((v) => (v === '0' ? input : v + input));
|
|
141
|
+
}
|
|
123
142
|
}
|
|
124
143
|
}
|
|
125
144
|
useInput(handleInput);
|
|
126
145
|
const cursor = (f) => (field === f ? '▏' : '');
|
|
127
|
-
const previewLabel = Number(minPoints)
|
|
128
|
-
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: " " }), 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" }))] }));
|
|
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" }))] }));
|
|
129
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');
|
|
@@ -5,14 +5,12 @@ 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
7
|
import { hasScheduledJob, installScheduledJob, removeScheduledJob } from '../lib/schedule.js';
|
|
8
|
+
import { thresholdLabel } from '../lib/subscriptionLabel.js';
|
|
8
9
|
import { ensureVisibleLines, sliceByLines } from '../lib/viewport.js';
|
|
9
10
|
import { footerRows, SUBS_KEYS } from './keymap.js';
|
|
10
11
|
import { HEADER_ROWS } from './Layout.js';
|
|
11
12
|
import { useTheme } from './theme.js';
|
|
12
13
|
import { useFlash } from './useFlash.js';
|
|
13
|
-
function pointsLabel(minPoints) {
|
|
14
|
-
return minPoints === 0 ? 'any' : `≥${minPoints} pts`;
|
|
15
|
-
}
|
|
16
14
|
export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange, onTabChange, }) {
|
|
17
15
|
const theme = useTheme();
|
|
18
16
|
const { columns, rows } = useWindowSize();
|
|
@@ -103,6 +101,6 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
|
|
|
103
101
|
const isSelected = index === selected;
|
|
104
102
|
const marker = isSelected ? theme.glyphs.selection : ' ';
|
|
105
103
|
const background = isSelected ? theme.colors.selectionBackground : undefined;
|
|
106
|
-
return (_jsxs(Text, { backgroundColor: background, children: [marker, " ", sub.name.padEnd(nameWidth), " \"", sub.query, "\" ",
|
|
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));
|
|
107
105
|
}), deleteConfirm && _jsxs(Text, { dimColor: true, children: ["delete '", deleteConfirm, "'? y/n"] }), _jsx(Text, { dimColor: true, children: scheduleStatusText }), scheduleFlash && _jsx(Text, { dimColor: true, children: scheduleFlash })] }));
|
|
108
106
|
}
|
package/dist/watch.js
CHANGED
|
@@ -2,6 +2,7 @@ import { searchRecent } from './api/algolia.js';
|
|
|
2
2
|
import { isSeen, markSeen } from './db/seen.js';
|
|
3
3
|
import { listSubscriptions, touchLastRun } from './db/subscriptions.js';
|
|
4
4
|
import { loadConfig } from './lib/config.js';
|
|
5
|
+
import { createDesktopNotifier } from './notify/desktop.js';
|
|
5
6
|
import { createTelegramNotifier } from './notify/telegram.js';
|
|
6
7
|
const SIX_HOURS = 6 * 60 * 60;
|
|
7
8
|
const TWENTY_FOUR_HOURS = 24 * 60 * 60;
|
|
@@ -15,11 +16,21 @@ function windowStart(lastRunAt, now) {
|
|
|
15
16
|
}
|
|
16
17
|
function buildNotifiers() {
|
|
17
18
|
const config = loadConfig();
|
|
19
|
+
const notifiers = [];
|
|
20
|
+
let desktopSkipped = false;
|
|
18
21
|
const telegram = config?.telegram;
|
|
19
22
|
if (telegram?.enabled && telegram.botToken && telegram.chatId) {
|
|
20
|
-
|
|
23
|
+
notifiers.push(createTelegramNotifier({ botToken: telegram.botToken, chatId: telegram.chatId }));
|
|
21
24
|
}
|
|
22
|
-
|
|
25
|
+
const desktop = config?.desktopNotifications;
|
|
26
|
+
if (desktop?.enabled) {
|
|
27
|
+
const notifier = createDesktopNotifier({ timeoutSeconds: desktop.timeoutSeconds });
|
|
28
|
+
if (notifier)
|
|
29
|
+
notifiers.push(notifier);
|
|
30
|
+
else
|
|
31
|
+
desktopSkipped = true;
|
|
32
|
+
}
|
|
33
|
+
return { notifiers, desktopSkipped };
|
|
23
34
|
}
|
|
24
35
|
async function notifyStory(sub, story, notifiers) {
|
|
25
36
|
try {
|
|
@@ -35,7 +46,11 @@ async function notifyStory(sub, story, notifiers) {
|
|
|
35
46
|
async function processSubscription(sub, notifiers, now, dryRun) {
|
|
36
47
|
let stories;
|
|
37
48
|
try {
|
|
38
|
-
stories = await searchRecent(sub.query, {
|
|
49
|
+
stories = await searchRecent(sub.query, {
|
|
50
|
+
createdAfter: windowStart(sub.lastRunAt, now),
|
|
51
|
+
minPoints: sub.minPoints,
|
|
52
|
+
minComments: sub.minComments,
|
|
53
|
+
});
|
|
39
54
|
}
|
|
40
55
|
catch (err) {
|
|
41
56
|
log(`${sub.name}: query failed - ${err.message}`);
|
|
@@ -71,9 +86,11 @@ async function processSubscription(sub, notifiers, now, dryRun) {
|
|
|
71
86
|
}
|
|
72
87
|
/** hn watch --once: one-shot pass over all subscriptions. Returns the process exit code. */
|
|
73
88
|
export async function runWatch(options) {
|
|
74
|
-
const notifiers = buildNotifiers();
|
|
89
|
+
const { notifiers, desktopSkipped } = buildNotifiers();
|
|
75
90
|
if (notifiers.length === 0) {
|
|
76
|
-
|
|
91
|
+
if (desktopSkipped)
|
|
92
|
+
return 0;
|
|
93
|
+
console.error('no notifier configured: hn config set telegram.enabled true (and botToken/chatId) or desktopNotifications.enabled true');
|
|
77
94
|
return 2;
|
|
78
95
|
}
|
|
79
96
|
const subs = listSubscriptions();
|