komado 0.1.2 → 0.1.4
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 +36 -27
- package/dist/cli.js +5 -5
- package/dist/components/screens/SettingsScreen.js +1 -1
- package/dist/config.js +1 -1
- package/dist/domain/shape.js +1 -1
- package/dist/sources/mangadex/auth.js +1 -1
- package/package.json +1 -1
- package/src/app.js +1 -1
- package/src/cli.js +5 -5
- package/src/components/List.js +1 -1
- package/src/components/screens/LibraryScreen.js +1 -1
- package/src/components/screens/LoginScreen.js +1 -1
- package/src/components/screens/MangaScreen.js +1 -1
- package/src/components/screens/ReaderScreen.js +1 -1
- package/src/components/screens/SettingsScreen.js +1 -1
- package/src/components/ui.js +1 -1
- package/src/config.js +2 -2
- package/src/domain/shape.js +1 -1
- package/src/lib/AppError.js +1 -1
- package/src/lib/cache.js +1 -1
- package/src/lib/fetchWithBackoff.js +1 -1
- package/src/lib/logger.js +1 -1
- package/src/render/detect.js +1 -1
- package/src/render/halfblock.js +2 -2
- package/src/render/image.js +1 -1
- package/src/render/sixel.js +4 -4
- package/src/sixel-reader.js +10 -10
- package/src/sources/local/archive.js +1 -1
- package/src/sources/local/index.js +1 -1
- package/src/sources/mangadex/auth.js +4 -4
- package/src/sources/mangadex/index.js +1 -1
- package/src/state/store.js +2 -2
- package/src/uninstall.js +4 -4
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ with [Ink](https://github.com/vadimdemedes/ink).
|
|
|
28
28
|
- **Reading progress.** Where you left off is saved per manga and resumable from
|
|
29
29
|
*Continue reading*.
|
|
30
30
|
- **Sign in to MangaDex (optional).** Log in to browse your **followed library**, see
|
|
31
|
-
which chapters you've already read, and **sync progress back**
|
|
31
|
+
which chapters you've already read, and **sync progress back** - finishing a chapter
|
|
32
32
|
marks it read on MangaDex. Reading itself needs no account.
|
|
33
33
|
- **Offline-friendly.** Point it at folders of `.cbz`/`.zip` or loose images.
|
|
34
34
|
|
|
@@ -41,7 +41,7 @@ with [Ink](https://github.com/vadimdemedes/ink).
|
|
|
41
41
|
|
|
42
42
|
## Install
|
|
43
43
|
|
|
44
|
-
**macOS / Linux**
|
|
44
|
+
**macOS / Linux** - one line fetches the latest, builds it, and drops a `komado`
|
|
45
45
|
launcher on your PATH:
|
|
46
46
|
|
|
47
47
|
```bash
|
|
@@ -52,24 +52,33 @@ Then just type **`komado`**. Re-run that same command any time to update. It nee
|
|
|
52
52
|
`git`, **Node ≥ 20**, and `npm`; it installs to `~/.local/share/komado` with the
|
|
53
53
|
launcher in `~/.local/bin` (override via `KOMADO_APP_DIR` / `KOMADO_BIN_DIR`).
|
|
54
54
|
|
|
55
|
-
**Windows**
|
|
56
|
-
|
|
55
|
+
**Windows** - one line installs Node.js for you (via `winget`) if it's missing,
|
|
56
|
+
then komado, leaving a native `komado` command on your `PATH`. Works from **both
|
|
57
|
+
CMD and PowerShell**, and ignores the execution policy either way:
|
|
57
58
|
|
|
58
|
-
```
|
|
59
|
-
irm https://raw.githubusercontent.com/RyuPrad/komado/main/install.ps1 | iex
|
|
59
|
+
```
|
|
60
|
+
powershell -NoProfile -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/RyuPrad/komado/main/install.ps1 | iex"
|
|
60
61
|
```
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
Prefer the native syntax? In **PowerShell**:
|
|
63
64
|
|
|
64
65
|
```powershell
|
|
65
|
-
|
|
66
|
+
irm https://raw.githubusercontent.com/RyuPrad/komado/main/install.ps1 | iex
|
|
66
67
|
```
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
Already have **Node ≥ 20**? `npm i -g komado` works from either shell - CMD runs
|
|
70
|
+
npm's `.cmd` shim directly, and PowerShell does too if your execution policy
|
|
71
|
+
allows scripts.
|
|
72
|
+
|
|
73
|
+
> Note: the short `irm … | iex` form is PowerShell-only - in **CMD** it prints
|
|
74
|
+
> `'irm' is not recognized`. Use the longer one-liner above (which works in both),
|
|
75
|
+
> or run `npm i -g komado` directly.
|
|
76
|
+
|
|
77
|
+
Don't use the `curl … | bash` line on Windows - under **Git Bash** or **WSL** it
|
|
69
78
|
installs a launcher that only runs inside that shell, never from CMD/PowerShell.
|
|
70
79
|
|
|
71
|
-
Uninstall from inside the app
|
|
72
|
-
confirm)
|
|
80
|
+
Uninstall from inside the app - **Settings → Uninstall komado…** (type `uninstall` to
|
|
81
|
+
confirm) - removes the app, its launcher, and `~/.komado` (config, reading progress,
|
|
73
82
|
MangaDex login). By hand that's
|
|
74
83
|
`rm -rf ~/.local/share/komado ~/.local/bin/komado ~/.komado`.
|
|
75
84
|
|
|
@@ -87,7 +96,7 @@ terminal supports and where state is stored:
|
|
|
87
96
|
npm run doctor
|
|
88
97
|
```
|
|
89
98
|
|
|
90
|
-
Render a single image (path or URL) at the best fidelity your terminal allows
|
|
99
|
+
Render a single image (path or URL) at the best fidelity your terminal allows - handy
|
|
91
100
|
for testing sixel/kitty terminals:
|
|
92
101
|
|
|
93
102
|
```bash
|
|
@@ -118,7 +127,7 @@ Readable text needs real pixels. At startup the app probes your terminal (run
|
|
|
118
127
|
full-width with vertical pan by default; press `f` to toggle whole-page fit.
|
|
119
128
|
Keys: `←`/`→` or `a`/`d` page · `↑`/`↓` pan · `n`/`p` chapter · `f` fit · `q` back.
|
|
120
129
|
- **neither** → the in-Ink **cell reader** is used (Unicode half-blocks, or
|
|
121
|
-
chafa symbols when available). Fine for art, coarse for small lettering
|
|
130
|
+
chafa symbols when available). Fine for art, coarse for small lettering -
|
|
122
131
|
that's the hard ceiling of character-cell rendering.
|
|
123
132
|
|
|
124
133
|
Terminals with pixel support include kitty, WezTerm, Ghostty, foot, recent
|
|
@@ -128,9 +137,9 @@ Windows Terminal (≥ 1.22), and VS Code's terminal (with image support enabled)
|
|
|
128
137
|
|
|
129
138
|
Reading from MangaDex needs **no account**. Signing in only adds personalization:
|
|
130
139
|
|
|
131
|
-
- **My Library**
|
|
132
|
-
- **Read-markers**
|
|
133
|
-
- **Progress sync**
|
|
140
|
+
- **My Library** - browse the manga you follow on MangaDex.
|
|
141
|
+
- **Read-markers** - chapters you've already read are marked in the chapter list.
|
|
142
|
+
- **Progress sync** - finishing a chapter pushes a read-marker back to MangaDex.
|
|
134
143
|
|
|
135
144
|
MangaDex uses OAuth2 **personal clients**, so logging in is a one-time setup:
|
|
136
145
|
|
|
@@ -140,7 +149,7 @@ MangaDex uses OAuth2 **personal clients**, so logging in is a one-time setup:
|
|
|
140
149
|
2. In the app: **Settings → Log in to MangaDex…**, then enter the client id/secret plus
|
|
141
150
|
your MangaDex username and password.
|
|
142
151
|
|
|
143
|
-
The session is **durable**
|
|
152
|
+
The session is **durable** - it requests an `offline_access` token and persists it, so
|
|
144
153
|
you stay logged in across restarts until you explicitly **log out** (Settings). Only the
|
|
145
154
|
client id/secret + refresh token are stored, in `~/.komado/credentials.json` (mode
|
|
146
155
|
`600`); your password is never written to disk. Toggle write-back any time with
|
|
@@ -166,10 +175,10 @@ archives are supported (RAR via the WASM `node-unrar-js`, no system binary neede
|
|
|
166
175
|
|
|
167
176
|
Everything is self-contained under `~/.komado/` (override with `KOMADO_HOME`):
|
|
168
177
|
|
|
169
|
-
- `config.json`
|
|
170
|
-
- `progress.json`
|
|
171
|
-
- `credentials.json`
|
|
172
|
-
- `cache/`
|
|
178
|
+
- `config.json` - preferences + library paths
|
|
179
|
+
- `progress.json` - reading progress
|
|
180
|
+
- `credentials.json` - MangaDex login (client id/secret + refresh token; mode `600`)
|
|
181
|
+
- `cache/` - scratch space for the renderer
|
|
173
182
|
|
|
174
183
|
## Architecture
|
|
175
184
|
|
|
@@ -180,23 +189,23 @@ cli.js → app.js (screen stack) → screens → hooks/state → sources/* → H
|
|
|
180
189
|
└──────────────→ render/* (image → terminal)
|
|
181
190
|
```
|
|
182
191
|
|
|
183
|
-
- **`src/sources/*`**
|
|
192
|
+
- **`src/sources/*`** - each source (`mangadex`, `local`) implements the same interface
|
|
184
193
|
(`search`, `getManga`, `listChapters`, `getPages`, `loadPageBuffer`) and returns the
|
|
185
194
|
unified `{ data, pagination, meta }` envelope. The only source-specific seam is
|
|
186
195
|
`loadPageBuffer`, which resolves raw bytes.
|
|
187
|
-
- **`src/domain/shape.js`**
|
|
188
|
-
- **`src/render/*`**
|
|
196
|
+
- **`src/domain/shape.js`** - the unified `Manga`/`Chapter` contract.
|
|
197
|
+
- **`src/render/*`** - capability detection + half-block (`sharp`) and `chafa` backends
|
|
189
198
|
behind one `renderInline()` dispatcher, with half-block as the always-works fallback.
|
|
190
|
-
- **`src/lib/*`**
|
|
199
|
+
- **`src/lib/*`** - cross-cutting utilities ported from a server toolkit:
|
|
191
200
|
`fetchWithBackoff` (retries 429/5xx + timeout), `createCache` (TTL + negative caching
|
|
192
201
|
+ stampede protection), `AppError`/typed errors, the response `envelope`.
|
|
193
|
-
- **`src/components/*` + `src/hooks/*`**
|
|
202
|
+
- **`src/components/*` + `src/hooks/*`** - Ink UI. Effects use a cancelled-flag/abort
|
|
194
203
|
guard against out-of-order responses, and reading progress writes are debounced.
|
|
195
204
|
|
|
196
205
|
## Development
|
|
197
206
|
|
|
198
207
|
UI is plain JSX in `.js` files, transpiled to `dist/` by esbuild (a small
|
|
199
|
-
`scripts/build.mjs` does a transpile-only, structure-preserving build
|
|
208
|
+
`scripts/build.mjs` does a transpile-only, structure-preserving build - no bundling,
|
|
200
209
|
so package imports stay external).
|
|
201
210
|
|
|
202
211
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
function printHelp() {
|
|
5
|
-
console.log(`komado
|
|
5
|
+
console.log(`komado - a terminal manga reader (MangaDex + local files)
|
|
6
6
|
|
|
7
7
|
Usage:
|
|
8
8
|
komado launch the interactive reader
|
|
@@ -40,8 +40,8 @@ async function doctor() {
|
|
|
40
40
|
console.log(` cell size: ${probe.cellW ?? "?"}x${probe.cellH ?? "?"} px`);
|
|
41
41
|
}
|
|
42
42
|
} else {
|
|
43
|
-
console.log(` kitty graphics: ${caps.kitty} (env guess
|
|
44
|
-
console.log(` sixel: ${caps.sixel} (env guess
|
|
43
|
+
console.log(` kitty graphics: ${caps.kitty} (env guess - run in a real terminal to probe)`);
|
|
44
|
+
console.log(` sixel: ${caps.sixel} (env guess - run in a real terminal to probe)`);
|
|
45
45
|
}
|
|
46
46
|
console.log(` chafa: ${caps.chafa ? caps.chafaVersion : "not installed"}`);
|
|
47
47
|
console.log(` inline backend: ${caps.chafa ? "chafa-symbols" : "half-block"} (config.renderer=${cfg.renderer})`);
|
|
@@ -50,7 +50,7 @@ async function doctor() {
|
|
|
50
50
|
console.log(
|
|
51
51
|
protos.length ? `
|
|
52
52
|
\u2192 Pixel graphics available (${protos.join(", ")}). Crisp rendering is possible:
|
|
53
|
-
test it with node dist/cli.js render <some-image>` : "\n \u2192 No pixel protocol detected
|
|
53
|
+
test it with node dist/cli.js render <some-image>` : "\n \u2192 No pixel protocol detected - rendering is limited to character cells."
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
console.log("\nPaths:");
|
|
@@ -63,7 +63,7 @@ async function doctor() {
|
|
|
63
63
|
console.log(` dataSaver: ${cfg.dataSaver}`);
|
|
64
64
|
console.log(` renderer: ${cfg.renderer}`);
|
|
65
65
|
console.log(` contentRating: ${cfg.contentRating.join(", ")}`);
|
|
66
|
-
console.log(` localLibraryPaths: ${cfg.localLibraryPaths.length ? cfg.localLibraryPaths.join(", ") : "(none
|
|
66
|
+
console.log(` localLibraryPaths: ${cfg.localLibraryPaths.length ? cfg.localLibraryPaths.join(", ") : "(none - add in Settings)"}`);
|
|
67
67
|
}
|
|
68
68
|
async function renderCmd(target, rest) {
|
|
69
69
|
if (!target) {
|
|
@@ -136,7 +136,7 @@ ${formatUninstallSummary(results)}
|
|
|
136
136
|
/* @__PURE__ */ jsx(Text, { children: "This permanently deletes:" }),
|
|
137
137
|
uninstallTargets().map((t) => /* @__PURE__ */ jsxs(Text, { color: "red", children: [
|
|
138
138
|
` \u2022 ${displayPath(t.path)}`,
|
|
139
|
-
/* @__PURE__ */ jsx(Text, { dimColor: true, children: `
|
|
139
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: ` - ${t.label}` })
|
|
140
140
|
] }, t.path)),
|
|
141
141
|
/* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
|
|
142
142
|
/* @__PURE__ */ jsx(Text, { children: "Type " }),
|
package/dist/config.js
CHANGED
|
@@ -29,7 +29,7 @@ const DEFAULT_CONFIG = {
|
|
|
29
29
|
// preferred MangaDex translatedLanguage
|
|
30
30
|
contentRating: ["safe", "suggestive"],
|
|
31
31
|
dataSaver: true,
|
|
32
|
-
// smaller MangaDex page images
|
|
32
|
+
// smaller MangaDex page images - ideal for a terminal
|
|
33
33
|
renderer: "auto",
|
|
34
34
|
// auto | halfblock | chafa
|
|
35
35
|
theme: "default",
|
package/dist/domain/shape.js
CHANGED
|
@@ -34,7 +34,7 @@ function makeChapter(p) {
|
|
|
34
34
|
}
|
|
35
35
|
function chapterLabel(ch) {
|
|
36
36
|
const head = ch.number != null && ch.number !== "" ? `Ch. ${ch.number}${ch.volume != null && ch.volume !== "" ? ` (Vol. ${ch.volume})` : ""}` : "Oneshot";
|
|
37
|
-
return ch.title ? `${head}
|
|
37
|
+
return ch.title ? `${head} - ${ch.title}` : head;
|
|
38
38
|
}
|
|
39
39
|
export {
|
|
40
40
|
chapterLabel,
|
|
@@ -76,7 +76,7 @@ async function getAccessToken({ signal, force = false } = {}) {
|
|
|
76
76
|
return access.token;
|
|
77
77
|
} catch (err) {
|
|
78
78
|
if (err.oauthError === "invalid_grant") {
|
|
79
|
-
logger.warn("MangaDex refresh token expired/revoked
|
|
79
|
+
logger.warn("MangaDex refresh token expired/revoked - logging out", err);
|
|
80
80
|
logout();
|
|
81
81
|
}
|
|
82
82
|
throw err;
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -39,7 +39,7 @@ export function App({ caps = {}, onViewer, initialRoute = null }) {
|
|
|
39
39
|
exit();
|
|
40
40
|
}, [exit]);
|
|
41
41
|
|
|
42
|
-
// Global keys
|
|
42
|
+
// Global keys - suppressed while a text input is focused (`typing`).
|
|
43
43
|
useInput((input, key) => {
|
|
44
44
|
if (typing) return;
|
|
45
45
|
if (input === 'q') quit();
|
package/src/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import process from 'node:process';
|
|
3
3
|
|
|
4
4
|
function printHelp() {
|
|
5
|
-
console.log(`komado
|
|
5
|
+
console.log(`komado - a terminal manga reader (MangaDex + local files)
|
|
6
6
|
|
|
7
7
|
Usage:
|
|
8
8
|
komado launch the interactive reader
|
|
@@ -42,8 +42,8 @@ async function doctor() {
|
|
|
42
42
|
console.log(` cell size: ${probe.cellW ?? '?'}x${probe.cellH ?? '?'} px`);
|
|
43
43
|
}
|
|
44
44
|
} else {
|
|
45
|
-
console.log(` kitty graphics: ${caps.kitty} (env guess
|
|
46
|
-
console.log(` sixel: ${caps.sixel} (env guess
|
|
45
|
+
console.log(` kitty graphics: ${caps.kitty} (env guess - run in a real terminal to probe)`);
|
|
46
|
+
console.log(` sixel: ${caps.sixel} (env guess - run in a real terminal to probe)`);
|
|
47
47
|
}
|
|
48
48
|
console.log(` chafa: ${caps.chafa ? caps.chafaVersion : 'not installed'}`);
|
|
49
49
|
console.log(` inline backend: ${caps.chafa ? 'chafa-symbols' : 'half-block'} (config.renderer=${cfg.renderer})`);
|
|
@@ -52,7 +52,7 @@ async function doctor() {
|
|
|
52
52
|
console.log(
|
|
53
53
|
protos.length
|
|
54
54
|
? `\n → Pixel graphics available (${protos.join(', ')}). Crisp rendering is possible:\n test it with node dist/cli.js render <some-image>`
|
|
55
|
-
: '\n → No pixel protocol detected
|
|
55
|
+
: '\n → No pixel protocol detected - rendering is limited to character cells.',
|
|
56
56
|
);
|
|
57
57
|
}
|
|
58
58
|
console.log('\nPaths:');
|
|
@@ -65,7 +65,7 @@ async function doctor() {
|
|
|
65
65
|
console.log(` dataSaver: ${cfg.dataSaver}`);
|
|
66
66
|
console.log(` renderer: ${cfg.renderer}`);
|
|
67
67
|
console.log(` contentRating: ${cfg.contentRating.join(', ')}`);
|
|
68
|
-
console.log(` localLibraryPaths: ${cfg.localLibraryPaths.length ? cfg.localLibraryPaths.join(', ') : '(none
|
|
68
|
+
console.log(` localLibraryPaths: ${cfg.localLibraryPaths.length ? cfg.localLibraryPaths.join(', ') : '(none - add in Settings)'}`);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
async function renderCmd(target, rest) {
|
package/src/components/List.js
CHANGED
|
@@ -20,7 +20,7 @@ export function List({
|
|
|
20
20
|
const selected = count ? Math.min(index, count - 1) : 0;
|
|
21
21
|
|
|
22
22
|
// Notify the parent of the highlighted item when the selection (or list size)
|
|
23
|
-
// changes. Keyed on the index + count (primitives)
|
|
23
|
+
// changes. Keyed on the index + count (primitives) - NOT the `items` array,
|
|
24
24
|
// whose reference changes every render in callers that rebuild it inline. If
|
|
25
25
|
// `items` were a dep, a parent whose onHighlight calls setState would loop:
|
|
26
26
|
// render → new items ref → effect → setState → render → … (max update depth).
|
|
@@ -9,7 +9,7 @@ import { truncate } from '../../lib/text.js';
|
|
|
9
9
|
const PAGE = 32;
|
|
10
10
|
|
|
11
11
|
// The signed-in user's MangaDex follows. Same browse/paginate shape as the
|
|
12
|
-
// search screen, minus the query box
|
|
12
|
+
// search screen, minus the query box - getFollows is just another envelope.
|
|
13
13
|
export function LibraryScreen({ params }) {
|
|
14
14
|
const sourceId = params?.sourceId || 'mangadex';
|
|
15
15
|
const ui = useUI();
|
|
@@ -22,7 +22,7 @@ export function LoginScreen() {
|
|
|
22
22
|
const [busy, setBusy] = useState(false);
|
|
23
23
|
const [error, setError] = useState(null);
|
|
24
24
|
|
|
25
|
-
// The whole screen is a form
|
|
25
|
+
// The whole screen is a form - keep global keys (q / Esc) suppressed.
|
|
26
26
|
useEffect(() => {
|
|
27
27
|
ui.setTyping(true);
|
|
28
28
|
return () => ui.setTyping(false);
|
|
@@ -39,7 +39,7 @@ export function MangaScreen({ params }) {
|
|
|
39
39
|
if (isLoggedIn() && source.getReadMarkers) {
|
|
40
40
|
source.getReadMarkers(initial.id, { signal: ctrl.signal })
|
|
41
41
|
.then((ids) => { if (!cancelled) setReadSet(new Set(ids)); })
|
|
42
|
-
.catch(() => {}); // decorative
|
|
42
|
+
.catch(() => {}); // decorative - never block the screen on this
|
|
43
43
|
}
|
|
44
44
|
} catch (err) {
|
|
45
45
|
if (!cancelled) setError(err);
|
|
@@ -75,7 +75,7 @@ export function ReaderScreen({ params }) {
|
|
|
75
75
|
return out;
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
-
// Background prefetch (own controller
|
|
78
|
+
// Background prefetch (own controller - survives page turns, aborts on unmount).
|
|
79
79
|
const prefetchers = useRef(new Set());
|
|
80
80
|
useEffect(() => () => {
|
|
81
81
|
for (const c of prefetchers.current) c.abort();
|
|
@@ -136,7 +136,7 @@ export function SettingsScreen() {
|
|
|
136
136
|
{uninstallTargets().map((t) => (
|
|
137
137
|
<Text key={t.path} color="red">
|
|
138
138
|
{` • ${displayPath(t.path)}`}
|
|
139
|
-
<Text dimColor>{`
|
|
139
|
+
<Text dimColor>{` - ${t.label}`}</Text>
|
|
140
140
|
</Text>
|
|
141
141
|
))}
|
|
142
142
|
<Box marginTop={1}>
|
package/src/components/ui.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
|
|
4
4
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
5
5
|
|
|
6
|
-
// Hand-rolled spinner
|
|
6
|
+
// Hand-rolled spinner - dependency-light, matching your preference for not
|
|
7
7
|
// pulling a package for something this small.
|
|
8
8
|
export function Spinner({ label = 'Loading' }) {
|
|
9
9
|
const [frame, setFrame] = useState(0);
|
package/src/config.js
CHANGED
|
@@ -7,7 +7,7 @@ const usingDefaultHome = !process.env.KOMADO_HOME;
|
|
|
7
7
|
const HOME = usingDefaultHome
|
|
8
8
|
? path.join(os.homedir(), '.komado')
|
|
9
9
|
: path.resolve(process.env.KOMADO_HOME);
|
|
10
|
-
// The pre-rename home (the project used to be "manga-tui")
|
|
10
|
+
// The pre-rename home (the project used to be "manga-tui") - migrated once on
|
|
11
11
|
// first run so existing config / reading progress / MangaDex login carry over.
|
|
12
12
|
const LEGACY_HOME = path.join(os.homedir(), '.manga-tui');
|
|
13
13
|
|
|
@@ -34,7 +34,7 @@ export const DEFAULT_CONFIG = {
|
|
|
34
34
|
localLibraryPaths: [], // directories scanned by the local source
|
|
35
35
|
language: 'en', // preferred MangaDex translatedLanguage
|
|
36
36
|
contentRating: ['safe', 'suggestive'],
|
|
37
|
-
dataSaver: true, // smaller MangaDex page images
|
|
37
|
+
dataSaver: true, // smaller MangaDex page images - ideal for a terminal
|
|
38
38
|
renderer: 'auto', // auto | halfblock | chafa
|
|
39
39
|
theme: 'default',
|
|
40
40
|
syncProgress: true, // push read-markers to MangaDex while logged in
|
package/src/domain/shape.js
CHANGED
|
@@ -43,5 +43,5 @@ export function chapterLabel(ch) {
|
|
|
43
43
|
const head = ch.number != null && ch.number !== ''
|
|
44
44
|
? `Ch. ${ch.number}${ch.volume != null && ch.volume !== '' ? ` (Vol. ${ch.volume})` : ''}`
|
|
45
45
|
: 'Oneshot';
|
|
46
|
-
return ch.title ? `${head}
|
|
46
|
+
return ch.title ? `${head} - ${ch.title}` : head;
|
|
47
47
|
}
|
package/src/lib/AppError.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Typed, operational errors
|
|
1
|
+
// Typed, operational errors - same idea as your server AppError, minus HTTP plumbing.
|
|
2
2
|
// statusCode is kept for parity/log triage; the TUI maps these to friendly messages.
|
|
3
3
|
export class AppError extends Error {
|
|
4
4
|
constructor(message, statusCode = 500, opts = {}) {
|
package/src/lib/cache.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// In-memory cache with TTL, negative caching, and stampede protection
|
|
1
|
+
// In-memory cache with TTL, negative caching, and stampede protection -
|
|
2
2
|
// a port of your createCache. `wrap` shares a single in-flight promise per key
|
|
3
3
|
// so concurrent callers (e.g. two screens requesting the same chapter) collapse
|
|
4
4
|
// into one upstream request.
|
|
@@ -48,7 +48,7 @@ export async function fetchWithBackoff(url, options = {}) {
|
|
|
48
48
|
}
|
|
49
49
|
return res;
|
|
50
50
|
} catch (err) {
|
|
51
|
-
// Caller cancelled
|
|
51
|
+
// Caller cancelled - propagate without retrying.
|
|
52
52
|
if (extSignal?.aborted) throw err;
|
|
53
53
|
if (attempt < retries) {
|
|
54
54
|
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt) + Math.random() * 200;
|
package/src/lib/logger.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { paths } from '../config.js';
|
|
3
3
|
|
|
4
|
-
// A TUI owns stdout
|
|
4
|
+
// A TUI owns stdout - console.log would corrupt the Ink render. So debug output
|
|
5
5
|
// goes to a file, only when KOMADO_DEBUG is set. Tail it with:
|
|
6
6
|
// tail -f ~/.komado/komado.log
|
|
7
7
|
const enabled = !!process.env.KOMADO_DEBUG;
|
package/src/render/detect.js
CHANGED
|
@@ -35,7 +35,7 @@ export function detectCapabilities() {
|
|
|
35
35
|
chafa = true;
|
|
36
36
|
chafaVersion = (out.match(/version\s+([\d.]+)/i) || [])[1] || 'unknown';
|
|
37
37
|
} catch {
|
|
38
|
-
/* chafa not on PATH
|
|
38
|
+
/* chafa not on PATH - half-block fallback */
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
cached = { term, termProgram, kitty, sixel, truecolor, chafa, chafaVersion };
|
package/src/render/halfblock.js
CHANGED
|
@@ -5,7 +5,7 @@ const RESET = `${ESC}[0m`;
|
|
|
5
5
|
|
|
6
6
|
// Render an image buffer to terminal lines using the upper-half-block glyph (▀):
|
|
7
7
|
// the glyph's foreground paints the TOP half of the cell, the background the
|
|
8
|
-
// BOTTOM half
|
|
8
|
+
// BOTTOM half - so each character encodes two vertical pixels at 24-bit colour.
|
|
9
9
|
// One column == one pixel wide, one row == two pixels tall.
|
|
10
10
|
export async function renderHalfBlock(buffer, { cols = 80 } = {}) {
|
|
11
11
|
const targetWidth = Math.max(1, Math.min(Math.floor(cols), 400));
|
|
@@ -34,7 +34,7 @@ export async function renderHalfBlock(buffer, { cols = 80 } = {}) {
|
|
|
34
34
|
const [br, bg, bb] = y + 1 < height ? px(x, y + 1) : [tr, tg, tb];
|
|
35
35
|
const fg = `${tr};${tg};${tb}`;
|
|
36
36
|
const bgc = `${br};${bg};${bb}`;
|
|
37
|
-
// Emit colour codes only when they change
|
|
37
|
+
// Emit colour codes only when they change - keeps lines compact.
|
|
38
38
|
if (fg !== lastFg) { line += `${ESC}[38;2;${fg}m`; lastFg = fg; }
|
|
39
39
|
if (bgc !== lastBg) { line += `${ESC}[48;2;${bgc}m`; lastBg = bgc; }
|
|
40
40
|
line += '▀';
|
package/src/render/image.js
CHANGED
|
@@ -9,7 +9,7 @@ export async function imageSize(buffer) {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
// Dispatch a buffer to the chosen inline backend, producing { lines, cols, rows }.
|
|
12
|
-
// Always falls back to half-block so a chafa hiccup never blanks the reader
|
|
12
|
+
// Always falls back to half-block so a chafa hiccup never blanks the reader -
|
|
13
13
|
// the same "graceful degradation" idea as your SSR→static fallback.
|
|
14
14
|
export async function renderInline(buffer, { cols = 80, backend = 'halfblock' } = {}) {
|
|
15
15
|
if (backend === 'chafa-symbols') {
|
package/src/render/sixel.js
CHANGED
|
@@ -11,7 +11,7 @@ const DEFAULT_CELL_H = 20;
|
|
|
11
11
|
|
|
12
12
|
// Encode an already-correctly-sized image to sixel at its NATIVE pixel
|
|
13
13
|
// resolution. --exact-size + font-ratio 1/1 makes chafa emit ~1 image px → 1
|
|
14
|
-
// sixel px, so the displayed size is exactly what we sized the image to
|
|
14
|
+
// sixel px, so the displayed size is exactly what we sized the image to - no
|
|
15
15
|
// dependence on chafa guessing the terminal's cell size through a pipe.
|
|
16
16
|
export async function encodePixels(buffer, { format = 'sixel' } = {}) {
|
|
17
17
|
const { stdout } = await withTempImage(buffer, (file) =>
|
|
@@ -40,7 +40,7 @@ export async function scalePage(buffer, { cols, cellW }) {
|
|
|
40
40
|
// mode 'width' → full terminal width, vertical window at cell offset `scroll`
|
|
41
41
|
// Pass a pre-scaled page (`scaled` from scalePage) in 'width' mode to skip the
|
|
42
42
|
// per-scroll resize; without it the page is scaled inline.
|
|
43
|
-
// Returns { buffer, maxScroll, scroll, imageRows }
|
|
43
|
+
// Returns { buffer, maxScroll, scroll, imageRows } - scroll clamped to range,
|
|
44
44
|
// imageRows = the rendered image's height in cells (so the caller can erase any
|
|
45
45
|
// rows below it instead of clearing the whole screen).
|
|
46
46
|
export async function prepareImage(buffer, { mode, cols, rows, scroll = 0, cellW, cellH, scaled = null }) {
|
|
@@ -80,7 +80,7 @@ export async function prepareImage(buffer, { mode, cols, rows, scroll = 0, cellW
|
|
|
80
80
|
// chafa + ~50ms of sharp per frame). But sixel is laid out as a fixed palette
|
|
81
81
|
// defined UP FRONT followed by independent 6px-tall bands, each of which
|
|
82
82
|
// re-selects its own colours. So we encode the whole page to sixel ONCE, then
|
|
83
|
-
// window the pre-encoded bands per frame
|
|
83
|
+
// window the pre-encoded bands per frame - a string slice, not a re-encode.
|
|
84
84
|
// (Verified against chafa 1.14: every palette entry precedes the first band and
|
|
85
85
|
// every band starts with a colour select, so any band range stands alone.)
|
|
86
86
|
const BAND_PX = 6;
|
|
@@ -109,7 +109,7 @@ export function parseSixelPage(raw) {
|
|
|
109
109
|
// chafa omits a band's leading colour select when it equals the previous
|
|
110
110
|
// band's last colour (the register carries across '-'). For a band to be the
|
|
111
111
|
// TOP of a sliced window it must re-select that colour itself, so prepend the
|
|
112
|
-
// carried register wherever it's missing
|
|
112
|
+
// carried register wherever it's missing - making every band self-contained.
|
|
113
113
|
let carry = '0'; // sixel's default colour register
|
|
114
114
|
const bands = rawBands.map((b) => {
|
|
115
115
|
const fixed = b.startsWith('#') ? b : `#${carry}${b}`;
|
package/src/sixel-reader.js
CHANGED
|
@@ -7,7 +7,7 @@ import { logger } from './lib/logger.js';
|
|
|
7
7
|
const ESC = '\x1b';
|
|
8
8
|
|
|
9
9
|
// Synchronized-update markers (DEC private mode 2026). Wrapping a frame makes a
|
|
10
|
-
// supporting terminal present it atomically
|
|
10
|
+
// supporting terminal present it atomically - so the strip-scroll's "scroll the
|
|
11
11
|
// region, then repaint the freed strip" can't flash a blank strip at the leading
|
|
12
12
|
// edge. Ignored (harmless) on terminals that don't implement it.
|
|
13
13
|
const SYNC_BEGIN = Buffer.from(`${ESC}[?2026h`, 'latin1');
|
|
@@ -30,7 +30,7 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
30
30
|
|
|
31
31
|
// Cell/band geometry for the optional strip-scroll. Sixel bands are 6px tall;
|
|
32
32
|
// the terminal scrolls by whole cells. A "slot" = LCM(6, cellH)px is the
|
|
33
|
-
// smallest shift that's whole in BOTH grids
|
|
33
|
+
// smallest shift that's whole in BOTH grids - scrolling by slots lets us move
|
|
34
34
|
// the on-screen pixels with the terminal and repaint only the newly-exposed
|
|
35
35
|
// strip (seam-free), instead of re-sending the entire viewport every step.
|
|
36
36
|
// Opt-in (KOMADO_SCROLL_DELTA=1) because it relies on the terminal scrolling
|
|
@@ -45,7 +45,7 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
45
45
|
let shownSig = null; // page + geometry the on-screen frame was drawn for
|
|
46
46
|
|
|
47
47
|
// Per-page caches. draw() runs on every keypress, but the page bytes and the
|
|
48
|
-
// full-width scale are constant within a page
|
|
48
|
+
// full-width scale are constant within a page - re-doing them per scroll step
|
|
49
49
|
// (a network round-trip per step for remote sources) is what made scrolling
|
|
50
50
|
// crawl. Cache both, keyed by page (and width for the scale).
|
|
51
51
|
let rawKey = null;
|
|
@@ -200,7 +200,7 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
200
200
|
});
|
|
201
201
|
// Last page reached → push a read-marker to MangaDex (self-guarded/deduped).
|
|
202
202
|
// `pages &&`: a rapid n/p runs changeChapter() (pages = null) during one of
|
|
203
|
-
// this draw's awaits, after it started
|
|
203
|
+
// this draw's awaits, after it started - guard before reading .length, or the
|
|
204
204
|
// stale tail throws "Cannot read properties of null (reading 'length')".
|
|
205
205
|
if (pages && pi === pages.length - 1 && source.syncChapterRead) {
|
|
206
206
|
source.syncChapterRead(manga.id, chapters[ci].id);
|
|
@@ -212,7 +212,7 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
// Whole-viewport frame: home + overwrite (no full ESC[2J each step
|
|
215
|
+
// Whole-viewport frame: home + overwrite (no full ESC[2J each step - that
|
|
216
216
|
// clear-to-blank is what made scrolling blink), erase only the rows a shorter
|
|
217
217
|
// image leaves below, then the status bar. One atomic write avoids partial
|
|
218
218
|
// frames and extra syscalls.
|
|
@@ -224,7 +224,7 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
// Strip-scroll frame (opt-in): scroll the image area with the terminal and
|
|
227
|
-
// repaint only the slot of sixel that scrolled into view
|
|
227
|
+
// repaint only the slot of sixel that scrolled into view - far less data than
|
|
228
228
|
// re-sending the whole viewport. Valid only for slot-sized shifts (whole cells
|
|
229
229
|
// AND whole bands), so the moved pixels stay band-aligned and there's no seam.
|
|
230
230
|
// Uses LF/RI inside a DECSTBM region (the most widely supported scroll path).
|
|
@@ -248,7 +248,7 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
// Coalescing scheduler: while a draw runs, extra requests collapse into a
|
|
251
|
-
// single follow-up at the latest state
|
|
251
|
+
// single follow-up at the latest state - input is never dropped and draws
|
|
252
252
|
// never stack up behind a slow encode.
|
|
253
253
|
function schedule({ fullClear = false } = {}) {
|
|
254
254
|
if (fullClear) pendingFullClear = true;
|
|
@@ -290,7 +290,7 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
290
290
|
const prevRaw = stdin.isRaw;
|
|
291
291
|
let onKey;
|
|
292
292
|
// Ink leaves stdin unref'd after unmount, so a bare `await`-for-keypress won't
|
|
293
|
-
// keep the process alive
|
|
293
|
+
// keep the process alive - it would exit the moment the first page is drawn.
|
|
294
294
|
// A ref'd timer holds the event loop open until we're done.
|
|
295
295
|
const keepAlive = setInterval(() => {}, 1 << 30);
|
|
296
296
|
// Re-render on terminal resize so the page tracks the window size. Full clear:
|
|
@@ -333,13 +333,13 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
333
333
|
} else {
|
|
334
334
|
return; // ignore other keys without redrawing
|
|
335
335
|
}
|
|
336
|
-
// Update state synchronously, then coalesce the redraw
|
|
336
|
+
// Update state synchronously, then coalesce the redraw - rapid repeats
|
|
337
337
|
// collapse into the fewest draws instead of being dropped mid-draw.
|
|
338
338
|
inputSeq += 1;
|
|
339
339
|
schedule();
|
|
340
340
|
};
|
|
341
341
|
|
|
342
|
-
// Enter raw mode *after* the first draw
|
|
342
|
+
// Enter raw mode *after* the first draw - Ink's unmount restores cooked
|
|
343
343
|
// mode on a deferred tick, which would otherwise leave stdin line-buffered
|
|
344
344
|
// (so single keypresses never arrive).
|
|
345
345
|
stdin.removeAllListeners('data');
|
|
@@ -32,7 +32,7 @@ function readZipEntry(filePath, entryName) {
|
|
|
32
32
|
return entry.getData();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
// ---- RAR / CBR (node-unrar-js
|
|
35
|
+
// ---- RAR / CBR (node-unrar-js - WASM, loaded lazily on first use) ----
|
|
36
36
|
let unrarPromise = null;
|
|
37
37
|
const getUnrar = () => (unrarPromise ??= import('node-unrar-js'));
|
|
38
38
|
|
|
@@ -27,7 +27,7 @@ const cleanName = (name) => name.replace(/\.(cbz|zip|cbr|rar)$/i, '');
|
|
|
27
27
|
const expandHome = (p) => (p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p);
|
|
28
28
|
|
|
29
29
|
// A directory is a manga. Its chapters are: image subfolders, then archive
|
|
30
|
-
// files; or
|
|
30
|
+
// files; or - if neither - the loose images in the folder become one chapter.
|
|
31
31
|
function buildMangaFromDir(dirPath) {
|
|
32
32
|
const entries = listDir(dirPath);
|
|
33
33
|
const chapters = [];
|
|
@@ -7,7 +7,7 @@ import { logger } from '../../lib/logger.js';
|
|
|
7
7
|
// MangaDex auth is OAuth2 "personal clients" (Keycloak). The user registers a
|
|
8
8
|
// client at mangadex.org/settings, then we exchange client id/secret + their
|
|
9
9
|
// username/password for a short-lived (15-min) access token and a rotating
|
|
10
|
-
// refresh token. Reading manga needs none of this
|
|
10
|
+
// refresh token. Reading manga needs none of this - login only unlocks the
|
|
11
11
|
// user's follows/library and read-marker sync.
|
|
12
12
|
|
|
13
13
|
// The access token lives in memory only; the refresh token is the durable
|
|
@@ -57,7 +57,7 @@ function applyTokens(creds, json) {
|
|
|
57
57
|
token: json.access_token,
|
|
58
58
|
expiresAt: Date.now() + (Number(json.expires_in) || 900) * 1000,
|
|
59
59
|
};
|
|
60
|
-
// The refresh token rotates on each use
|
|
60
|
+
// The refresh token rotates on each use - persist the latest one.
|
|
61
61
|
setCredentials({ ...creds, refreshToken: json.refresh_token || creds.refreshToken });
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -101,10 +101,10 @@ export async function getAccessToken({ signal, force = false } = {}) {
|
|
|
101
101
|
} catch (err) {
|
|
102
102
|
// Only drop the saved session when the refresh token is DEFINITIVELY
|
|
103
103
|
// dead (expired/revoked → invalid_grant). Transient or unexpected
|
|
104
|
-
// failures keep the credentials so the next call
|
|
104
|
+
// failures keep the credentials so the next call - or next launch - can
|
|
105
105
|
// recover, instead of silently logging the user out.
|
|
106
106
|
if (err.oauthError === 'invalid_grant') {
|
|
107
|
-
logger.warn('MangaDex refresh token expired/revoked
|
|
107
|
+
logger.warn('MangaDex refresh token expired/revoked - logging out', err);
|
|
108
108
|
logout();
|
|
109
109
|
}
|
|
110
110
|
throw err;
|
|
@@ -121,7 +121,7 @@ export async function loadPageBuffer(page, { signal } = {}) {
|
|
|
121
121
|
|
|
122
122
|
// The signed-in user's followed manga, normalized like search results.
|
|
123
123
|
export async function getFollows({ offset = 0, limit = MANGADEX.pageLimit, signal } = {}) {
|
|
124
|
-
// /user/follows/manga rejects contentRating[] (HTTP 400)
|
|
124
|
+
// /user/follows/manga rejects contentRating[] (HTTP 400) - it returns ALL of
|
|
125
125
|
// the user's follows regardless of rating, which is what we want here anyway.
|
|
126
126
|
const res = await mdGet('/user/follows/manga', {
|
|
127
127
|
limit,
|
package/src/state/store.js
CHANGED
|
@@ -13,7 +13,7 @@ function readJson(file, fallback) {
|
|
|
13
13
|
|
|
14
14
|
// While the app is uninstalling itself, every writer no-ops so a pending debounced
|
|
15
15
|
// save (or the flushProgress() that quit() runs) can't recreate the data directory
|
|
16
|
-
// we just deleted. Set once, never unset
|
|
16
|
+
// we just deleted. Set once, never unset - the process is on its way out.
|
|
17
17
|
let persistenceDisabled = false;
|
|
18
18
|
export function disablePersistence() {
|
|
19
19
|
persistenceDisabled = true;
|
|
@@ -49,7 +49,7 @@ function loadProgress() {
|
|
|
49
49
|
return progress;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// Debounced save
|
|
52
|
+
// Debounced save - the reader updates progress on every page turn, so coalesce.
|
|
53
53
|
let saveTimer = null;
|
|
54
54
|
function scheduleSave() {
|
|
55
55
|
if (persistenceDisabled) return;
|
package/src/uninstall.js
CHANGED
|
@@ -6,7 +6,7 @@ import { disablePersistence } from './state/store.js';
|
|
|
6
6
|
|
|
7
7
|
// Where install.sh put things. Mirrors the installer's own defaults + env
|
|
8
8
|
// overrides (KOMADO_APP_DIR / KOMADO_BIN_DIR) so an in-app uninstall targets
|
|
9
|
-
// exactly what was installed
|
|
9
|
+
// exactly what was installed - and never a dev checkout, which lives elsewhere.
|
|
10
10
|
function appDir() {
|
|
11
11
|
if (process.env.KOMADO_APP_DIR) return path.resolve(process.env.KOMADO_APP_DIR);
|
|
12
12
|
const share = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
@@ -16,13 +16,13 @@ function launcherFile() {
|
|
|
16
16
|
const bin = process.env.KOMADO_BIN_DIR
|
|
17
17
|
? path.resolve(process.env.KOMADO_BIN_DIR)
|
|
18
18
|
: path.join(os.homedir(), '.local', 'bin');
|
|
19
|
-
// The launcher FILE only
|
|
19
|
+
// The launcher FILE only - never the whole bin dir, which is shared with other tools.
|
|
20
20
|
return path.join(bin, 'komado');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// The three things a full install creates. `paths.home` is the runtime data dir
|
|
24
24
|
// (~/.komado or KOMADO_HOME) and holds config, reading progress, the MangaDex
|
|
25
|
-
// login, the page cache, and the log
|
|
25
|
+
// login, the page cache, and the log - all under one dir, so one delete clears it.
|
|
26
26
|
export function uninstallTargets() {
|
|
27
27
|
return [
|
|
28
28
|
{ label: 'application files', path: appDir() },
|
|
@@ -47,7 +47,7 @@ function assertSafe(p) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
// Remove the installed app, its launcher, and all runtime data. Synchronous
|
|
50
|
+
// Remove the installed app, its launcher, and all runtime data. Synchronous - a
|
|
51
51
|
// one-shot teardown run right before exit. It first switches off the store's
|
|
52
52
|
// persistence so a pending debounced save (or the flushProgress() that quit()
|
|
53
53
|
// runs) can't recreate the data dir we're deleting. One failing target never
|