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 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 + Telegram notifications
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
  ```
@@ -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 numericFilters = [`created_at_i>${options.createdAfter}`];
59
- if (options.minPoints)
60
- numericFilters.push(`points>=${options.minPoints}`);
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: numericFilters.join(','),
65
- hitsPerPage: String(options.hitsPerPage ?? 50),
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');
@@ -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 = Number(options.minPoints);
139
- if (!Number.isInteger(minPoints) || minPoints < 0) {
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 points = s.minPoints === 0 ? 'any' : `>=${s.minPoints}`;
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}" ${points} ${lastRun}`);
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, { createdAfter: windowStart(), minPoints: points, hitsPerPage: PREVIEW_LIMIT });
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, { name: trimmedName, query: trimmedQuery, minPoints: points });
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) > 0 ? `≥${minPoints} pts` : 'any points';
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, "\" ", pointsLabel(sub.minPoints).padEnd(9), " added", ' ', formatAge(sub.createdAt), " ago"] }, sub.id));
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
- return [createTelegramNotifier({ botToken: telegram.botToken, chatId: telegram.chatId })];
23
+ notifiers.push(createTelegramNotifier({ botToken: telegram.botToken, chatId: telegram.chatId }));
21
24
  }
22
- return [];
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, { createdAfter: windowStart(sub.lastRunAt, now), minPoints: sub.minPoints });
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
- console.error('no notifier configured: hn config set telegram.enabled true (and botToken/chatId)');
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hn-bits",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Terminal-first Hacker News client",
5
5
  "type": "module",
6
6
  "repository": {