@isomoes/iread 0.1.0 → 0.2.2

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
@@ -4,6 +4,8 @@ A concise, local, single-user RSS/Atom reader inspired by newsboat. Keyboard-fir
4
4
 
5
5
  There is no auth, no multi-user, no cloud sync, and no background scheduler. Refresh is always user-initiated. All data lives in a single local SQLite file — by default `~/.config/iread/iread.db` — which is the trust boundary.
6
6
 
7
+ ![iread UI](https://github.com/user-attachments/assets/ca9489c9-630f-4a67-9cd5-60c18ce3544e)
8
+
7
9
  ## Quick start
8
10
 
9
11
  ```sh
@@ -19,6 +21,9 @@ Options:
19
21
  -p, --port <port> Port to listen on (default: $PORT or 8787)
20
22
  --db <path> SQLite database file
21
23
  (default: $DB_PATH or ~/.config/iread/iread.db)
24
+ --opml <path> OPML file auto-saved on every subscription change
25
+ (default: $OPML_PATH or feeds.opml next to the database;
26
+ pass "" to disable)
22
27
  -v, --version Print the version and exit
23
28
  -h, --help Show this help and exit
24
29
  ```
@@ -35,7 +40,7 @@ Options:
35
40
  - Star and unstar.
36
41
  - Live case-insensitive search over title plus summary in the current view.
37
42
  - Full keyboard navigation as the primary interaction model.
38
- - OPML import (bulk add) and OPML export.
43
+ - OPML import (bulk add) and OPML export, plus an always-current `feeds.opml` auto-saved next to the database on every change for quick sharing and backup. The auto-saved file is a one-way snapshot of the database (overwritten on every change and at startup), so use it for backup and sharing — to bring feeds in, use OPML import.
39
44
  - Light, dark, and system theme, persisted to localStorage.
40
45
 
41
46
  ## Requirements (development)
@@ -78,7 +83,7 @@ pnpm start
78
83
 
79
84
  Open http://localhost:8787
80
85
 
81
- `PORT` (default 8787) and `DB_PATH` (default `~/.config/iread/iread.db`) are read from the environment. See `config/.env.example`.
86
+ `PORT` (default 8787), `DB_PATH` (default `~/.config/iread/iread.db`), and `OPML_PATH` (default `feeds.opml` next to the database; set empty to disable the auto-saved mirror) are read from the environment. See `config/.env.example`.
82
87
 
83
88
  ## Publish to npm
84
89
 
@@ -114,6 +119,7 @@ Keys are case-sensitive (Shift matters). Bindings fire only when you are not typ
114
119
  | `r` | Refresh current feed | Refresh the selected feed, or the feed of the selected article in a smart view. |
115
120
  | `R` | Refresh all feeds | Refresh every feed and update counts on completion. |
116
121
  | `v` | Open original | Open the article link in a new tab. |
122
+ | `#` then N | Open link by number | Links in the article body are numbered inline `[N]`; press `#`, type the number, and it opens in a new tab — instantly once the number is unambiguous, otherwise `Enter` confirms; `Esc` cancels. |
117
123
  | `/` | Focus search | Focus and select the search input. |
118
124
  | `Esc` | Contextual dismiss | Close help, clear search, or return focus from the reader to the list. |
119
125
  | `?` | Help overlay | Toggle the keybinding overlay. |
@@ -30,6 +30,9 @@ Options:
30
30
  -p, --port <port> Port to listen on (default: $PORT or 8787)
31
31
  --db <path> SQLite database file
32
32
  (default: $DB_PATH or ~/.config/iread/iread.db)
33
+ --opml <path> OPML file auto-saved on every subscription change
34
+ (default: $OPML_PATH or feeds.opml next to the database;
35
+ pass "" to disable)
33
36
  -v, --version Print the version and exit
34
37
  -h, --help Show this help and exit
35
38
  `;
@@ -74,6 +77,13 @@ for (let i = 0; i < args.length; i++) {
74
77
  process.env.DB_PATH = value;
75
78
  break;
76
79
  }
80
+ case '--opml': {
81
+ const value = args[++i];
82
+ if (value === undefined)
83
+ fail('--opml requires a file path (pass "" to disable)');
84
+ process.env.OPML_PATH = value;
85
+ break;
86
+ }
77
87
  default:
78
88
  fail(`unknown option: ${arg}`);
79
89
  }
@@ -81,4 +91,3 @@ for (let i = 0; i < args.length; i++) {
81
91
  // Serve the prebuilt web bundle unless the caller explicitly set NODE_ENV.
82
92
  process.env.NODE_ENV ??= 'production';
83
93
  await import('./index.js');
84
- //# sourceMappingURL=cli.js.map
package/dist/server/db.js CHANGED
@@ -21,7 +21,7 @@ function defaultDbPath() {
21
21
  const DB_PATH = process.env.DB_PATH ?? defaultDbPath();
22
22
  // Resolve to an absolute path and ensure the containing directory exists
23
23
  // (DatabaseSync will not create intermediate directories on its own).
24
- const dbPath = resolve(process.cwd(), DB_PATH);
24
+ export const dbPath = resolve(process.cwd(), DB_PATH);
25
25
  mkdirSync(dirname(dbPath), { recursive: true });
26
26
  export const db = new DatabaseSync(dbPath);
27
27
  // Connection PRAGMAs. WAL keeps readers from erroring during writes;
@@ -155,4 +155,3 @@ export function totalChanges() {
155
155
  const row = db.prepare('SELECT total_changes() AS n').get();
156
156
  return row.n;
157
157
  }
158
- //# sourceMappingURL=db.js.map
@@ -9,6 +9,7 @@ import { db, transaction, totalChanges, queryAll, queryGet, run, } from './db.js
9
9
  import { fetchFeed } from './fetch-feed.js';
10
10
  import { assertSafeFeedUrl } from './ssrf.js';
11
11
  import { sanitizeArticleHtml, toPlainText } from './sanitize.js';
12
+ import { writeOpmlSnapshot } from './opml-sync.js';
12
13
  const SUMMARY_MAX = 280;
13
14
  const LIMIT_DEFAULT = 50;
14
15
  const LIMIT_MAX = 200;
@@ -211,7 +212,12 @@ const UPDATE_FEED_ON_SUCCESS = `
211
212
  last_fetched_at = ?, fetch_error = NULL
212
213
  WHERE id = ?
213
214
  `;
214
- async function refreshFeedRow(row) {
215
+ // Phase 1 (async, no DB, runs concurrently across feeds): fetch + parse + sanitize.
216
+ // Never throws — a failed fetch/parse becomes an 'error' plan so the caller's loop
217
+ // always completes. CRITICAL (review fix #13): the new etag/last_modified are
218
+ // carried in the plan and persisted only by applyRefresh, after a fully
219
+ // successful parse+upsert.
220
+ async function planRefresh(row) {
215
221
  const now = Date.now();
216
222
  try {
217
223
  const result = await fetchFeed({
@@ -219,30 +225,56 @@ async function refreshFeedRow(row) {
219
225
  etag: row.etag,
220
226
  lastModified: row.last_modified,
221
227
  });
222
- if (result.kind === 'notModified') {
228
+ if (result.kind === 'notModified')
229
+ return { kind: 'notModified', now };
230
+ const { feedMeta, commit } = await parseAndPrepare(row.feed_url, result.xml);
231
+ return {
232
+ kind: 'ok',
233
+ now,
234
+ feedMeta,
235
+ commit,
236
+ etag: result.etag,
237
+ lastModified: result.lastModified,
238
+ };
239
+ }
240
+ catch (err) {
241
+ return { kind: 'error', now, message: err instanceof Error ? err.message : String(err) };
242
+ }
243
+ }
244
+ // Record a failed refresh on the feed row. Shared by the error-plan branch and the
245
+ // catch fallback so the UPDATE + error outcome live in exactly one place.
246
+ function recordFeedError(row, now, message) {
247
+ db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = ? WHERE id = ?`).run(now, message, row.id);
248
+ return { feed: getFeedWithCounts(row.id), newItems: 0, ok: false };
249
+ }
250
+ // Phase 2 (synchronous DB writes): apply one plan. Fully synchronous — it never
251
+ // awaits — so even when called from concurrent fetch workers its writes can't
252
+ // overlap and node:sqlite never awaits mid-transaction. Only the commit + feed-row
253
+ // update run inside the transaction.
254
+ function applyRefresh(row, plan) {
255
+ try {
256
+ if (plan.kind === 'notModified') {
223
257
  // 304: nothing changed. Update last_fetched_at and clear any prior error.
224
- db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = NULL WHERE id = ?`).run(now, row.id);
258
+ db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = NULL WHERE id = ?`).run(plan.now, row.id);
225
259
  return { feed: getFeedWithCounts(row.id), newItems: 0, ok: true };
226
260
  }
227
- // Parse + sanitize OUTSIDE the transaction (async, CPU work, no DB). Only the
228
- // synchronous DB writes (commit) run inside the transaction so node:sqlite
229
- // never awaits mid-transaction. CRITICAL (review fix #13): the new
230
- // etag/last_modified are persisted only here, after a fully successful
231
- // parse+upsert.
232
- const { feedMeta, commit } = await parseAndPrepare(row.feed_url, result.xml);
233
- const newItems = transaction(() => {
234
- const inserted = commit(row.id);
235
- db.prepare(UPDATE_FEED_ON_SUCCESS).run(feedMeta.title, feedMeta.siteUrl, feedMeta.description, result.etag, result.lastModified, now, row.id);
236
- return inserted;
237
- });
238
- return { feed: getFeedWithCounts(row.id), newItems, ok: true };
261
+ if (plan.kind === 'ok') {
262
+ const newItems = transaction(() => {
263
+ const inserted = plan.commit(row.id);
264
+ db.prepare(UPDATE_FEED_ON_SUCCESS).run(plan.feedMeta.title, plan.feedMeta.siteUrl, plan.feedMeta.description, plan.etag, plan.lastModified, plan.now, row.id);
265
+ return inserted;
266
+ });
267
+ return { feed: getFeedWithCounts(row.id), newItems, ok: true };
268
+ }
269
+ return recordFeedError(row, plan.now, plan.message);
239
270
  }
240
271
  catch (err) {
241
- const message = err instanceof Error ? err.message : String(err);
242
- db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = ? WHERE id = ?`).run(now, message, row.id);
243
- return { feed: getFeedWithCounts(row.id), newItems: 0, ok: false };
272
+ return recordFeedError(row, plan.now, err instanceof Error ? err.message : String(err));
244
273
  }
245
274
  }
275
+ async function refreshFeedRow(row) {
276
+ return applyRefresh(row, await planRefresh(row));
277
+ }
246
278
  function getFeedRow(id) {
247
279
  return queryGet(`SELECT * FROM feeds WHERE id = ?`, id);
248
280
  }
@@ -253,32 +285,59 @@ export async function refreshFeed(id) {
253
285
  if (!row)
254
286
  throw new ServiceError('NOT_FOUND', 'Feed not found.');
255
287
  const outcome = await refreshFeedRow(row);
288
+ syncOpmlMirror();
256
289
  return { feed: outcome.feed, newItems: outcome.newItems };
257
290
  }
258
- /** Refresh every feed sequentially. Never fails because one feed errored;
259
- * tallies refreshed/failed/newItems and returns the full updated feed list. */
291
+ // Max feeds fetched+parsed at once in refreshAll. Bounded so we never open
292
+ // hundreds of sockets or buffer hundreds of 10 MB bodies simultaneously.
293
+ const REFRESH_CONCURRENCY = 8;
294
+ // Map items through an async worker with at most `limit` in flight, preserving
295
+ // input order in the result. Used to overlap the per-feed fetch/parse/apply work.
296
+ async function mapWithConcurrency(items, limit, worker) {
297
+ const results = new Array(items.length);
298
+ let next = 0;
299
+ const runners = Array.from({ length: Math.min(limit, items.length) }, () => (async () => {
300
+ for (;;) {
301
+ const i = next++;
302
+ if (i >= items.length)
303
+ return;
304
+ results[i] = await worker(items[i], i);
305
+ }
306
+ })());
307
+ await Promise.all(runners);
308
+ return results;
309
+ }
310
+ /** Refresh every feed. Each feed's fetch + parse runs concurrently (bounded by
311
+ * REFRESH_CONCURRENCY) and its synchronous apply runs in the same worker, so the
312
+ * DB writes serialize naturally and only ~concurrency feeds' parsed content is held
313
+ * at once. Never fails because one feed errored; tallies refreshed/failed/newItems
314
+ * and returns the full updated list. */
260
315
  export async function refreshAll() {
261
316
  const rows = queryAll(`SELECT * FROM feeds ORDER BY id ASC`);
262
317
  let refreshed = 0;
263
318
  let failed = 0;
264
319
  let newItems = 0;
265
- // Sequential, one transaction per feed (commit per feed keeps each transaction
266
- // short on the synchronous DB; one failure never aborts the loop).
267
- for (const row of rows) {
320
+ // Each worker fetches+parses (concurrent) then applies its own plan (synchronous,
321
+ // so applies never overlap and node:sqlite stays happy). The try/catch guarantees
322
+ // the batch always completes: even if applyRefresh's own error path throws (a
323
+ // DB-level fault), that feed is tallied as failed rather than aborting the request.
324
+ const outcomes = await mapWithConcurrency(rows, REFRESH_CONCURRENCY, async (row) => {
268
325
  try {
269
- const outcome = await refreshFeedRow(row);
270
- if (outcome.ok)
271
- refreshed += 1;
272
- else
273
- failed += 1;
274
- newItems += outcome.newItems;
326
+ const outcome = applyRefresh(row, await planRefresh(row));
327
+ return { ok: outcome.ok, newItems: outcome.newItems };
275
328
  }
276
329
  catch {
277
- // refreshFeedRow already records its own errors; this guards against any
278
- // unexpected throw so the loop always completes.
279
- failed += 1;
330
+ return { ok: false, newItems: 0 };
280
331
  }
332
+ });
333
+ for (const outcome of outcomes) {
334
+ if (outcome.ok)
335
+ refreshed += 1;
336
+ else
337
+ failed += 1;
338
+ newItems += outcome.newItems;
281
339
  }
340
+ syncOpmlMirror();
282
341
  return { feeds: listFeeds().feeds, refreshed, failed, newItems };
283
342
  }
284
343
  // ---------------------------------------------------------------------------
@@ -336,6 +395,7 @@ export async function addFeed(url) {
336
395
  const feed = getFeedWithCounts(feedId);
337
396
  if (!feed)
338
397
  throw new ServiceError('NOT_FOUND', 'Feed disappeared after insert.');
398
+ syncOpmlMirror();
339
399
  return feed;
340
400
  }
341
401
  // ---------------------------------------------------------------------------
@@ -345,6 +405,7 @@ export function deleteFeed(id) {
345
405
  const res = run(`DELETE FROM feeds WHERE id = ?`, id);
346
406
  if (res.changes === 0)
347
407
  throw new ServiceError('NOT_FOUND', 'Feed not found.');
408
+ syncOpmlMirror();
348
409
  }
349
410
  // ---------------------------------------------------------------------------
350
411
  // listItems (PLAN 7 GET /api/items) — dynamic WHERE built from fixed fragments
@@ -513,6 +574,7 @@ export async function importFeeds(urls) {
513
574
  }
514
575
  }
515
576
  }
577
+ syncOpmlMirror();
516
578
  return { added, skipped, failed, feeds: listFeeds().feeds };
517
579
  }
518
580
  /** All feeds as plain Feed DTOs (for OPML export). */
@@ -520,4 +582,6 @@ export function listFeedsForExport() {
520
582
  const rows = queryAll(`SELECT * FROM feeds ORDER BY title COLLATE NOCASE ASC, id ASC`);
521
583
  return rows.map(mapFeed);
522
584
  }
523
- //# sourceMappingURL=feed-service.js.map
585
+ export function syncOpmlMirror() {
586
+ writeOpmlSnapshot(listFeedsForExport());
587
+ }
@@ -80,4 +80,3 @@ export async function fetchFeed(input) {
80
80
  clearTimeout(timer);
81
81
  }
82
82
  }
83
- //# sourceMappingURL=fetch-feed.js.map
@@ -17,6 +17,8 @@ const IS_PROD = process.env.NODE_ENV === 'production';
17
17
  // Importing ./db has the side effect of opening the DB and running migrations.
18
18
  // Do it explicitly so startup failures surface immediately.
19
19
  import './db.js';
20
+ import { syncOpmlMirror } from './feed-service.js';
21
+ syncOpmlMirror();
20
22
  const app = new Hono();
21
23
  // --- API routes (registered FIRST) -----------------------------------------
22
24
  const api = new Hono();
@@ -59,4 +61,3 @@ serve({ fetch: app.fetch, port: PORT }, (info) => {
59
61
  console.log(`[iread] listening on ${url}`);
60
62
  });
61
63
  export { app };
62
- //# sourceMappingURL=index.js.map
@@ -0,0 +1,41 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { dbPath } from './db.js';
4
+ import { exportOpml } from './opml.js';
5
+ function resolveMirrorPath() {
6
+ const override = process.env.OPML_PATH;
7
+ if (override !== undefined) {
8
+ const trimmed = override.trim();
9
+ return trimmed === '' ? null : resolve(process.cwd(), trimmed);
10
+ }
11
+ return join(dirname(dbPath), 'feeds.opml');
12
+ }
13
+ export const opmlMirrorPath = resolveMirrorPath();
14
+ function withoutDateCreated(xml) {
15
+ return xml.replace(/[ \t]*<dateCreated>.*?<\/dateCreated>\r?\n?/, '');
16
+ }
17
+ export function writeOpmlSnapshot(feeds) {
18
+ if (!opmlMirrorPath)
19
+ return;
20
+ try {
21
+ const xml = exportOpml(feeds);
22
+ let existing = null;
23
+ try {
24
+ existing = readFileSync(opmlMirrorPath, 'utf-8');
25
+ }
26
+ catch {
27
+ existing = null;
28
+ }
29
+ if (existing !== null && withoutDateCreated(existing) === withoutDateCreated(xml)) {
30
+ return;
31
+ }
32
+ mkdirSync(dirname(opmlMirrorPath), { recursive: true });
33
+ const tmp = `${opmlMirrorPath}.tmp`;
34
+ writeFileSync(tmp, xml, 'utf-8');
35
+ renameSync(tmp, opmlMirrorPath);
36
+ }
37
+ catch (err) {
38
+ const message = err instanceof Error ? err.message : String(err);
39
+ console.warn(`[iread] could not write OPML mirror at ${opmlMirrorPath}: ${message}`);
40
+ }
41
+ }
@@ -102,6 +102,7 @@ export function importOpml(xml) {
102
102
  // ---------------------------------------------------------------------------
103
103
  function escapeXmlAttr(value) {
104
104
  return value
105
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
105
106
  .replace(/&/g, '&amp;')
106
107
  .replace(/</g, '&lt;')
107
108
  .replace(/>/g, '&gt;')
@@ -134,4 +135,3 @@ export function exportOpml(feeds) {
134
135
  lines.push('');
135
136
  return lines.join('\n');
136
137
  }
137
- //# sourceMappingURL=opml.js.map
@@ -65,4 +65,3 @@ feeds.delete('/:id', (c) => {
65
65
  return serviceErrorToResponse(c, err);
66
66
  }
67
67
  });
68
- //# sourceMappingURL=feeds.js.map
@@ -41,4 +41,3 @@ export function parseOptionalInt(raw) {
41
41
  const n = Number(raw);
42
42
  return Number.isFinite(n) ? Math.trunc(n) : undefined;
43
43
  }
44
- //# sourceMappingURL=helpers.js.map
@@ -92,4 +92,3 @@ items.patch('/:id', async (c) => {
92
92
  return serviceErrorToResponse(c, err);
93
93
  }
94
94
  });
95
- //# sourceMappingURL=items.js.map
@@ -47,4 +47,3 @@ opml.post('/', async (c) => {
47
47
  const body = result;
48
48
  return c.json(body, 200);
49
49
  });
50
- //# sourceMappingURL=opml.js.map
@@ -72,4 +72,3 @@ export function sanitizeArticleHtml(raw) {
72
72
  export function toPlainText(raw) {
73
73
  return sanitizeHtml(raw, { allowedTags: [], allowedAttributes: {} });
74
74
  }
75
- //# sourceMappingURL=sanitize.js.map
@@ -272,4 +272,3 @@ export async function fetchWithGuardedRedirects(initialUrl, init) {
272
272
  }
273
273
  throw new SsrfError(`Too many redirects (> ${MAX_REDIRECTS}).`);
274
274
  }
275
- //# sourceMappingURL=ssrf.js.map
@@ -2,4 +2,3 @@
2
2
  // Shared between the Hono server and the React client. Type-only (no runtime code).
3
3
  // Timestamps are Unix epoch milliseconds. Import everywhere with `import type`.
4
4
  export {};
5
- //# sourceMappingURL=types.js.map