@isomoes/iread 0.1.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/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/server/cli.js +84 -0
- package/dist/server/cli.js.map +1 -0
- package/dist/server/db.js +158 -0
- package/dist/server/db.js.map +1 -0
- package/dist/server/feed-service.js +523 -0
- package/dist/server/feed-service.js.map +1 -0
- package/dist/server/fetch-feed.js +83 -0
- package/dist/server/fetch-feed.js.map +1 -0
- package/dist/server/index.js +62 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/opml.js +137 -0
- package/dist/server/opml.js.map +1 -0
- package/dist/server/routes/feeds.js +68 -0
- package/dist/server/routes/feeds.js.map +1 -0
- package/dist/server/routes/helpers.js +44 -0
- package/dist/server/routes/helpers.js.map +1 -0
- package/dist/server/routes/items.js +95 -0
- package/dist/server/routes/items.js.map +1 -0
- package/dist/server/routes/opml.js +50 -0
- package/dist/server/routes/opml.js.map +1 -0
- package/dist/server/sanitize.js +75 -0
- package/dist/server/sanitize.js.map +1 -0
- package/dist/server/ssrf.js +275 -0
- package/dist/server/ssrf.js.map +1 -0
- package/dist/shared/types.js +5 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/web/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/web/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/web/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/web/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- package/dist/web/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- package/dist/web/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- package/dist/web/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- package/dist/web/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- package/dist/web/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- package/dist/web/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/web/assets/index-BI1j2sXf.css +2 -0
- package/dist/web/assets/index-HhCr0pHx.js +17 -0
- package/dist/web/assets/index-HhCr0pHx.js.map +1 -0
- package/dist/web/index.html +25 -0
- package/package.json +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 isomoes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# iread
|
|
2
|
+
|
|
3
|
+
A concise, local, single-user RSS/Atom reader inspired by newsboat. Keyboard-first three-pane UI, TypeScript end to end. You add feeds by URL, refresh them on demand, and read server-sanitized article bodies in a calm, terminal-quiet reading surface with light, dark, and system themes.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx @isomoes/iread
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then open http://localhost:8787. Requires Node.js 24 or newer (the server uses the built-in `node:sqlite` module). Data is stored in `~/.config/iread/iread.db` (`$XDG_CONFIG_HOME` is honored).
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Usage: iread [options]
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
-p, --port <port> Port to listen on (default: $PORT or 8787)
|
|
20
|
+
--db <path> SQLite database file
|
|
21
|
+
(default: $DB_PATH or ~/.config/iread/iread.db)
|
|
22
|
+
-v, --version Print the version and exit
|
|
23
|
+
-h, --help Show this help and exit
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- Add a feed by URL (title resolved from feed metadata), delete a feed.
|
|
29
|
+
- Refresh one feed (`r`) or refresh all feeds (`R`).
|
|
30
|
+
- Sidebar feed list with per-feed unread counts plus global smart-view totals.
|
|
31
|
+
- Smart views: All, Unread, Starred, applied across the article list, with per-feed selection.
|
|
32
|
+
- Article list with read/unread indicator, source feed, relative time, and star indicator.
|
|
33
|
+
- Reader pane with title, author, date, server-sanitized HTML body, and open-original link.
|
|
34
|
+
- Mark read/unread, auto-mark-read on open (and, on desktop, when you move on from a viewed item), mark-all-read for the current feed or view.
|
|
35
|
+
- Star and unstar.
|
|
36
|
+
- Live case-insensitive search over title plus summary in the current view.
|
|
37
|
+
- Full keyboard navigation as the primary interaction model.
|
|
38
|
+
- OPML import (bulk add) and OPML export.
|
|
39
|
+
- Light, dark, and system theme, persisted to localStorage.
|
|
40
|
+
|
|
41
|
+
## Requirements (development)
|
|
42
|
+
|
|
43
|
+
- Node.js 24 or newer (the server uses the built-in `node:sqlite` module).
|
|
44
|
+
- pnpm.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
pnpm install
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Develop
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
pnpm dev
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This runs two processes with `concurrently`:
|
|
59
|
+
|
|
60
|
+
- The API server on port 8787 (`tsx watch src/server/index.ts`).
|
|
61
|
+
- The Vite dev server on port 5173 with HMR.
|
|
62
|
+
|
|
63
|
+
Vite proxies `/api` to `http://localhost:8787`, so the browser only ever talks to 5173 and there are no CORS concerns.
|
|
64
|
+
|
|
65
|
+
In development (`NODE_ENV` is not `production`) the database defaults to `data/iread.db` inside the repo (gitignored), so dev experiments never touch your real `~/.config/iread` data.
|
|
66
|
+
|
|
67
|
+
Open http://localhost:5173
|
|
68
|
+
|
|
69
|
+
## Build and start (production)
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
pnpm build
|
|
73
|
+
pnpm start
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- `pnpm build` compiles the server to `dist/server/` (with shared types in `dist/shared/`) and bundles the web app to `dist/web/`.
|
|
77
|
+
- `pnpm start` runs a single Hono process that serves the API and the static web bundle with SPA fallback.
|
|
78
|
+
|
|
79
|
+
Open http://localhost:8787
|
|
80
|
+
|
|
81
|
+
`PORT` (default 8787) and `DB_PATH` (default `~/.config/iread/iread.db`) are read from the environment. See `config/.env.example`.
|
|
82
|
+
|
|
83
|
+
## Publish to npm
|
|
84
|
+
|
|
85
|
+
`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.
|
|
86
|
+
|
|
87
|
+
## Usage
|
|
88
|
+
|
|
89
|
+
1. Start the app (dev or production) and open it in your browser.
|
|
90
|
+
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.
|
|
91
|
+
3. Select a feed or a smart view (All, Unread, Starred) in the sidebar.
|
|
92
|
+
4. Navigate the article list with `j` and `k`, open an article with `Enter`, and read it in the right pane.
|
|
93
|
+
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.
|
|
94
|
+
6. Star with `s`, toggle read with `m`, mark a whole scope read with `A`, and search with `/`.
|
|
95
|
+
7. Press `?` at any time to see the full keyboard map. Press `t` to cycle the theme.
|
|
96
|
+
|
|
97
|
+
## Keyboard shortcuts
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
| Key | Action | Effect |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `j` / Down | Next article | Move selection down one row. Stops at the last item (no wrap). |
|
|
104
|
+
| `k` / Up | Previous article | Move selection up one row. Stops at the first item. |
|
|
105
|
+
| `n` | Next unread | Jump to the next unread item below; wrap to the first unread from the top if none below. |
|
|
106
|
+
| `g` | Top | Select the first item and scroll to the top. |
|
|
107
|
+
| `G` | Bottom | Select the last item and scroll to the bottom. |
|
|
108
|
+
| `Enter` / `o` | Open / focus reader | Render the selected item, mark it read, and move focus into the reader. |
|
|
109
|
+
| `J` / `]` | Next feed/view | Move sidebar selection down and load its items, selecting the first one. |
|
|
110
|
+
| `K` / `[` | Previous feed/view | Move sidebar selection up and load its items. |
|
|
111
|
+
| `m` | Toggle read/unread | Flip the read state of the selected item; counts update. |
|
|
112
|
+
| `s` | Toggle star | Flip the starred state; in the Starred view an unstarred item leaves the list. |
|
|
113
|
+
| `A` | Mark feed/view read | Mark the current scope read and offer an Undo toast. |
|
|
114
|
+
| `r` | Refresh current feed | Refresh the selected feed, or the feed of the selected article in a smart view. |
|
|
115
|
+
| `R` | Refresh all feeds | Refresh every feed and update counts on completion. |
|
|
116
|
+
| `v` | Open original | Open the article link in a new tab. |
|
|
117
|
+
| `/` | Focus search | Focus and select the search input. |
|
|
118
|
+
| `Esc` | Contextual dismiss | Close help, clear search, or return focus from the reader to the list. |
|
|
119
|
+
| `?` | Help overlay | Toggle the keybinding overlay. |
|
|
120
|
+
| `t` | Toggle theme | Cycle light, dark, and system theme; persisted. |
|
|
121
|
+
|
|
122
|
+
## Project layout
|
|
123
|
+
|
|
124
|
+
- `src/shared/` shared, type-only DTOs used by both server and web.
|
|
125
|
+
- `src/server/` Hono API, SQLite access, feed fetch/parse/sanitize, OPML, SSRF guard.
|
|
126
|
+
- `src/web/` React app: three-pane layout, hooks, components, styles.
|
|
127
|
+
- `data/` development SQLite database (gitignored); production data lives in `~/.config/iread/`.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// src/server/cli.ts
|
|
3
|
+
// npm bin entry point (`npx iread`). Parses flags into the PORT/DB_PATH env
|
|
4
|
+
// vars the server reads, defaults NODE_ENV to production so index.ts serves
|
|
5
|
+
// the bundled web app, then imports ./index.js which opens the DB and starts
|
|
6
|
+
// listening. Plain `pnpm start`/`node dist/server/index.js` still works.
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
// node:sqlite emits an ExperimentalWarning. The package.json scripts pass
|
|
9
|
+
// --disable-warning=ExperimentalWarning, but a bin shebang cannot carry node
|
|
10
|
+
// flags portably, so filter that warning here before ./db.js loads the module.
|
|
11
|
+
const emitWarning = process.emitWarning.bind(process);
|
|
12
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
13
|
+
const second = rest[0];
|
|
14
|
+
const type = typeof second === 'string'
|
|
15
|
+
? second
|
|
16
|
+
: second !== null && typeof second === 'object' && 'type' in second
|
|
17
|
+
? String(second.type)
|
|
18
|
+
: warning instanceof Error
|
|
19
|
+
? warning.name
|
|
20
|
+
: undefined;
|
|
21
|
+
if (type === 'ExperimentalWarning')
|
|
22
|
+
return;
|
|
23
|
+
emitWarning(warning, ...rest);
|
|
24
|
+
});
|
|
25
|
+
const HELP = `iread — local, single-user RSS/Atom reader
|
|
26
|
+
|
|
27
|
+
Usage: iread [options]
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
-p, --port <port> Port to listen on (default: $PORT or 8787)
|
|
31
|
+
--db <path> SQLite database file
|
|
32
|
+
(default: $DB_PATH or ~/.config/iread/iread.db)
|
|
33
|
+
-v, --version Print the version and exit
|
|
34
|
+
-h, --help Show this help and exit
|
|
35
|
+
`;
|
|
36
|
+
function fail(message) {
|
|
37
|
+
console.error(`iread: ${message}\n\n${HELP}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
async function printVersion() {
|
|
41
|
+
// Emitted file is dist/server/cli.js; package.json sits two levels up at the
|
|
42
|
+
// package root (same relative position when run from src/ via tsx).
|
|
43
|
+
const raw = await readFile(new URL('../../package.json', import.meta.url), 'utf-8');
|
|
44
|
+
const pkg = JSON.parse(raw);
|
|
45
|
+
console.log(pkg.version);
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
const args = process.argv.slice(2);
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
const arg = args[i];
|
|
51
|
+
switch (arg) {
|
|
52
|
+
case '-h':
|
|
53
|
+
case '--help':
|
|
54
|
+
console.log(HELP);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
break;
|
|
57
|
+
case '-v':
|
|
58
|
+
case '--version':
|
|
59
|
+
await printVersion();
|
|
60
|
+
break;
|
|
61
|
+
case '-p':
|
|
62
|
+
case '--port': {
|
|
63
|
+
const value = args[++i];
|
|
64
|
+
if (!value || !Number.isInteger(Number(value)) || Number(value) < 0 || Number(value) > 65535) {
|
|
65
|
+
fail(`${arg} requires a port number (0-65535)`);
|
|
66
|
+
}
|
|
67
|
+
process.env.PORT = value;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case '--db': {
|
|
71
|
+
const value = args[++i];
|
|
72
|
+
if (!value)
|
|
73
|
+
fail('--db requires a file path');
|
|
74
|
+
process.env.DB_PATH = value;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
fail(`unknown option: ${arg}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Serve the prebuilt web bundle unless the caller explicitly set NODE_ENV.
|
|
82
|
+
process.env.NODE_ENV ??= 'production';
|
|
83
|
+
await import('./index.js');
|
|
84
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../../src/server/cli.ts"],"names":[],"mappings":";AACA,oBAAoB;AACpB,4EAA4E;AAC5E,4EAA4E;AAC5E,6EAA6E;AAC7E,yEAAyE;AAEzE,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,0EAA0E;AAC1E,6EAA6E;AAC7E,+EAA+E;AAC/E,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,CAAiC,CAAC;AACtF,OAAO,CAAC,WAAW,GAAG,CAAC,CAAC,OAAgB,EAAE,GAAG,IAAe,EAAE,EAAE;IAC9D,MAAM,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;IACvB,MAAM,IAAI,GACR,OAAO,MAAM,KAAK,QAAQ;QACxB,CAAC,CAAC,MAAM;QACR,CAAC,CAAC,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,IAAI,MAAM;YACjE,CAAC,CAAC,MAAM,CAAE,MAA6B,CAAC,IAAI,CAAC;YAC7C,CAAC,CAAC,OAAO,YAAY,KAAK;gBACxB,CAAC,CAAC,OAAO,CAAC,IAAI;gBACd,CAAC,CAAC,SAAS,CAAC;IACpB,IAAI,IAAI,KAAK,qBAAqB;QAAE,OAAO;IAC3C,WAAW,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,CAAC;AAChC,CAAC,CAA+B,CAAC;AAEjC,MAAM,IAAI,GAAG;;;;;;;;;;CAUZ,CAAC;AAEF,SAAS,IAAI,CAAC,OAAe;IAC3B,OAAO,CAAC,KAAK,CAAC,UAAU,OAAO,OAAO,IAAI,EAAE,CAAC,CAAC;IAC9C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,KAAK,UAAU,YAAY;IACzB,6EAA6E;IAC7E,oEAAoE;IACpE,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,GAAG,CAAC,oBAAoB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC,CAAC;IACpF,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAwB,CAAC;IACnD,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACzB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AACnC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;IACrC,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;IACpB,QAAQ,GAAG,EAAE,CAAC;QACZ,KAAK,IAAI,CAAC;QACV,KAAK,QAAQ;YACX,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YAClB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAChB,MAAM;QACR,KAAK,IAAI,CAAC;QACV,KAAK,WAAW;YACd,MAAM,YAAY,EAAE,CAAC;YACrB,MAAM;QACR,KAAK,IAAI,CAAC;QACV,KAAK,QAAQ,CAAC,CAAC,CAAC;YACd,MAAM,KAAK,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;YACxB,IAAI,CAAC,KAAK,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,GAAG,KAAK,EAAE,CAAC;gBAC7F,IAAI,CAAC,GAAG,GAAG,mCAAmC,CAAC,CAAC;YAClD,CAAC;YACD,OAAO,CAAC,GAAG,CAAC,IAAI,GAAG,KAAK,CAAC;YACzB,MAAM;QACR,CAAC;QACD,KAAK,MAAM,CAAC,CAAC,CAAC;YACZ,MAAM,KAAK,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;YACxB,IAAI,CAAC,KAAK;gBAAE,IAAI,CAAC,2BAA2B,CAAC,CAAC;YAC9C,OAAO,CAAC,GAAG,CAAC,OAAO,GAAG,KAAK,CAAC;YAC5B,MAAM;QACR,CAAC;QACD;YACE,IAAI,CAAC,mBAAmB,GAAG,EAAE,CAAC,CAAC;IACnC,CAAC;AACH,CAAC;AAED,2EAA2E;AAC3E,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,CAAC;AAEtC,MAAM,MAAM,CAAC,YAAY,CAAC,CAAC"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// src/server/db.ts
|
|
2
|
+
// Opens the node:sqlite DatabaseSync at DB_PATH, applies PRAGMAs, runs the v1
|
|
3
|
+
// schema bootstrap behind a schema_meta version gate (all inside a transaction),
|
|
4
|
+
// and exports the db handle plus typed row-access helpers.
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { mkdirSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
// Default DB location: $DB_PATH if set (relative paths resolve against cwd).
|
|
10
|
+
// Otherwise it depends on the mode: in development (NODE_ENV !== 'production')
|
|
11
|
+
// the repo-local data/iread.db, so dev experiments never touch real data; in
|
|
12
|
+
// production (`pnpm start`, the npx CLI) ~/.config/iread/iread.db, honoring
|
|
13
|
+
// $XDG_CONFIG_HOME.
|
|
14
|
+
function defaultDbPath() {
|
|
15
|
+
if (process.env.NODE_ENV !== 'production')
|
|
16
|
+
return 'data/iread.db';
|
|
17
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
18
|
+
const configHome = xdg && xdg.trim() !== '' ? xdg : join(homedir(), '.config');
|
|
19
|
+
return join(configHome, 'iread', 'iread.db');
|
|
20
|
+
}
|
|
21
|
+
const DB_PATH = process.env.DB_PATH ?? defaultDbPath();
|
|
22
|
+
// Resolve to an absolute path and ensure the containing directory exists
|
|
23
|
+
// (DatabaseSync will not create intermediate directories on its own).
|
|
24
|
+
const dbPath = resolve(process.cwd(), DB_PATH);
|
|
25
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
26
|
+
export const db = new DatabaseSync(dbPath);
|
|
27
|
+
// Connection PRAGMAs. WAL keeps readers from erroring during writes;
|
|
28
|
+
// foreign_keys ON enforces ON DELETE CASCADE; busy_timeout avoids spurious
|
|
29
|
+
// SQLITE_BUSY under concurrent access.
|
|
30
|
+
db.exec('PRAGMA journal_mode = WAL;');
|
|
31
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
32
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Schema (v1)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
const SCHEMA_V1 = `
|
|
37
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
38
|
+
key TEXT PRIMARY KEY,
|
|
39
|
+
value TEXT NOT NULL
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS feeds (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
feed_url TEXT NOT NULL UNIQUE,
|
|
45
|
+
site_url TEXT,
|
|
46
|
+
title TEXT NOT NULL DEFAULT '',
|
|
47
|
+
description TEXT,
|
|
48
|
+
etag TEXT,
|
|
49
|
+
last_modified TEXT,
|
|
50
|
+
last_fetched_at INTEGER,
|
|
51
|
+
fetch_error TEXT,
|
|
52
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS items (
|
|
56
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
57
|
+
feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE,
|
|
58
|
+
guid TEXT,
|
|
59
|
+
link TEXT,
|
|
60
|
+
title TEXT NOT NULL DEFAULT '(untitled)',
|
|
61
|
+
author TEXT,
|
|
62
|
+
content_html TEXT NOT NULL DEFAULT '',
|
|
63
|
+
summary TEXT,
|
|
64
|
+
published_at INTEGER NOT NULL,
|
|
65
|
+
fetched_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000),
|
|
66
|
+
is_read INTEGER NOT NULL DEFAULT 0 CHECK (is_read IN (0,1)),
|
|
67
|
+
is_starred INTEGER NOT NULL DEFAULT 0 CHECK (is_starred IN (0,1)),
|
|
68
|
+
dedup_key TEXT NOT NULL
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_feed_dedup ON items(feed_id, dedup_key);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_items_feed_pub ON items(feed_id, published_at DESC, id DESC);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_items_pub ON items(published_at DESC, id DESC);
|
|
74
|
+
`;
|
|
75
|
+
const MIGRATIONS = [
|
|
76
|
+
{
|
|
77
|
+
version: 1,
|
|
78
|
+
up(database) {
|
|
79
|
+
database.exec(SCHEMA_V1);
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
const TARGET_VERSION = MIGRATIONS.reduce((max, m) => Math.max(max, m.version), 0);
|
|
84
|
+
function readSchemaVersion() {
|
|
85
|
+
// schema_meta may not exist yet on a fresh database.
|
|
86
|
+
try {
|
|
87
|
+
const row = db
|
|
88
|
+
.prepare(`SELECT value FROM schema_meta WHERE key = 'schema_version'`)
|
|
89
|
+
.get();
|
|
90
|
+
if (!row)
|
|
91
|
+
return 0;
|
|
92
|
+
const n = Number.parseInt(row.value, 10);
|
|
93
|
+
return Number.isFinite(n) ? n : 0;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function setSchemaVersion(version) {
|
|
100
|
+
db.prepare(`INSERT INTO schema_meta(key, value) VALUES ('schema_version', ?)
|
|
101
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(String(version));
|
|
102
|
+
}
|
|
103
|
+
function migrate() {
|
|
104
|
+
const current = readSchemaVersion();
|
|
105
|
+
if (current >= TARGET_VERSION)
|
|
106
|
+
return;
|
|
107
|
+
db.exec('BEGIN');
|
|
108
|
+
try {
|
|
109
|
+
for (const migration of MIGRATIONS) {
|
|
110
|
+
if (migration.version > current) {
|
|
111
|
+
migration.up(db);
|
|
112
|
+
setSchemaVersion(migration.version);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
db.exec('COMMIT');
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
db.exec('ROLLBACK');
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
migrate();
|
|
123
|
+
/** Run a SELECT and return all rows typed as T (node:sqlite returns plain objects). */
|
|
124
|
+
export function queryAll(sql, ...params) {
|
|
125
|
+
return db.prepare(sql).all(...params);
|
|
126
|
+
}
|
|
127
|
+
/** Run a SELECT and return the first row typed as T, or undefined. */
|
|
128
|
+
export function queryGet(sql, ...params) {
|
|
129
|
+
return db.prepare(sql).get(...params);
|
|
130
|
+
}
|
|
131
|
+
/** Run a write statement; returns rows changed and the last inserted rowid (as number). */
|
|
132
|
+
export function run(sql, ...params) {
|
|
133
|
+
const res = db.prepare(sql).run(...params);
|
|
134
|
+
return { changes: Number(res.changes), lastInsertRowid: Number(res.lastInsertRowid) };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Run `fn` inside a single transaction. Commits on success, rolls back if `fn`
|
|
138
|
+
* throws (rethrowing the original error). node:sqlite is synchronous so this is
|
|
139
|
+
* a plain try/catch around BEGIN/COMMIT/ROLLBACK.
|
|
140
|
+
*/
|
|
141
|
+
export function transaction(fn) {
|
|
142
|
+
db.exec('BEGIN');
|
|
143
|
+
try {
|
|
144
|
+
const result = fn();
|
|
145
|
+
db.exec('COMMIT');
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
db.exec('ROLLBACK');
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Total number of row changes since the connection opened (for newItems delta). */
|
|
154
|
+
export function totalChanges() {
|
|
155
|
+
const row = db.prepare('SELECT total_changes() AS n').get();
|
|
156
|
+
return row.n;
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=db.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"db.js","sourceRoot":"","sources":["../../src/server/db.ts"],"names":[],"mappings":"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"}
|