hn-bits 0.2.1 → 0.4.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 +93 -13
- package/dist/api/algolia.js +31 -11
- package/dist/db/bookmarks.js +37 -0
- package/dist/db/db.js +64 -0
- package/dist/db/seen.js +12 -0
- package/dist/db/subscriptions.js +44 -0
- package/dist/index.js +85 -5
- package/dist/lib/config.js +1 -0
- package/dist/lib/configKeys.js +9 -0
- package/dist/lib/listNavigation.js +7 -7
- package/dist/notify/notifier.js +1 -0
- package/dist/notify/telegram.js +58 -0
- package/dist/ui/App.js +92 -10
- package/dist/ui/AskAI.js +2 -1
- package/dist/ui/Comments.js +18 -9
- package/dist/ui/HelpOverlay.js +2 -1
- package/dist/ui/Layout.js +2 -1
- package/dist/ui/LoadingIndicator.js +2 -1
- package/dist/ui/SavedList.js +90 -0
- package/dist/ui/SearchResults.js +13 -4
- package/dist/ui/StoryList.js +14 -7
- package/dist/ui/StoryRow.js +7 -3
- package/dist/ui/SubscriptionForm.js +104 -0
- package/dist/ui/SubscriptionMatches.js +103 -0
- package/dist/ui/SubscriptionsView.js +83 -0
- package/dist/ui/SummaryPanel.js +2 -1
- package/dist/ui/TabBar.js +13 -10
- package/dist/ui/ThemePicker.js +28 -0
- package/dist/ui/keymap.js +48 -0
- package/dist/ui/theme.js +38 -2
- package/dist/ui/useFlash.js +14 -0
- package/dist/watch.js +96 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
# hn-bits
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A fullscreen terminal client for Hacker News, built with TypeScript + Ink. Optional local AI summaries via Ollama, topic subscriptions with Telegram alerts, no cloud required beyond what you opt into.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
[](https://github.com/navinrc/hn-bits/actions)
|
|
7
|
+
[](https://github.com/navinrc/hn-bits/releases)
|
|
8
|
+
[](https://www.npmjs.com/package/hn-bits)
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- Fullscreen TUI: top/new/best/ask/show/saved/subs tabs, vim-style navigation
|
|
20
|
+
- Threaded comments view with fold/collapse
|
|
21
|
+
- Search across stories
|
|
22
|
+
- 7 built-in themes (`hn`, `mocha`, `dracula`, `tokyo`, `nord`, `gruvbox`, `concord`)
|
|
23
|
+
- Local AI article/thread summaries via Ollama, no cloud calls
|
|
24
|
+
- Ask AI: multi-turn Q&A grounded in the current story
|
|
25
|
+
- Subscriptions + `hn watch --once` radar with Telegram notifications
|
|
26
|
+
- Bookmarks: `B` to save a story, `hn bookmarks` to browse them
|
|
27
|
+
- SQLite storage (subscriptions, dedup, bookmarks); config file for AI/Telegram settings
|
|
4
28
|
|
|
5
29
|
## Install
|
|
6
30
|
|
|
@@ -25,18 +49,18 @@ npm link # exposes the `hn` binary
|
|
|
25
49
|
## Usage
|
|
26
50
|
|
|
27
51
|
```bash
|
|
28
|
-
hn # fullscreen shell: top/new/best/ask/show tabs
|
|
52
|
+
hn # fullscreen shell: top/new/best/ask/show/saved/subs tabs
|
|
29
53
|
hn search <query...> # search results
|
|
30
|
-
hn
|
|
54
|
+
hn bookmarks # opens straight into the saved tab
|
|
55
|
+
hn subs # opens straight into the subs tab
|
|
56
|
+
hn --theme dracula # themes: hn (default), mocha, dracula, tokyo, nord, gruvbox, concord
|
|
31
57
|
```
|
|
32
58
|
|
|
33
59
|
Press `?` from any view for the full keybinding help overlay.
|
|
34
60
|
|
|
35
61
|
### Local AI (summaries + Ask AI)
|
|
36
62
|
|
|
37
|
-
Article/thread summaries (`s`) and interactive Q&A (`a`) run against a local [Ollama](https://ollama.com) instance
|
|
38
|
-
|
|
39
|
-

|
|
63
|
+
Article/thread summaries (`s`) and interactive Q&A (`a`) run against a local [Ollama](https://ollama.com) instance, no cloud calls, no API keys. Optional: the app works fully without it, `s`/`a` just show a setup hint until configured.
|
|
40
64
|
|
|
41
65
|
**Prerequisites**
|
|
42
66
|
|
|
@@ -46,7 +70,7 @@ ollama serve # if not already running as a service
|
|
|
46
70
|
ollama pull llama3.2 # or any other chat-capable model
|
|
47
71
|
```
|
|
48
72
|
|
|
49
|
-
**Setup**
|
|
73
|
+
**Setup** (one-time):
|
|
50
74
|
|
|
51
75
|
```bash
|
|
52
76
|
hn config set ollama.host http://localhost:11434
|
|
@@ -55,9 +79,43 @@ hn config set ollama.model llama3.2
|
|
|
55
79
|
|
|
56
80
|
Missing fields fall back to the defaults above; invalid JSON degrades to "AI disabled" with a warning rather than crashing the reader.
|
|
57
81
|
|
|
82
|
+
### Subscriptions + Telegram notifications
|
|
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.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
hn sub add apple "Apple" --min-points 50 # topic name, Algolia query, min score
|
|
88
|
+
hn sub list
|
|
89
|
+
hn sub rm apple
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Telegram setup** (one-time):
|
|
93
|
+
|
|
94
|
+
1. Message [`@BotFather`](https://t.me/BotFather) on Telegram, run `/newbot`, copy the token it gives you.
|
|
95
|
+
2. Message your new bot once (anything, e.g. "hi").
|
|
96
|
+
3. Fetch `https://api.telegram.org/bot<TOKEN>/getUpdates` and read `result[0].message.chat.id`.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
hn config set telegram.botToken 123456:ABC-...
|
|
100
|
+
hn config set telegram.chatId 987654321
|
|
101
|
+
hn config set telegram.enabled true
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
All three keys are required, `telegram.enabled` alone is not enough. With nothing enabled, `hn watch --once` exits with code 2.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
hn watch --once # one pass: query subscriptions, notify new matches, exit
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Schedule it, e.g. every 30 minutes via crontab:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
*/30 * * * * cd /path/to/hn-bits && hn watch --once
|
|
114
|
+
```
|
|
115
|
+
|
|
58
116
|
### Configuration
|
|
59
117
|
|
|
60
|
-
`hn config` reads/writes `~/.config/hn-bits/config.json` (override the path with `$HN_BITS_CONFIG`)
|
|
118
|
+
`hn config` reads/writes `~/.config/hn-bits/config.json` (override the path with `$HN_BITS_CONFIG`), no need to hand-edit JSON:
|
|
61
119
|
|
|
62
120
|
```bash
|
|
63
121
|
hn config list # every known key + current value
|
|
@@ -66,11 +124,11 @@ hn config set ollama.model llama3.2 # validates + writes
|
|
|
66
124
|
hn config unset ollama.model # removes a key
|
|
67
125
|
```
|
|
68
126
|
|
|
69
|
-
Sensitive values (e.g.
|
|
127
|
+
Sensitive values (e.g. `telegram.botToken`) print masked in `list` but raw from `get`, so scripts can still consume them. File format is plain JSON; hand-editing still works if you prefer it.
|
|
70
128
|
|
|
71
129
|
### Themes
|
|
72
130
|
|
|
73
|
-
`hn`, `mocha`, `dracula`, `tokyo`, `nord`, `gruvbox` (default: `hn`, HN-orange). Pick with `--theme <name
|
|
131
|
+
`hn`, `mocha`, `dracula`, `tokyo`, `nord`, `gruvbox`, `concord` (default: `hn`, HN-orange). Pick with `--theme <name>`, the `HN_THEME` env var, `hn config set ui.theme <name>`, or press `T` in the TUI to switch live and persist the choice. Precedence: flag > env > config > default. `hn theme` shows the active theme and where it came from.
|
|
74
132
|
|
|
75
133
|
### Keybindings
|
|
76
134
|
|
|
@@ -95,6 +153,7 @@ Sensitive values (e.g. a future Telegram bot token) print masked in `list` but r
|
|
|
95
153
|
| `/` | search |
|
|
96
154
|
| `s` | AI summary (article, or thread if no article) |
|
|
97
155
|
| `a` | Ask AI (chat grounded in the story) |
|
|
156
|
+
| `B` | bookmark |
|
|
98
157
|
|
|
99
158
|
**Comments**
|
|
100
159
|
|
|
@@ -108,11 +167,31 @@ Sensitive values (e.g. a future Telegram bot token) print masked in `list` but r
|
|
|
108
167
|
| `r` | reload |
|
|
109
168
|
| `s` | AI thread summary |
|
|
110
169
|
| `a` | Ask AI (chat grounded in the story) |
|
|
170
|
+
| `B` | bookmark |
|
|
111
171
|
| `esc`/`b` | back |
|
|
112
172
|
|
|
113
173
|
**Search results**
|
|
114
174
|
|
|
115
|
-
Same as story list, minus the tab/feed keys, plus `/` to start a new search. `esc` returns to the list if you got here via `/` in-TUI, or quits if you entered via `hn search`.
|
|
175
|
+
Same as story list, minus the tab/feed keys, plus `/` to start a new search and `S` to subscribe (prefills a topic from the query). `esc` returns to the list if you got here via `/` in-TUI, or quits if you entered via `hn search`.
|
|
176
|
+
|
|
177
|
+
**Saved** (`hn bookmarks`, or the Saved tab)
|
|
178
|
+
|
|
179
|
+
Same as story list, minus search; `B` removes a bookmark instead of adding one.
|
|
180
|
+
|
|
181
|
+
**Subs** (`hn subs`, or the Subs tab)
|
|
182
|
+
|
|
183
|
+
| Key | Action |
|
|
184
|
+
|-----|--------|
|
|
185
|
+
| `j`/`k` or ↓/↑ | move selection |
|
|
186
|
+
| `←`/`→` | previous/next tab |
|
|
187
|
+
| `enter` | browse matches for the selected topic |
|
|
188
|
+
| `a` | add subscription |
|
|
189
|
+
| `e` | edit subscription |
|
|
190
|
+
| `d` | delete subscription |
|
|
191
|
+
|
|
192
|
+
**Sub matches** (opened from Subs via `enter`)
|
|
193
|
+
|
|
194
|
+
Same as story list, minus search/tab-switch; `r` refetches, `esc` returns to Subs.
|
|
116
195
|
|
|
117
196
|
## Development
|
|
118
197
|
|
|
@@ -126,9 +205,10 @@ npm run build # tsc
|
|
|
126
205
|
|
|
127
206
|
- TypeScript (strict, ESM) + Ink (React for terminals)
|
|
128
207
|
- Commander CLI, native `fetch`
|
|
129
|
-
- HN Firebase + Algolia APIs
|
|
208
|
+
- HN Firebase + Algolia APIs, no backend
|
|
209
|
+
- `better-sqlite3` for subscriptions/dedup/bookmarks
|
|
130
210
|
- Vitest
|
|
131
211
|
|
|
132
212
|
## Specs
|
|
133
213
|
|
|
134
|
-
Implementation specs live in [`specs/`](specs/README.md); phase-by-phase status is tracked in [`PROGRESS.md`](PROGRESS.md). V1, V1.5, V1.6,
|
|
214
|
+
Implementation specs live in [`specs/`](specs/README.md); phase-by-phase status is tracked in [`PROGRESS.md`](PROGRESS.md). V1, V1.5, V1.6, V2 (local AI), and V3 (subscriptions/watcher/notifications/bookmarks) are feature-complete.
|
package/dist/api/algolia.js
CHANGED
|
@@ -25,6 +25,17 @@ export async function fetchComments(storyId) {
|
|
|
25
25
|
const item = await getJson(`${BASE}/items/${storyId}`);
|
|
26
26
|
return item.children.map(toNode).filter((c) => c != null);
|
|
27
27
|
}
|
|
28
|
+
function hitToStory(hit) {
|
|
29
|
+
return {
|
|
30
|
+
id: Number(hit.objectID),
|
|
31
|
+
title: hit.title,
|
|
32
|
+
url: hit.url ?? undefined,
|
|
33
|
+
by: hit.author ?? '?',
|
|
34
|
+
score: hit.points ?? 0,
|
|
35
|
+
descendants: hit.num_comments ?? 0,
|
|
36
|
+
time: hit.created_at_i,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
28
39
|
export async function searchStories(query, page = 0) {
|
|
29
40
|
const params = new URLSearchParams({
|
|
30
41
|
query,
|
|
@@ -34,18 +45,27 @@ export async function searchStories(query, page = 0) {
|
|
|
34
45
|
});
|
|
35
46
|
const res = await getJson(`${BASE}/search?${params}`);
|
|
36
47
|
return {
|
|
37
|
-
stories: res.hits
|
|
38
|
-
.filter((h) => h.title != null)
|
|
39
|
-
.map((h) => ({
|
|
40
|
-
id: Number(h.objectID),
|
|
41
|
-
title: h.title,
|
|
42
|
-
url: h.url ?? undefined,
|
|
43
|
-
by: h.author ?? '?',
|
|
44
|
-
score: h.points ?? 0,
|
|
45
|
-
descendants: h.num_comments ?? 0,
|
|
46
|
-
time: h.created_at_i,
|
|
47
|
-
})),
|
|
48
|
+
stories: res.hits.filter((h) => h.title != null).map(hitToStory),
|
|
48
49
|
hasMore: page + 1 < res.nbPages,
|
|
49
50
|
totalHits: res.nbHits,
|
|
50
51
|
};
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Recency-ordered search (subscription matching + watcher window rescans),
|
|
55
|
+
* as opposed to searchStories' relevance ordering.
|
|
56
|
+
*/
|
|
57
|
+
export async function searchRecent(query, options) {
|
|
58
|
+
const numericFilters = [`created_at_i>${options.createdAfter}`];
|
|
59
|
+
if (options.minPoints)
|
|
60
|
+
numericFilters.push(`points>=${options.minPoints}`);
|
|
61
|
+
const params = new URLSearchParams({
|
|
62
|
+
query,
|
|
63
|
+
tags: 'story',
|
|
64
|
+
numericFilters: numericFilters.join(','),
|
|
65
|
+
hitsPerPage: String(options.hitsPerPage ?? 50),
|
|
66
|
+
typoTolerance: 'false',
|
|
67
|
+
queryType: 'prefixNone',
|
|
68
|
+
});
|
|
69
|
+
const res = await getJson(`${BASE}/search_by_date?${params}`);
|
|
70
|
+
return res.hits.filter((h) => h.title != null).map(hitToStory);
|
|
71
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { openDb } from './db.js';
|
|
2
|
+
function toStory(row) {
|
|
3
|
+
return {
|
|
4
|
+
id: row.story_id,
|
|
5
|
+
title: row.title,
|
|
6
|
+
url: row.url ?? undefined,
|
|
7
|
+
by: row.by,
|
|
8
|
+
score: row.score,
|
|
9
|
+
descendants: row.descendants,
|
|
10
|
+
time: row.time,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
let lastBookmarkedAt = 0;
|
|
14
|
+
/** Monotonic ms timestamp — Date.now() alone can tie on rapid successive bookmarks. */
|
|
15
|
+
function nextBookmarkedAt() {
|
|
16
|
+
lastBookmarkedAt = Math.max(Date.now(), lastBookmarkedAt + 1);
|
|
17
|
+
return lastBookmarkedAt;
|
|
18
|
+
}
|
|
19
|
+
export function isBookmarked(storyId) {
|
|
20
|
+
const row = openDb().prepare('SELECT 1 FROM bookmarks WHERE story_id = ?').get(storyId);
|
|
21
|
+
return row != null;
|
|
22
|
+
}
|
|
23
|
+
/** Toggles the bookmark for a story; returns the new state (true = now bookmarked). */
|
|
24
|
+
export function toggleBookmark(story) {
|
|
25
|
+
const db = openDb();
|
|
26
|
+
if (isBookmarked(story.id)) {
|
|
27
|
+
db.prepare('DELETE FROM bookmarks WHERE story_id = ?').run(story.id);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
db.prepare(`INSERT INTO bookmarks (story_id, title, url, by, score, descendants, time, bookmarked_at)
|
|
31
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(story.id, story.title, story.url ?? null, story.by, story.score, story.descendants, story.time, nextBookmarkedAt());
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
export function listBookmarks() {
|
|
35
|
+
const rows = openDb().prepare('SELECT * FROM bookmarks ORDER BY bookmarked_at DESC').all();
|
|
36
|
+
return rows.map(toStory);
|
|
37
|
+
}
|
package/dist/db/db.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import DatabaseConstructor from 'better-sqlite3';
|
|
5
|
+
const MIGRATIONS = [
|
|
6
|
+
`
|
|
7
|
+
CREATE TABLE subscriptions (
|
|
8
|
+
id INTEGER PRIMARY KEY,
|
|
9
|
+
name TEXT NOT NULL UNIQUE,
|
|
10
|
+
query TEXT NOT NULL,
|
|
11
|
+
min_points INTEGER NOT NULL DEFAULT 0,
|
|
12
|
+
created_at INTEGER NOT NULL,
|
|
13
|
+
last_run_at INTEGER
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE TABLE seen_items (
|
|
17
|
+
story_id INTEGER NOT NULL,
|
|
18
|
+
subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
|
|
19
|
+
notified_at INTEGER NOT NULL,
|
|
20
|
+
PRIMARY KEY (story_id, subscription_id)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE bookmarks (
|
|
24
|
+
story_id INTEGER PRIMARY KEY,
|
|
25
|
+
title TEXT NOT NULL,
|
|
26
|
+
url TEXT,
|
|
27
|
+
by TEXT NOT NULL,
|
|
28
|
+
score INTEGER NOT NULL,
|
|
29
|
+
descendants INTEGER NOT NULL,
|
|
30
|
+
time INTEGER NOT NULL,
|
|
31
|
+
bookmarked_at INTEGER NOT NULL
|
|
32
|
+
);
|
|
33
|
+
`,
|
|
34
|
+
];
|
|
35
|
+
export function dbPath() {
|
|
36
|
+
return process.env.HN_BITS_DB || join(homedir(), '.local', 'share', 'hn-bits', 'hn-bits.db');
|
|
37
|
+
}
|
|
38
|
+
let cached = null;
|
|
39
|
+
function migrate(db) {
|
|
40
|
+
const currentVersion = db.pragma('user_version', { simple: true });
|
|
41
|
+
for (let i = currentVersion; i < MIGRATIONS.length; i++) {
|
|
42
|
+
db.transaction(() => {
|
|
43
|
+
db.exec(MIGRATIONS[i]);
|
|
44
|
+
db.pragma(`user_version = ${i + 1}`);
|
|
45
|
+
})();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Opens (or returns the cached handle for) the app database, running pending migrations. */
|
|
49
|
+
export function openDb() {
|
|
50
|
+
const path = dbPath();
|
|
51
|
+
if (cached && cached.path === path)
|
|
52
|
+
return cached.db;
|
|
53
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
54
|
+
const db = new DatabaseConstructor(path);
|
|
55
|
+
db.pragma('journal_mode = WAL');
|
|
56
|
+
migrate(db);
|
|
57
|
+
cached = { path, db };
|
|
58
|
+
return db;
|
|
59
|
+
}
|
|
60
|
+
/** Test-only: drops the cached handle so the next openDb() re-resolves HN_BITS_DB. */
|
|
61
|
+
export function resetDbCache() {
|
|
62
|
+
cached?.db.close();
|
|
63
|
+
cached = null;
|
|
64
|
+
}
|
package/dist/db/seen.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { openDb } from './db.js';
|
|
2
|
+
export function isSeen(storyId, subscriptionId) {
|
|
3
|
+
const row = openDb()
|
|
4
|
+
.prepare('SELECT 1 FROM seen_items WHERE story_id = ? AND subscription_id = ?')
|
|
5
|
+
.get(storyId, subscriptionId);
|
|
6
|
+
return row != null;
|
|
7
|
+
}
|
|
8
|
+
export function markSeen(storyId, subscriptionId, at) {
|
|
9
|
+
openDb()
|
|
10
|
+
.prepare('INSERT OR IGNORE INTO seen_items (story_id, subscription_id, notified_at) VALUES (?, ?, ?)')
|
|
11
|
+
.run(storyId, subscriptionId, at);
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { openDb } from './db.js';
|
|
2
|
+
function fromRow(row) {
|
|
3
|
+
return {
|
|
4
|
+
id: row.id,
|
|
5
|
+
name: row.name,
|
|
6
|
+
query: row.query,
|
|
7
|
+
minPoints: row.min_points,
|
|
8
|
+
createdAt: row.created_at,
|
|
9
|
+
lastRunAt: row.last_run_at,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function assertNameFree(name, excludeId) {
|
|
13
|
+
const db = openDb();
|
|
14
|
+
const conflict = excludeId === undefined
|
|
15
|
+
? db.prepare('SELECT 1 FROM subscriptions WHERE name = ?').get(name)
|
|
16
|
+
: db.prepare('SELECT 1 FROM subscriptions WHERE name = ? AND id != ?').get(name, excludeId);
|
|
17
|
+
if (conflict)
|
|
18
|
+
throw new Error(`subscription '${name}' already exists`);
|
|
19
|
+
}
|
|
20
|
+
export function addSubscription(name, query, minPoints) {
|
|
21
|
+
assertNameFree(name);
|
|
22
|
+
const createdAt = Math.floor(Date.now() / 1000);
|
|
23
|
+
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 };
|
|
27
|
+
}
|
|
28
|
+
export function listSubscriptions() {
|
|
29
|
+
const rows = openDb().prepare('SELECT * FROM subscriptions ORDER BY created_at ASC').all();
|
|
30
|
+
return rows.map(fromRow);
|
|
31
|
+
}
|
|
32
|
+
export function removeSubscription(name) {
|
|
33
|
+
const info = openDb().prepare('DELETE FROM subscriptions WHERE name = ?').run(name);
|
|
34
|
+
return info.changes > 0;
|
|
35
|
+
}
|
|
36
|
+
export function updateSubscription(id, fields) {
|
|
37
|
+
assertNameFree(fields.name, id);
|
|
38
|
+
const db = openDb();
|
|
39
|
+
db.prepare('UPDATE subscriptions SET name = ?, query = ?, min_points = ? WHERE id = ?').run(fields.name, fields.query, fields.minPoints, id);
|
|
40
|
+
return fromRow(db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id));
|
|
41
|
+
}
|
|
42
|
+
export function touchLastRun(id, at) {
|
|
43
|
+
openDb().prepare('UPDATE subscriptions SET last_run_at = ? WHERE id = ?').run(at, id);
|
|
44
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -26,15 +26,36 @@ program
|
|
|
26
26
|
const { App } = await import('./ui/App.js');
|
|
27
27
|
render(_jsx(App, { initialQuery: queryParts.join(' ') }), { alternateScreen });
|
|
28
28
|
});
|
|
29
|
+
program
|
|
30
|
+
.command('bookmarks')
|
|
31
|
+
.description('Open the TUI on the Saved tab')
|
|
32
|
+
.action(async () => {
|
|
33
|
+
applyTheme(program.opts());
|
|
34
|
+
const { App } = await import('./ui/App.js');
|
|
35
|
+
render(_jsx(App, { initialView: "saved" }), { alternateScreen });
|
|
36
|
+
});
|
|
37
|
+
program
|
|
38
|
+
.command('subs')
|
|
39
|
+
.description('Open the TUI on the Subs tab')
|
|
40
|
+
.action(async () => {
|
|
41
|
+
applyTheme(program.opts());
|
|
42
|
+
const { App } = await import('./ui/App.js');
|
|
43
|
+
render(_jsx(App, { initialView: "subs" }), { alternateScreen });
|
|
44
|
+
});
|
|
29
45
|
program
|
|
30
46
|
.command('theme')
|
|
31
47
|
.description('Show the active color theme and available palettes')
|
|
32
48
|
.action(async () => {
|
|
33
|
-
const { paletteNames, resolvePaletteName } = await import('./ui/theme.js');
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
49
|
+
const { paletteNames, resolvePaletteName, resolvePaletteSource } = await import('./ui/theme.js');
|
|
50
|
+
const flag = program.opts().theme;
|
|
51
|
+
const activeName = resolvePaletteName(flag);
|
|
52
|
+
const source = resolvePaletteSource(flag);
|
|
53
|
+
const sourceLabel = source === 'default' ? '(default)' : `(from ${source})`;
|
|
54
|
+
console.log(`Active theme: ${activeName} ${sourceLabel}`);
|
|
55
|
+
console.log(`Available: ${paletteNames()
|
|
56
|
+
.map((n) => (n === 'hn' ? `${n} (default)` : n))
|
|
57
|
+
.join(', ')}`);
|
|
58
|
+
console.log('Set with `hn --theme <name>`, the HN_THEME environment variable, or `hn config set ui.theme <name>`.');
|
|
38
59
|
});
|
|
39
60
|
const config = program.command('config').description('Get or set hn-bits config values');
|
|
40
61
|
config
|
|
@@ -90,4 +111,63 @@ config
|
|
|
90
111
|
process.exit(1);
|
|
91
112
|
}
|
|
92
113
|
});
|
|
114
|
+
const sub = program.command('sub').description('Manage topic subscriptions');
|
|
115
|
+
sub
|
|
116
|
+
.command('add <name> <query...>')
|
|
117
|
+
.description('Add a subscription')
|
|
118
|
+
.option('--min-points <n>', 'minimum points threshold', '0')
|
|
119
|
+
.action(async (name, queryParts, options) => {
|
|
120
|
+
const minPoints = Number(options.minPoints);
|
|
121
|
+
if (!Number.isInteger(minPoints) || minPoints < 0) {
|
|
122
|
+
console.error(`--min-points must be a non-negative integer, got '${options.minPoints}'`);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
const { addSubscription } = await import('./db/subscriptions.js');
|
|
126
|
+
try {
|
|
127
|
+
addSubscription(name, queryParts.join(' '), minPoints);
|
|
128
|
+
console.log(`added '${name}'`);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
console.error(err.message);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
sub
|
|
136
|
+
.command('list')
|
|
137
|
+
.description('List subscriptions')
|
|
138
|
+
.action(async () => {
|
|
139
|
+
const { listSubscriptions } = await import('./db/subscriptions.js');
|
|
140
|
+
const { formatAge } = await import('./lib/format.js');
|
|
141
|
+
const subs = listSubscriptions();
|
|
142
|
+
if (subs.length === 0) {
|
|
143
|
+
console.log('no subscriptions yet');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const nameWidth = Math.max(...subs.map((s) => s.name.length));
|
|
147
|
+
for (const s of subs) {
|
|
148
|
+
const points = s.minPoints === 0 ? 'any' : `>=${s.minPoints}`;
|
|
149
|
+
const lastRun = s.lastRunAt == null ? 'never' : `${formatAge(s.lastRunAt)} ago`;
|
|
150
|
+
console.log(`${s.name.padEnd(nameWidth)} "${s.query}" ${points} ${lastRun}`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
sub
|
|
154
|
+
.command('rm <name>')
|
|
155
|
+
.description('Remove a subscription')
|
|
156
|
+
.action(async (name) => {
|
|
157
|
+
const { removeSubscription } = await import('./db/subscriptions.js');
|
|
158
|
+
if (!removeSubscription(name)) {
|
|
159
|
+
console.error(`subscription '${name}' not found`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
console.log(`removed '${name}'`);
|
|
163
|
+
});
|
|
164
|
+
program
|
|
165
|
+
.command('watch')
|
|
166
|
+
.description('Check subscriptions for new matches and notify (one-shot; cron owns scheduling)')
|
|
167
|
+
.requiredOption('--once', 'run a single pass and exit (required; guards against future daemon semantics)')
|
|
168
|
+
.option('--dry-run', 'print would-notify matches without sending or writing seen_items')
|
|
169
|
+
.action(async (options) => {
|
|
170
|
+
const { runWatch } = await import('./watch.js');
|
|
171
|
+
process.exit(await runWatch({ dryRun: Boolean(options.dryRun) }));
|
|
172
|
+
});
|
|
93
173
|
program.parse();
|
package/dist/lib/config.js
CHANGED
package/dist/lib/configKeys.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { paletteNames } from '../ui/theme.js';
|
|
2
|
+
function validateTheme(raw) {
|
|
3
|
+
const names = paletteNames();
|
|
4
|
+
if (!names.includes(raw)) {
|
|
5
|
+
throw new Error(`unknown theme '${raw}' (valid: ${names.join(', ')})`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
1
8
|
export const CONFIG_KEYS = {
|
|
2
9
|
'ollama.host': { path: ['ollama', 'host'], type: 'string' },
|
|
3
10
|
'ollama.model': { path: ['ollama', 'model'], type: 'string' },
|
|
@@ -6,9 +13,11 @@ export const CONFIG_KEYS = {
|
|
|
6
13
|
'telegram.chatId': { path: ['telegram', 'chatId'], type: 'string' },
|
|
7
14
|
'desktopNotifications.enabled': { path: ['desktopNotifications', 'enabled'], type: 'boolean' },
|
|
8
15
|
'desktopNotifications.timeoutSeconds': { path: ['desktopNotifications', 'timeoutSeconds'], type: 'number' },
|
|
16
|
+
'ui.theme': { path: ['ui', 'theme'], type: 'string', validate: validateTheme },
|
|
9
17
|
};
|
|
10
18
|
/** Coerces a CLI string argument to the key's declared type. Throws on invalid input. */
|
|
11
19
|
export function parseValue(def, raw) {
|
|
20
|
+
def.validate?.(raw);
|
|
12
21
|
if (def.type === 'boolean') {
|
|
13
22
|
if (raw !== 'true' && raw !== 'false')
|
|
14
23
|
throw new Error(`expected 'true' or 'false', got '${raw}'`);
|
|
@@ -7,12 +7,12 @@ const FEED_KEYS = { t: 'top', n: 'new', b: 'best' };
|
|
|
7
7
|
export function mapFeedKey(key) {
|
|
8
8
|
return FEED_KEYS[key];
|
|
9
9
|
}
|
|
10
|
-
const
|
|
11
|
-
export function
|
|
12
|
-
const index =
|
|
13
|
-
return
|
|
10
|
+
const TAB_ORDER = ['top', 'new', 'best', 'ask', 'show', 'saved', 'subs'];
|
|
11
|
+
export function nextTab(current) {
|
|
12
|
+
const index = TAB_ORDER.indexOf(current);
|
|
13
|
+
return TAB_ORDER[(index + 1) % TAB_ORDER.length];
|
|
14
14
|
}
|
|
15
|
-
export function
|
|
16
|
-
const index =
|
|
17
|
-
return
|
|
15
|
+
export function previousTab(current) {
|
|
16
|
+
const index = TAB_ORDER.indexOf(current);
|
|
17
|
+
return TAB_ORDER[(index - 1 + TAB_ORDER.length) % TAB_ORDER.length];
|
|
18
18
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { hnItemUrl } from '../api/firebase.js';
|
|
2
|
+
const SEND_TIMEOUT_MS = 10_000;
|
|
3
|
+
export class NotifyError extends Error {
|
|
4
|
+
}
|
|
5
|
+
function escapeHtml(text) {
|
|
6
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
7
|
+
}
|
|
8
|
+
function buildMessage({ subscription, story }) {
|
|
9
|
+
const lines = [`🔔 <b>${escapeHtml(subscription.name)}</b>`];
|
|
10
|
+
if (story.url) {
|
|
11
|
+
lines.push(`<a href="${story.url}">${escapeHtml(story.title)}</a>`);
|
|
12
|
+
lines.push(`${story.score} points · ${story.descendants} comments · by ${escapeHtml(story.by)}`);
|
|
13
|
+
lines.push(`<a href="${hnItemUrl(story.id)}">HN discussion</a>`);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
lines.push(`<a href="${hnItemUrl(story.id)}">${escapeHtml(story.title)}</a>`);
|
|
17
|
+
lines.push(`${story.score} points · ${story.descendants} comments · by ${escapeHtml(story.by)}`);
|
|
18
|
+
}
|
|
19
|
+
return lines.join('\n');
|
|
20
|
+
}
|
|
21
|
+
async function post(config, text) {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const timeout = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
|
|
24
|
+
try {
|
|
25
|
+
return await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'Content-Type': 'application/json' },
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
chat_id: config.chatId,
|
|
30
|
+
text,
|
|
31
|
+
parse_mode: 'HTML',
|
|
32
|
+
disable_web_page_preview: false,
|
|
33
|
+
}),
|
|
34
|
+
signal: controller.signal,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function sendMessage(config, text, retried = false) {
|
|
42
|
+
const res = await post(config, text);
|
|
43
|
+
const body = (await res.json().catch(() => null));
|
|
44
|
+
if (res.ok && body?.ok)
|
|
45
|
+
return;
|
|
46
|
+
if (res.status === 429 && !retried) {
|
|
47
|
+
const retryAfter = body?.parameters?.retry_after ?? 1;
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
|
|
49
|
+
return sendMessage(config, text, true);
|
|
50
|
+
}
|
|
51
|
+
throw new NotifyError(`telegram sendMessage failed: ${res.status}`);
|
|
52
|
+
}
|
|
53
|
+
export function createTelegramNotifier(config) {
|
|
54
|
+
return {
|
|
55
|
+
name: 'telegram',
|
|
56
|
+
send: (match) => sendMessage(config, buildMessage(match)),
|
|
57
|
+
};
|
|
58
|
+
}
|