hn-bits 0.5.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.
@@ -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
+ }
@@ -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
@@ -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, { createdAfter: windowStart(sub.lastRunAt, now), minPoints: sub.minPoints });
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}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hn-bits",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Terminal-first Hacker News client",
5
5
  "type": "module",
6
6
  "repository": {