@isomoes/iread 0.2.1 → 0.2.3
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 +69 -49
- package/dist/server/cli.js +0 -1
- package/dist/server/db.js +0 -1
- package/dist/server/feed-service.js +88 -33
- package/dist/server/fetch-feed.js +0 -1
- package/dist/server/index.js +0 -1
- package/dist/server/opml-sync.js +14 -2
- package/dist/server/opml.js +0 -1
- package/dist/server/routes/feeds.js +0 -1
- package/dist/server/routes/helpers.js +0 -1
- package/dist/server/routes/items.js +0 -1
- package/dist/server/routes/opml.js +0 -1
- package/dist/server/sanitize.js +0 -1
- package/dist/server/ssrf.js +0 -1
- package/dist/shared/types.js +0 -1
- package/dist/web/assets/{index-COIZwpno.js → index-DFve9iOj.js} +1 -2
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/server/cli.js.map +0 -1
- package/dist/server/db.js.map +0 -1
- package/dist/server/feed-service.js.map +0 -1
- package/dist/server/fetch-feed.js.map +0 -1
- package/dist/server/index.js.map +0 -1
- package/dist/server/opml-sync.js.map +0 -1
- package/dist/server/opml.js.map +0 -1
- package/dist/server/routes/feeds.js.map +0 -1
- package/dist/server/routes/helpers.js.map +0 -1
- package/dist/server/routes/items.js.map +0 -1
- package/dist/server/routes/opml.js.map +0 -1
- package/dist/server/sanitize.js.map +0 -1
- package/dist/server/ssrf.js.map +0 -1
- package/dist/shared/types.js.map +0 -1
- package/dist/web/assets/index-COIZwpno.js.map +0 -1
package/README.md
CHANGED
|
@@ -4,6 +4,10 @@ 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
|
+

|
|
8
|
+
|
|
9
|
+
📺 Watch the [v0.2.2 intro video](https://www.bilibili.com/video/BV1cmJg6SEso/) on Bilibili.
|
|
10
|
+
|
|
7
11
|
## Quick start
|
|
8
12
|
|
|
9
13
|
```sh
|
|
@@ -26,6 +30,26 @@ Options:
|
|
|
26
30
|
-h, --help Show this help and exit
|
|
27
31
|
```
|
|
28
32
|
|
|
33
|
+
## Run with Docker
|
|
34
|
+
|
|
35
|
+
Prebuilt multi-arch images (amd64 + arm64) are published to the GitHub Container Registry, so no Node.js on the host is required:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
docker run -d --name iread -p 8787:8787 -v iread-data:/data ghcr.io/isomoes/iread:latest
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then open http://localhost:8787. The SQLite database and the auto-saved `feeds.opml` mirror live under `/data` in the container — here the named `iread-data` volume — so your subscriptions survive container upgrades.
|
|
42
|
+
|
|
43
|
+
A `docker-compose.yml` is included for the same thing:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
docker compose up -d
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Build the image from the checkout instead of pulling it with `docker compose up -d --build`, or `docker build -t iread .`.
|
|
50
|
+
|
|
51
|
+
`PORT` (default 8787), `DB_PATH` (default `/data/iread.db`), and `OPML_PATH` (default `/data/feeds.opml`) are configurable via `-e`/`environment:`. To store data in a host directory instead of a named volume, bind-mount it and make it writable by the image's unprivileged `node` user (uid 1000), e.g. `mkdir iread-data && sudo chown 1000:1000 iread-data` then `-v "$PWD/iread-data:/data"`.
|
|
52
|
+
|
|
29
53
|
## Features
|
|
30
54
|
|
|
31
55
|
- Add a feed by URL (title resolved from feed metadata), delete a feed.
|
|
@@ -41,18 +65,61 @@ Options:
|
|
|
41
65
|
- 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.
|
|
42
66
|
- Light, dark, and system theme, persisted to localStorage.
|
|
43
67
|
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
1. Start the app (dev or production) and open it in your browser.
|
|
71
|
+
2. Add a feed by pasting its RSS or Atom URL into the add-feed form in the sidebar, or import an OPML file from the OPML menu. A `config/sample-feeds.opml` file is included for a quick start, and [`isomoes/arch-config/iread/feeds.opml`](https://github.com/isomoes/arch-config/blob/master/iread/feeds.opml) is a real-world example (the feed set isomoes actually reads) you can import directly.
|
|
72
|
+
3. Select a feed or a smart view (All, Unread, Starred) in the sidebar.
|
|
73
|
+
4. Navigate the article list with `j` and `k`, open an article with `Enter`, and read it in the right pane.
|
|
74
|
+
5. Press `r` to refresh the current feed or `R` to refresh all feeds. Refresh fetches remote feeds; client polling only refreshes local data and never re-fetches remotely.
|
|
75
|
+
6. Star with `s`, toggle read with `m`, mark a whole scope read with `A`, and search with `/`.
|
|
76
|
+
7. Press `?` at any time to see the full keyboard map. Press `t` to cycle the theme.
|
|
77
|
+
|
|
78
|
+
## Keyboard shortcuts
|
|
79
|
+
|
|
80
|
+
Keys are case-sensitive (Shift matters). Bindings fire only when you are not typing in an input and no Ctrl, Meta, or Alt modifier is held.
|
|
81
|
+
|
|
82
|
+
| Key | Action | Effect |
|
|
83
|
+
| ------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
84
|
+
| `j` / Down | Down in focused pane | Pane-contextual: move the article selection (list), the feed/view selection (sidebar), or the reader scroll down one. Stops at the end (no wrap). |
|
|
85
|
+
| `k` / Up | Up in focused pane | As `j`, upward. Stops at the start. |
|
|
86
|
+
| `n` | Next unread | Jump to the next unread item below; wrap to the first unread from the top if none below. |
|
|
87
|
+
| `g` | Top | Pane-contextual: in the list, select the first item; in the sidebar, jump to the first feed/view; in the reader, scroll the article to the top. |
|
|
88
|
+
| `G` | Bottom | Pane-contextual: in the list, select the last item; in the sidebar, jump to the last feed/view; in the reader, scroll the article to the bottom. |
|
|
89
|
+
| `Enter` / `o` | Open / focus reader | Render the selected item, mark it read, and move focus into the reader. |
|
|
90
|
+
| `J` / `]` | Next feed/view | Move sidebar selection down and load its items, selecting the first one. |
|
|
91
|
+
| `K` / `[` | Previous feed/view | Move sidebar selection up and load its items. |
|
|
92
|
+
| `m` | Toggle read/unread | Flip the read state of the selected item; counts update. |
|
|
93
|
+
| `s` | Toggle star | Flip the starred state; in the Starred view an unstarred item leaves the list. |
|
|
94
|
+
| `A` | Mark feed/view read | Mark the current scope read and offer an Undo toast. |
|
|
95
|
+
| `r` | Refresh current feed | Refresh the selected feed, or the feed of the selected article in a smart view. |
|
|
96
|
+
| `R` | Refresh all feeds | Refresh every feed and update counts on completion. |
|
|
97
|
+
| `v` | Open original | Open the article link in a new tab. |
|
|
98
|
+
| `#` 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. |
|
|
99
|
+
| `/` | Focus search | Focus and select the search input. |
|
|
100
|
+
| `Esc` | Contextual dismiss | Close help, clear search, or return focus from the reader to the list. |
|
|
101
|
+
| `?` | Help overlay | Toggle the keybinding overlay. |
|
|
102
|
+
| `t` | Toggle theme | Cycle light, dark, and system theme; persisted. |
|
|
103
|
+
|
|
104
|
+
## Project layout
|
|
105
|
+
|
|
106
|
+
- `src/shared/` shared, type-only DTOs used by both server and web.
|
|
107
|
+
- `src/server/` Hono API, SQLite access, feed fetch/parse/sanitize, OPML, SSRF guard.
|
|
108
|
+
- `src/web/` React app: three-pane layout, hooks, components, styles.
|
|
109
|
+
- `data/` development SQLite database (gitignored); production data lives in `~/.config/iread/`.
|
|
110
|
+
|
|
44
111
|
## Requirements (development)
|
|
45
112
|
|
|
46
113
|
- Node.js 24 or newer (the server uses the built-in `node:sqlite` module).
|
|
47
114
|
- pnpm.
|
|
48
115
|
|
|
49
|
-
|
|
116
|
+
### Install
|
|
50
117
|
|
|
51
118
|
```sh
|
|
52
119
|
pnpm install
|
|
53
120
|
```
|
|
54
121
|
|
|
55
|
-
|
|
122
|
+
### Develop
|
|
56
123
|
|
|
57
124
|
```sh
|
|
58
125
|
pnpm dev
|
|
@@ -82,50 +149,3 @@ pnpm start
|
|
|
82
149
|
Open http://localhost:8787
|
|
83
150
|
|
|
84
151
|
`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`.
|
|
85
|
-
|
|
86
|
-
## Publish to npm
|
|
87
|
-
|
|
88
|
-
`pnpm publish` runs the full build via the `prepack` hook and ships only `dist/` (server, shared types, and the prebuilt web bundle); the `iread` bin points at `dist/server/cli.js`. The web framework dependencies are devDependencies, so `npx @isomoes/iread` installs only the small server runtime set.
|
|
89
|
-
|
|
90
|
-
## Usage
|
|
91
|
-
|
|
92
|
-
1. Start the app (dev or production) and open it in your browser.
|
|
93
|
-
2. Add a feed by pasting its RSS or Atom URL into the add-feed form in the sidebar, or import an OPML file from the OPML menu. A `config/sample-feeds.opml` file is included for a quick start.
|
|
94
|
-
3. Select a feed or a smart view (All, Unread, Starred) in the sidebar.
|
|
95
|
-
4. Navigate the article list with `j` and `k`, open an article with `Enter`, and read it in the right pane.
|
|
96
|
-
5. Press `r` to refresh the current feed or `R` to refresh all feeds. Refresh fetches remote feeds; client polling only refreshes local data and never re-fetches remotely.
|
|
97
|
-
6. Star with `s`, toggle read with `m`, mark a whole scope read with `A`, and search with `/`.
|
|
98
|
-
7. Press `?` at any time to see the full keyboard map. Press `t` to cycle the theme.
|
|
99
|
-
|
|
100
|
-
## Keyboard shortcuts
|
|
101
|
-
|
|
102
|
-
Keys are case-sensitive (Shift matters). Bindings fire only when you are not typing in an input and no Ctrl, Meta, or Alt modifier is held.
|
|
103
|
-
|
|
104
|
-
| Key | Action | Effect |
|
|
105
|
-
|---|---|---|
|
|
106
|
-
| `j` / Down | Next article | Move selection down one row. Stops at the last item (no wrap). |
|
|
107
|
-
| `k` / Up | Previous article | Move selection up one row. Stops at the first item. |
|
|
108
|
-
| `n` | Next unread | Jump to the next unread item below; wrap to the first unread from the top if none below. |
|
|
109
|
-
| `g` | Top | Select the first item and scroll to the top. |
|
|
110
|
-
| `G` | Bottom | Select the last item and scroll to the bottom. |
|
|
111
|
-
| `Enter` / `o` | Open / focus reader | Render the selected item, mark it read, and move focus into the reader. |
|
|
112
|
-
| `J` / `]` | Next feed/view | Move sidebar selection down and load its items, selecting the first one. |
|
|
113
|
-
| `K` / `[` | Previous feed/view | Move sidebar selection up and load its items. |
|
|
114
|
-
| `m` | Toggle read/unread | Flip the read state of the selected item; counts update. |
|
|
115
|
-
| `s` | Toggle star | Flip the starred state; in the Starred view an unstarred item leaves the list. |
|
|
116
|
-
| `A` | Mark feed/view read | Mark the current scope read and offer an Undo toast. |
|
|
117
|
-
| `r` | Refresh current feed | Refresh the selected feed, or the feed of the selected article in a smart view. |
|
|
118
|
-
| `R` | Refresh all feeds | Refresh every feed and update counts on completion. |
|
|
119
|
-
| `v` | Open original | Open the article link in a new tab. |
|
|
120
|
-
| `#` 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. |
|
|
121
|
-
| `/` | Focus search | Focus and select the search input. |
|
|
122
|
-
| `Esc` | Contextual dismiss | Close help, clear search, or return focus from the reader to the list. |
|
|
123
|
-
| `?` | Help overlay | Toggle the keybinding overlay. |
|
|
124
|
-
| `t` | Toggle theme | Cycle light, dark, and system theme; persisted. |
|
|
125
|
-
|
|
126
|
-
## Project layout
|
|
127
|
-
|
|
128
|
-
- `src/shared/` shared, type-only DTOs used by both server and web.
|
|
129
|
-
- `src/server/` Hono API, SQLite access, feed fetch/parse/sanitize, OPML, SSRF guard.
|
|
130
|
-
- `src/web/` React app: three-pane layout, hooks, components, styles.
|
|
131
|
-
- `data/` development SQLite database (gitignored); production data lives in `~/.config/iread/`.
|
package/dist/server/cli.js
CHANGED
package/dist/server/db.js
CHANGED
|
@@ -212,7 +212,12 @@ const UPDATE_FEED_ON_SUCCESS = `
|
|
|
212
212
|
last_fetched_at = ?, fetch_error = NULL
|
|
213
213
|
WHERE id = ?
|
|
214
214
|
`;
|
|
215
|
-
async
|
|
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) {
|
|
216
221
|
const now = Date.now();
|
|
217
222
|
try {
|
|
218
223
|
const result = await fetchFeed({
|
|
@@ -220,30 +225,56 @@ async function refreshFeedRow(row) {
|
|
|
220
225
|
etag: row.etag,
|
|
221
226
|
lastModified: row.last_modified,
|
|
222
227
|
});
|
|
223
|
-
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') {
|
|
224
257
|
// 304: nothing changed. Update last_fetched_at and clear any prior error.
|
|
225
|
-
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);
|
|
226
259
|
return { feed: getFeedWithCounts(row.id), newItems: 0, ok: true };
|
|
227
260
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return inserted;
|
|
238
|
-
});
|
|
239
|
-
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);
|
|
240
270
|
}
|
|
241
271
|
catch (err) {
|
|
242
|
-
|
|
243
|
-
db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = ? WHERE id = ?`).run(now, message, row.id);
|
|
244
|
-
return { feed: getFeedWithCounts(row.id), newItems: 0, ok: false };
|
|
272
|
+
return recordFeedError(row, plan.now, err instanceof Error ? err.message : String(err));
|
|
245
273
|
}
|
|
246
274
|
}
|
|
275
|
+
async function refreshFeedRow(row) {
|
|
276
|
+
return applyRefresh(row, await planRefresh(row));
|
|
277
|
+
}
|
|
247
278
|
function getFeedRow(id) {
|
|
248
279
|
return queryGet(`SELECT * FROM feeds WHERE id = ?`, id);
|
|
249
280
|
}
|
|
@@ -257,29 +288,54 @@ export async function refreshFeed(id) {
|
|
|
257
288
|
syncOpmlMirror();
|
|
258
289
|
return { feed: outcome.feed, newItems: outcome.newItems };
|
|
259
290
|
}
|
|
260
|
-
|
|
261
|
-
|
|
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. */
|
|
262
315
|
export async function refreshAll() {
|
|
263
316
|
const rows = queryAll(`SELECT * FROM feeds ORDER BY id ASC`);
|
|
264
317
|
let refreshed = 0;
|
|
265
318
|
let failed = 0;
|
|
266
319
|
let newItems = 0;
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
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) => {
|
|
270
325
|
try {
|
|
271
|
-
const outcome = await
|
|
272
|
-
|
|
273
|
-
refreshed += 1;
|
|
274
|
-
else
|
|
275
|
-
failed += 1;
|
|
276
|
-
newItems += outcome.newItems;
|
|
326
|
+
const outcome = applyRefresh(row, await planRefresh(row));
|
|
327
|
+
return { ok: outcome.ok, newItems: outcome.newItems };
|
|
277
328
|
}
|
|
278
329
|
catch {
|
|
279
|
-
|
|
280
|
-
// unexpected throw so the loop always completes.
|
|
281
|
-
failed += 1;
|
|
330
|
+
return { ok: false, newItems: 0 };
|
|
282
331
|
}
|
|
332
|
+
});
|
|
333
|
+
for (const outcome of outcomes) {
|
|
334
|
+
if (outcome.ok)
|
|
335
|
+
refreshed += 1;
|
|
336
|
+
else
|
|
337
|
+
failed += 1;
|
|
338
|
+
newItems += outcome.newItems;
|
|
283
339
|
}
|
|
284
340
|
syncOpmlMirror();
|
|
285
341
|
return { feeds: listFeeds().feeds, refreshed, failed, newItems };
|
|
@@ -529,4 +585,3 @@ export function listFeedsForExport() {
|
|
|
529
585
|
export function syncOpmlMirror() {
|
|
530
586
|
writeOpmlSnapshot(listFeedsForExport());
|
|
531
587
|
}
|
|
532
|
-
//# sourceMappingURL=feed-service.js.map
|
package/dist/server/index.js
CHANGED
package/dist/server/opml-sync.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, renameSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
2
|
import { dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { dbPath } from './db.js';
|
|
4
4
|
import { exportOpml } from './opml.js';
|
|
@@ -11,11 +11,24 @@ function resolveMirrorPath() {
|
|
|
11
11
|
return join(dirname(dbPath), 'feeds.opml');
|
|
12
12
|
}
|
|
13
13
|
export const opmlMirrorPath = resolveMirrorPath();
|
|
14
|
+
function withoutDateCreated(xml) {
|
|
15
|
+
return xml.replace(/[ \t]*<dateCreated>.*?<\/dateCreated>\r?\n?/, '');
|
|
16
|
+
}
|
|
14
17
|
export function writeOpmlSnapshot(feeds) {
|
|
15
18
|
if (!opmlMirrorPath)
|
|
16
19
|
return;
|
|
17
20
|
try {
|
|
18
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
|
+
}
|
|
19
32
|
mkdirSync(dirname(opmlMirrorPath), { recursive: true });
|
|
20
33
|
const tmp = `${opmlMirrorPath}.tmp`;
|
|
21
34
|
writeFileSync(tmp, xml, 'utf-8');
|
|
@@ -26,4 +39,3 @@ export function writeOpmlSnapshot(feeds) {
|
|
|
26
39
|
console.warn(`[iread] could not write OPML mirror at ${opmlMirrorPath}: ${message}`);
|
|
27
40
|
}
|
|
28
41
|
}
|
|
29
|
-
//# sourceMappingURL=opml-sync.js.map
|
package/dist/server/opml.js
CHANGED
package/dist/server/sanitize.js
CHANGED
package/dist/server/ssrf.js
CHANGED
package/dist/shared/types.js
CHANGED