hn-bits 0.2.0 → 0.3.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.
@@ -0,0 +1,82 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { Box, Text, useInput, useWindowSize } from 'ink';
4
+ import { listSubscriptions, removeSubscription } from '../db/subscriptions.js';
5
+ import { formatAge } from '../lib/format.js';
6
+ import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
7
+ import { ensureVisibleLines, sliceByLines } from '../lib/viewport.js';
8
+ import { footerRows, SUBS_KEYS } from './keymap.js';
9
+ import { HEADER_ROWS } from './Layout.js';
10
+ import { theme } from './theme.js';
11
+ function pointsLabel(minPoints) {
12
+ return minPoints === 0 ? 'any' : `≥${minPoints} pts`;
13
+ }
14
+ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange, onTabChange, }) {
15
+ const { columns, rows } = useWindowSize();
16
+ const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SUBS_KEYS, columns));
17
+ const [subs, setSubs] = useState(() => listSubscriptions());
18
+ const [selected, setSelected] = useState(0);
19
+ const [deleteConfirm, setDeleteConfirm] = useState(null);
20
+ const pendingTopJump = useRef(false);
21
+ const topLineRef = useRef(0);
22
+ function reload() {
23
+ const next = listSubscriptions();
24
+ setSubs(next);
25
+ setSelected((s) => clampSelection(s, 0, next.length));
26
+ }
27
+ function handleInput(input, key) {
28
+ if (deleteConfirm) {
29
+ if (input === 'y') {
30
+ removeSubscription(deleteConfirm);
31
+ reload();
32
+ }
33
+ setDeleteConfirm(null);
34
+ return;
35
+ }
36
+ const isG = input === 'g';
37
+ if (isG && pendingTopJump.current) {
38
+ pendingTopJump.current = false;
39
+ return setSelected(0);
40
+ }
41
+ pendingTopJump.current = isG;
42
+ const targetFeed = mapFeedKey(input);
43
+ if (targetFeed)
44
+ return onFeedChange(targetFeed);
45
+ if (key.leftArrow)
46
+ return onTabChange(-1);
47
+ if (key.rightArrow)
48
+ return onTabChange(1);
49
+ if (input === 'j' || key.downArrow)
50
+ return setSelected((s) => clampSelection(s, 1, subs.length));
51
+ if (input === 'k' || key.upArrow)
52
+ return setSelected((s) => clampSelection(s, -1, subs.length));
53
+ if (input === 'G')
54
+ return setSelected(Math.max(0, subs.length - 1));
55
+ if (key.return && subs[selected])
56
+ return onSelectMatches(subs[selected]);
57
+ if (input === 'a')
58
+ return onAdd();
59
+ if (input === 'e' && subs[selected])
60
+ return onEdit(subs[selected]);
61
+ if (input === 'd' && subs[selected])
62
+ return setDeleteConfirm(subs[selected].name);
63
+ }
64
+ useInput(handleInput);
65
+ if (subs.length === 0) {
66
+ return _jsx(Text, { dimColor: true, children: "no subscriptions yet - press a to add" });
67
+ }
68
+ const statusLineHeight = deleteConfirm ? 1 : 0;
69
+ const listHeight = Math.max(1, bodyHeight - statusLineHeight);
70
+ const heights = subs.map(() => 1);
71
+ const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
72
+ topLineRef.current = topLine;
73
+ const { first, last } = sliceByLines(heights, topLine, listHeight);
74
+ const nameWidth = Math.max(...subs.map((s) => s.name.length));
75
+ return (_jsxs(Box, { flexDirection: "column", children: [subs.slice(first, last + 1).map((sub, i) => {
76
+ const index = first + i;
77
+ const isSelected = index === selected;
78
+ const marker = isSelected ? theme.glyphs.selection : ' ';
79
+ const background = isSelected ? theme.colors.selectionBackground : undefined;
80
+ 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));
81
+ }), deleteConfirm && _jsxs(Text, { dimColor: true, children: ["delete '", deleteConfirm, "'? y/n"] })] }));
82
+ }
package/dist/ui/TabBar.js CHANGED
@@ -2,17 +2,19 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useWindowSize } from 'ink';
3
3
  import { theme } from './theme.js';
4
4
  const TABS = [
5
- { feed: 'top', label: 'Top' },
6
- { feed: 'new', label: 'New' },
7
- { feed: 'best', label: 'Best' },
8
- { feed: 'ask', label: 'Ask' },
9
- { feed: 'show', label: 'Show' },
5
+ { id: 'top', label: 'Top' },
6
+ { id: 'new', label: 'New' },
7
+ { id: 'best', label: 'Best' },
8
+ { id: 'ask', label: 'Ask' },
9
+ { id: 'show', label: 'Show' },
10
+ { id: 'saved', label: 'Saved' },
11
+ { id: 'subs', label: 'Subs' },
10
12
  ];
11
13
  const BRAND = 'Hacker News';
12
14
  const HELP_HINT = '? help';
13
15
  const TAB_GAP = ' ';
14
16
  function tabSegment(tab, active) {
15
- if (tab.feed !== active)
17
+ if (tab.id !== active)
16
18
  return { segment: { text: tab.label, color: 'muted' }, width: tab.label.length };
17
19
  const inner = ` ${tab.label} `;
18
20
  return { segment: { text: `│${inner}│`, color: 'accent', bold: true }, width: inner.length };
@@ -29,7 +31,7 @@ function buildTabSegments(active) {
29
31
  }
30
32
  const { segment, width } = tabSegment(tab, active);
31
33
  segments.push(segment);
32
- if (tab.feed === active) {
34
+ if (tab.id === active) {
33
35
  activeWallColumn = column;
34
36
  activeInnerWidth = width;
35
37
  }
package/dist/ui/keymap.js CHANGED
@@ -14,6 +14,7 @@ export const LIST_KEYS = [
14
14
  { key: '/', label: 'search' },
15
15
  { key: 's', label: 'summary' },
16
16
  { key: 'a', label: 'ask ai' },
17
+ { key: 'B', label: 'bookmark' },
17
18
  ];
18
19
  export const COMMENTS_KEYS = [
19
20
  { key: 'j/k', label: 'move' },
@@ -24,6 +25,7 @@ export const COMMENTS_KEYS = [
24
25
  { key: 'r', label: 'reload' },
25
26
  { key: 's', label: 'summary' },
26
27
  { key: 'a', label: 'ask ai' },
28
+ { key: 'B', label: 'bookmark' },
27
29
  { key: 'esc', label: 'back' },
28
30
  ];
29
31
  export const SEARCH_RESULTS_KEYS = [
@@ -34,8 +36,48 @@ export const SEARCH_RESULTS_KEYS = [
34
36
  { key: '/', label: 'new search' },
35
37
  { key: 's', label: 'summary' },
36
38
  { key: 'a', label: 'ask ai' },
39
+ { key: 'B', label: 'bookmark' },
40
+ { key: 'S', label: 'subscribe' },
37
41
  { key: 'esc', label: 'back/quit' },
38
42
  ];
43
+ export const SUBS_KEYS = [
44
+ { key: 'j/k', label: 'move' },
45
+ { key: '←/→', label: 'tab' },
46
+ { key: 't/n/b', label: 'feed' },
47
+ { key: 'enter', label: 'matches' },
48
+ { key: 'a', label: 'add' },
49
+ { key: 'e', label: 'edit' },
50
+ { key: 'd', label: 'delete' },
51
+ ];
52
+ export const SUB_MATCHES_KEYS = [
53
+ { key: 'j/k', label: 'move' },
54
+ { key: 'gg/G', label: 'top/bottom' },
55
+ { key: 'enter', label: 'comments' },
56
+ { key: 'o', label: 'browser' },
57
+ { key: 'B', label: 'bookmark' },
58
+ { key: 's', label: 'summary' },
59
+ { key: 'a', label: 'ask ai' },
60
+ { key: 'r', label: 'refetch' },
61
+ { key: 'esc', label: 'back' },
62
+ ];
63
+ export const SUB_FORM_KEYS = [
64
+ { key: 'tab', label: 'next field' },
65
+ { key: 'enter', label: 'save' },
66
+ { key: 'esc', label: 'cancel' },
67
+ ];
68
+ export const SAVED_KEYS = [
69
+ { key: 'j/k', label: 'move' },
70
+ { key: '←/→', label: 'tab' },
71
+ { key: 't/n/b', label: 'feed' },
72
+ { key: 'gg/G', label: 'top/bottom' },
73
+ { key: 'enter', label: 'comments' },
74
+ { key: 'o', label: 'browser' },
75
+ { key: 'r', label: 'reload' },
76
+ { key: '/', label: 'search' },
77
+ { key: 's', label: 'summary' },
78
+ { key: 'a', label: 'ask ai' },
79
+ { key: 'B', label: 'remove' },
80
+ ];
39
81
  /** Footer hint string: view keys followed by the global keys. */
40
82
  export function footerHint(viewKeys) {
41
83
  return [...viewKeys, ...GLOBAL_KEYS].map((binding) => `${binding.key} ${binding.label}`).join(' · ');
package/dist/ui/theme.js CHANGED
@@ -3,6 +3,7 @@ const glyphs = {
3
3
  foldOpen: '▾',
4
4
  foldClosed: '▸',
5
5
  upvote: '▲',
6
+ bookmark: '★',
6
7
  };
7
8
  function ansi256(code) {
8
9
  return `ansi256(${code})`;
@@ -0,0 +1,14 @@
1
+ import { useRef, useState } from 'react';
2
+ const FLASH_MS = 1500;
3
+ /** Transient status text (e.g. "bookmarked ✓") that self-clears after FLASH_MS. */
4
+ export function useFlash() {
5
+ const [message, setMessage] = useState(null);
6
+ const timer = useRef(null);
7
+ function flash(text) {
8
+ if (timer.current)
9
+ clearTimeout(timer.current);
10
+ setMessage(text);
11
+ timer.current = setTimeout(() => setMessage(null), FLASH_MS);
12
+ }
13
+ return [message, flash];
14
+ }
package/dist/watch.js ADDED
@@ -0,0 +1,96 @@
1
+ import { searchRecent } from './api/algolia.js';
2
+ import { isSeen, markSeen } from './db/seen.js';
3
+ import { listSubscriptions, touchLastRun } from './db/subscriptions.js';
4
+ import { loadConfig } from './lib/config.js';
5
+ import { createTelegramNotifier } from './notify/telegram.js';
6
+ const SIX_HOURS = 6 * 60 * 60;
7
+ const TWENTY_FOUR_HOURS = 24 * 60 * 60;
8
+ function log(message) {
9
+ console.log(`[${new Date().toISOString()}] ${message}`);
10
+ }
11
+ function windowStart(lastRunAt, now) {
12
+ if (lastRunAt == null)
13
+ return now - TWENTY_FOUR_HOURS;
14
+ return Math.max(lastRunAt - SIX_HOURS, 0);
15
+ }
16
+ function buildNotifiers() {
17
+ const config = loadConfig();
18
+ const telegram = config?.telegram;
19
+ if (telegram?.enabled && telegram.botToken && telegram.chatId) {
20
+ return [createTelegramNotifier({ botToken: telegram.botToken, chatId: telegram.chatId })];
21
+ }
22
+ return [];
23
+ }
24
+ async function notifyStory(sub, story, notifiers) {
25
+ try {
26
+ for (const notifier of notifiers)
27
+ await notifier.send({ subscription: sub, story });
28
+ return true;
29
+ }
30
+ catch (err) {
31
+ log(`${sub.name}: notify failed for ${story.id} - ${err.message}`);
32
+ return false;
33
+ }
34
+ }
35
+ async function processSubscription(sub, notifiers, now, dryRun) {
36
+ let stories;
37
+ try {
38
+ stories = await searchRecent(sub.query, { createdAfter: windowStart(sub.lastRunAt, now), minPoints: sub.minPoints });
39
+ }
40
+ catch (err) {
41
+ log(`${sub.name}: query failed - ${err.message}`);
42
+ return { notified: 0, hadFailure: true };
43
+ }
44
+ const matches = stories.filter((story) => !isSeen(story.id, sub.id)).sort((a, b) => a.time - b.time);
45
+ if (matches.length === 0) {
46
+ log(`${sub.name}: no new matches`);
47
+ if (!dryRun)
48
+ touchLastRun(sub.id, now);
49
+ return { notified: 0, hadFailure: false };
50
+ }
51
+ log(`${sub.name}: ${matches.length} new matches`);
52
+ let notified = 0;
53
+ let hadFailure = false;
54
+ for (const story of matches) {
55
+ if (dryRun) {
56
+ log(`would notify: [${sub.name}] ${story.title} (${story.score} pts)`);
57
+ continue;
58
+ }
59
+ const sent = await notifyStory(sub, story, notifiers);
60
+ if (!sent) {
61
+ hadFailure = true;
62
+ continue;
63
+ }
64
+ markSeen(story.id, sub.id, now);
65
+ log(`${sub.name}: notified ${story.id} "${story.title}" (${story.score} pts)`);
66
+ notified++;
67
+ }
68
+ if (!dryRun)
69
+ touchLastRun(sub.id, now);
70
+ return { notified, hadFailure };
71
+ }
72
+ /** hn watch --once: one-shot pass over all subscriptions. Returns the process exit code. */
73
+ export async function runWatch(options) {
74
+ const notifiers = buildNotifiers();
75
+ if (notifiers.length === 0) {
76
+ console.error('no notifier configured: hn config set telegram.enabled true (and botToken/chatId)');
77
+ return 2;
78
+ }
79
+ const subs = listSubscriptions();
80
+ if (subs.length === 0) {
81
+ log('no subscriptions');
82
+ return 0;
83
+ }
84
+ log(`watch: ${subs.length} subscriptions`);
85
+ const now = Math.floor(Date.now() / 1000);
86
+ let totalNotified = 0;
87
+ let failedSubs = 0;
88
+ for (const sub of subs) {
89
+ const result = await processSubscription(sub, notifiers, now, options.dryRun);
90
+ totalNotified += result.notified;
91
+ if (result.hadFailure)
92
+ failedSubs++;
93
+ }
94
+ log(`done: ${totalNotified} notified, ${failedSubs} failed`);
95
+ return failedSubs > 0 ? 1 : 0;
96
+ }
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "hn-bits",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Terminal-first Hacker News client",
5
5
  "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/navinrc/hn-bits.git"
9
+ },
6
10
  "bin": {
7
11
  "hn": "./dist/index.js"
8
12
  },
@@ -22,6 +26,7 @@
22
26
  "license": "MIT",
23
27
  "dependencies": {
24
28
  "@mozilla/readability": "^0.6.0",
29
+ "better-sqlite3": "^12.11.1",
25
30
  "commander": "^15.0.0",
26
31
  "ink": "^7.1.0",
27
32
  "jsdom": "^29.1.1",
@@ -29,6 +34,7 @@
29
34
  "react": "^19.2.7"
30
35
  },
31
36
  "devDependencies": {
37
+ "@types/better-sqlite3": "^7.6.13",
32
38
  "@types/jsdom": "^28.0.3",
33
39
  "@types/node": "^26.1.0",
34
40
  "@types/react": "^19.2.17",