komado 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/README.md +208 -0
- package/dist/app.js +59 -0
- package/dist/cli.js +196 -0
- package/dist/components/List.js +41 -0
- package/dist/components/screens/ContinueScreen.js +66 -0
- package/dist/components/screens/HomeScreen.js +59 -0
- package/dist/components/screens/LibraryScreen.js +69 -0
- package/dist/components/screens/LoginScreen.js +85 -0
- package/dist/components/screens/MangaScreen.js +107 -0
- package/dist/components/screens/ReaderScreen.js +195 -0
- package/dist/components/screens/SearchScreen.js +111 -0
- package/dist/components/screens/SettingsScreen.js +144 -0
- package/dist/components/ui.js +33 -0
- package/dist/config.js +51 -0
- package/dist/domain/shape.js +44 -0
- package/dist/hooks/useStdoutDimensions.js +17 -0
- package/dist/lib/AppError.js +37 -0
- package/dist/lib/cache.js +54 -0
- package/dist/lib/catchAsync.js +12 -0
- package/dist/lib/envelope.js +15 -0
- package/dist/lib/fetchWithBackoff.js +65 -0
- package/dist/lib/logger.js +41 -0
- package/dist/lib/natsort.js +7 -0
- package/dist/lib/text.js +20 -0
- package/dist/render/chafa.js +56 -0
- package/dist/render/detect.js +86 -0
- package/dist/render/halfblock.js +42 -0
- package/dist/render/image.js +23 -0
- package/dist/render/sixel.js +88 -0
- package/dist/sixel-reader.js +309 -0
- package/dist/sources/index.js +17 -0
- package/dist/sources/local/archive.js +68 -0
- package/dist/sources/local/index.js +147 -0
- package/dist/sources/mangadex/auth.js +102 -0
- package/dist/sources/mangadex/client.js +76 -0
- package/dist/sources/mangadex/index.js +156 -0
- package/dist/sources/mangadex/normalize.js +54 -0
- package/dist/state/store.js +91 -0
- package/dist/ui-context.js +11 -0
- package/package.json +50 -0
- package/src/app.js +73 -0
- package/src/cli.js +218 -0
- package/src/components/List.js +60 -0
- package/src/components/screens/ContinueScreen.js +73 -0
- package/src/components/screens/HomeScreen.js +54 -0
- package/src/components/screens/LibraryScreen.js +79 -0
- package/src/components/screens/LoginScreen.js +92 -0
- package/src/components/screens/MangaScreen.js +125 -0
- package/src/components/screens/ReaderScreen.js +230 -0
- package/src/components/screens/SearchScreen.js +123 -0
- package/src/components/screens/SettingsScreen.js +146 -0
- package/src/components/ui.js +42 -0
- package/src/config.js +49 -0
- package/src/domain/shape.js +47 -0
- package/src/hooks/useStdoutDimensions.js +19 -0
- package/src/lib/AppError.js +26 -0
- package/src/lib/cache.js +57 -0
- package/src/lib/catchAsync.js +12 -0
- package/src/lib/envelope.js +14 -0
- package/src/lib/fetchWithBackoff.js +74 -0
- package/src/lib/logger.js +41 -0
- package/src/lib/natsort.js +7 -0
- package/src/lib/text.js +18 -0
- package/src/render/chafa.js +64 -0
- package/src/render/detect.js +112 -0
- package/src/render/halfblock.js +46 -0
- package/src/render/image.js +24 -0
- package/src/render/sixel.js +141 -0
- package/src/sixel-reader.js +359 -0
- package/src/sources/index.js +17 -0
- package/src/sources/local/archive.js +74 -0
- package/src/sources/local/index.js +155 -0
- package/src/sources/mangadex/auth.js +125 -0
- package/src/sources/mangadex/client.js +83 -0
- package/src/sources/mangadex/index.js +166 -0
- package/src/sources/mangadex/normalize.js +70 -0
- package/src/state/store.js +90 -0
- package/src/ui-context.js +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# komado
|
|
2
|
+
|
|
3
|
+
A terminal manga reader. Browse and read from **MangaDex** (online) or your **local
|
|
4
|
+
library** (CBZ/ZIP archives and image folders), rendered straight into the terminal
|
|
5
|
+
with [Ink](https://github.com/vadimdemedes/ink).
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
komado
|
|
9
|
+
a terminal manga reader · MangaDex + local files
|
|
10
|
+
|
|
11
|
+
› Search MangaDex online catalog
|
|
12
|
+
Popular on MangaDex most followed
|
|
13
|
+
Local library your CBZ / folders
|
|
14
|
+
Continue reading resume
|
|
15
|
+
Settings config & library paths
|
|
16
|
+
Quit
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **Two sources, one reader.** MangaDex and local files are normalized into a single
|
|
22
|
+
shape, so search / details / reader / progress treat them identically.
|
|
23
|
+
- **Image rendering with graceful upgrade.** A pure-JS Unicode half-block renderer works
|
|
24
|
+
in any 24-bit terminal; if [`chafa`](https://hpjansson.org/chafa/) is installed it’s
|
|
25
|
+
auto-used for sharper output. Falls back automatically if anything goes wrong.
|
|
26
|
+
- **Reader UX.** Vertical scroll for tall pages, page/chapter navigation, fit-to-screen
|
|
27
|
+
toggle, runtime renderer switching, and next-page prefetch.
|
|
28
|
+
- **Reading progress.** Where you left off is saved per manga and resumable from
|
|
29
|
+
*Continue reading*.
|
|
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** — finishing a chapter
|
|
32
|
+
marks it read on MangaDex. Reading itself needs no account.
|
|
33
|
+
- **Offline-friendly.** Point it at folders of `.cbz`/`.zip` or loose images.
|
|
34
|
+
|
|
35
|
+
## Requirements
|
|
36
|
+
|
|
37
|
+
- Node.js **≥ 20** (developed on Node 22).
|
|
38
|
+
- A 24-bit-color terminal (most modern ones).
|
|
39
|
+
- Optional but recommended: **`chafa`** for higher-fidelity rendering.
|
|
40
|
+
- Debian/Ubuntu: `sudo apt install chafa` · macOS: `brew install chafa`
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
One line — fetches the latest, builds it, and drops a `komado` launcher on your PATH:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
curl -fsSL https://raw.githubusercontent.com/RyuPrad/komado/main/install.sh | bash
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then just type **`komado`**. Re-run that same command any time to update. It needs
|
|
51
|
+
`git`, **Node ≥ 20**, and `npm`; it installs to `~/.local/share/komado` with the
|
|
52
|
+
launcher in `~/.local/bin` (override via `KOMADO_APP_DIR` / `KOMADO_BIN_DIR`).
|
|
53
|
+
|
|
54
|
+
Uninstall: `rm -rf ~/.local/share/komado ~/.local/bin/komado` (add `~/.komado` to also
|
|
55
|
+
drop your saved progress and login).
|
|
56
|
+
|
|
57
|
+
### From source (for development)
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm install # also builds dist/ via the prepare script
|
|
61
|
+
npm start # rebuilds, then launches the reader
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The UI is JSX, transpiled to `dist/` by esbuild (`npm run build`). Check what your
|
|
65
|
+
terminal supports and where state is stored:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm run doctor
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Render a single image (path or URL) at the best fidelity your terminal allows — handy
|
|
72
|
+
for testing sixel/kitty terminals:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
node dist/cli.js render ./cover.jpg
|
|
76
|
+
node dist/cli.js render https://example.com/page.png 100
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Keys
|
|
80
|
+
|
|
81
|
+
| Context | Keys | Action |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| Lists | `↑`/`↓` or `j`/`k`, `g`/`G`, `PgUp`/`PgDn` | move / jump / page |
|
|
84
|
+
| Lists | `enter` | open · `/` focus search · `esc` back |
|
|
85
|
+
| Reader | `↑`/`↓` or `j`/`k` | scroll within a page |
|
|
86
|
+
| Reader | `←`/`→` or `a`/`d`, `space` | previous / next page |
|
|
87
|
+
| Reader | `N` / `P` | next / previous chapter |
|
|
88
|
+
| Reader | `f` | toggle fit-to-screen |
|
|
89
|
+
| Reader | `r` | cycle renderer (`auto` → `halfblock` → `chafa`) |
|
|
90
|
+
| Global | `q` | quit · `esc` back |
|
|
91
|
+
|
|
92
|
+
## Rendering quality
|
|
93
|
+
|
|
94
|
+
Readable text needs real pixels. At startup the app probes your terminal (run
|
|
95
|
+
`npm run doctor` to see the result):
|
|
96
|
+
|
|
97
|
+
- **sixel or kitty graphics supported** → opening a chapter launches a
|
|
98
|
+
full-resolution **pixel viewer** (chafa straight to the terminal). It renders
|
|
99
|
+
full-width with vertical pan by default; press `f` to toggle whole-page fit.
|
|
100
|
+
Keys: `←`/`→` or `a`/`d` page · `↑`/`↓` pan · `N`/`P` chapter · `f` fit · `q` back.
|
|
101
|
+
- **neither** → the in-Ink **cell reader** is used (Unicode half-blocks, or
|
|
102
|
+
chafa symbols when available). Fine for art, coarse for small lettering —
|
|
103
|
+
that's the hard ceiling of character-cell rendering.
|
|
104
|
+
|
|
105
|
+
Terminals with pixel support include kitty, WezTerm, Ghostty, foot, recent
|
|
106
|
+
Windows Terminal (≥ 1.22), and VS Code's terminal (with image support enabled).
|
|
107
|
+
|
|
108
|
+
## MangaDex account (optional)
|
|
109
|
+
|
|
110
|
+
Reading from MangaDex needs **no account**. Signing in only adds personalization:
|
|
111
|
+
|
|
112
|
+
- **My Library** — browse the manga you follow on MangaDex.
|
|
113
|
+
- **Read-markers** — chapters you've already read are marked in the chapter list.
|
|
114
|
+
- **Progress sync** — finishing a chapter pushes a read-marker back to MangaDex.
|
|
115
|
+
|
|
116
|
+
MangaDex uses OAuth2 **personal clients**, so logging in is a one-time setup:
|
|
117
|
+
|
|
118
|
+
1. On [mangadex.org](https://mangadex.org) → **Settings → API Clients**, create a
|
|
119
|
+
personal client and note its **client ID** and **client secret**. (A new client may
|
|
120
|
+
need staff approval before it works.)
|
|
121
|
+
2. In the app: **Settings → Log in to MangaDex…**, then enter the client id/secret plus
|
|
122
|
+
your MangaDex username and password.
|
|
123
|
+
|
|
124
|
+
The session is **durable** — it requests an `offline_access` token and persists it, so
|
|
125
|
+
you stay logged in across restarts until you explicitly **log out** (Settings). Only the
|
|
126
|
+
client id/secret + refresh token are stored, in `~/.komado/credentials.json` (mode
|
|
127
|
+
`600`); your password is never written to disk. Toggle write-back any time with
|
|
128
|
+
**Settings → Sync reading progress**.
|
|
129
|
+
|
|
130
|
+
## Local library layout
|
|
131
|
+
|
|
132
|
+
Add library folders in **Settings → Add library path…**. Within a library folder:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
My Library/
|
|
136
|
+
Berserk/ # folder = a manga
|
|
137
|
+
Chapter 1/ # image subfolder = a chapter
|
|
138
|
+
001.jpg 002.jpg …
|
|
139
|
+
Chapter 2.cbz # …or a .cbz/.zip = a chapter
|
|
140
|
+
One Shot.cbz # a standalone .cbz = a single-chapter manga
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Pages are ordered naturally (`2` before `10`). Both `.cbz`/`.zip` and `.cbr`/`.rar`
|
|
144
|
+
archives are supported (RAR via the WASM `node-unrar-js`, no system binary needed).
|
|
145
|
+
|
|
146
|
+
## Where state lives
|
|
147
|
+
|
|
148
|
+
Everything is self-contained under `~/.komado/` (override with `KOMADO_HOME`):
|
|
149
|
+
|
|
150
|
+
- `config.json` — preferences + library paths
|
|
151
|
+
- `progress.json` — reading progress
|
|
152
|
+
- `credentials.json` — MangaDex login (client id/secret + refresh token; mode `600`)
|
|
153
|
+
- `cache/` — scratch space for the renderer
|
|
154
|
+
|
|
155
|
+
## Architecture
|
|
156
|
+
|
|
157
|
+
The layering mirrors a route→controller→service→db backend, adapted for a TUI:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
cli.js → app.js (screen stack) → screens → hooks/state → sources/* → HTTP | filesystem
|
|
161
|
+
└──────────────→ render/* (image → terminal)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- **`src/sources/*`** — each source (`mangadex`, `local`) implements the same interface
|
|
165
|
+
(`search`, `getManga`, `listChapters`, `getPages`, `loadPageBuffer`) and returns the
|
|
166
|
+
unified `{ data, pagination, meta }` envelope. The only source-specific seam is
|
|
167
|
+
`loadPageBuffer`, which resolves raw bytes.
|
|
168
|
+
- **`src/domain/shape.js`** — the unified `Manga`/`Chapter` contract.
|
|
169
|
+
- **`src/render/*`** — capability detection + half-block (`sharp`) and `chafa` backends
|
|
170
|
+
behind one `renderInline()` dispatcher, with half-block as the always-works fallback.
|
|
171
|
+
- **`src/lib/*`** — cross-cutting utilities ported from a server toolkit:
|
|
172
|
+
`fetchWithBackoff` (retries 429/5xx + timeout), `createCache` (TTL + negative caching
|
|
173
|
+
+ stampede protection), `AppError`/typed errors, the response `envelope`.
|
|
174
|
+
- **`src/components/*` + `src/hooks/*`** — Ink UI. Effects use a cancelled-flag/abort
|
|
175
|
+
guard against out-of-order responses, and reading progress writes are debounced.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
UI is plain JSX in `.js` files, transpiled to `dist/` by esbuild (a small
|
|
180
|
+
`scripts/build.mjs` does a transpile-only, structure-preserving build — no bundling,
|
|
181
|
+
so package imports stay external).
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npm run build # src/ → dist/ (esbuild, JSX automatic runtime)
|
|
185
|
+
npm run lint # ESLint 9 (flat config, eslint-plugin-react + react-hooks)
|
|
186
|
+
npm test # Vitest: lib/source unit tests + an ink-testing-library UI test
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`npm test` covers the cache, backoff, envelope, natural sort, MangaDex normalization,
|
|
190
|
+
MangaDex auth (token refresh, offline-scope fallback, session handling) and the authed
|
|
191
|
+
source methods, the local source (folder + `.cbz` + `.cbr`), and an end-to-end UI walk
|
|
192
|
+
(home → local manga → reader) via ink-testing-library. The `.cbr` test is skipped
|
|
193
|
+
automatically where the `rar` binary isn't available.
|
|
194
|
+
|
|
195
|
+
## Roadmap
|
|
196
|
+
|
|
197
|
+
- True sixel/kitty fullscreen page view (beyond the standalone `render` command)
|
|
198
|
+
- On-disk page cache for offline re-reads of MangaDex chapters
|
|
199
|
+
|
|
200
|
+
## Legal
|
|
201
|
+
|
|
202
|
+
Uses the public MangaDex API for personal reading. Respect MangaDex’s
|
|
203
|
+
[terms](https://api.mangadex.org/docs/) and rate limits, and support official releases
|
|
204
|
+
where available.
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT
|
package/dist/app.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useCallback } from "react";
|
|
3
|
+
import { Box, useApp, useInput } from "ink";
|
|
4
|
+
import { UIContext } from "./ui-context.js";
|
|
5
|
+
import { useStdoutDimensions } from "./hooks/useStdoutDimensions.js";
|
|
6
|
+
import { flushProgress } from "./state/store.js";
|
|
7
|
+
import { HomeScreen } from "./components/screens/HomeScreen.js";
|
|
8
|
+
import { SearchScreen } from "./components/screens/SearchScreen.js";
|
|
9
|
+
import { MangaScreen } from "./components/screens/MangaScreen.js";
|
|
10
|
+
import { ReaderScreen } from "./components/screens/ReaderScreen.js";
|
|
11
|
+
import { SettingsScreen } from "./components/screens/SettingsScreen.js";
|
|
12
|
+
import { ContinueScreen } from "./components/screens/ContinueScreen.js";
|
|
13
|
+
import { LoginScreen } from "./components/screens/LoginScreen.js";
|
|
14
|
+
import { LibraryScreen } from "./components/screens/LibraryScreen.js";
|
|
15
|
+
const SCREENS = {
|
|
16
|
+
home: HomeScreen,
|
|
17
|
+
search: SearchScreen,
|
|
18
|
+
manga: MangaScreen,
|
|
19
|
+
reader: ReaderScreen,
|
|
20
|
+
settings: SettingsScreen,
|
|
21
|
+
continue: ContinueScreen,
|
|
22
|
+
login: LoginScreen,
|
|
23
|
+
library: LibraryScreen
|
|
24
|
+
};
|
|
25
|
+
function App({ caps = {}, onViewer, initialRoute = null }) {
|
|
26
|
+
const { exit } = useApp();
|
|
27
|
+
const dimensions = useStdoutDimensions();
|
|
28
|
+
const [stack, setStack] = useState(
|
|
29
|
+
initialRoute ? [{ name: "home", params: {} }, initialRoute] : [{ name: "home", params: {} }]
|
|
30
|
+
);
|
|
31
|
+
const [typing, setTyping] = useState(false);
|
|
32
|
+
const navigate = useCallback((name, params = {}) => setStack((s) => [...s, { name, params }]), []);
|
|
33
|
+
const goBack = useCallback(() => setStack((s) => s.length > 1 ? s.slice(0, -1) : s), []);
|
|
34
|
+
const replace = useCallback((name, params = {}) => setStack((s) => [...s.slice(0, -1), { name, params }]), []);
|
|
35
|
+
const quit = useCallback(() => {
|
|
36
|
+
flushProgress();
|
|
37
|
+
exit();
|
|
38
|
+
}, [exit]);
|
|
39
|
+
useInput((input, key) => {
|
|
40
|
+
if (typing) return;
|
|
41
|
+
if (input === "q") quit();
|
|
42
|
+
else if (key.escape) goBack();
|
|
43
|
+
});
|
|
44
|
+
const current = stack[stack.length - 1];
|
|
45
|
+
const Screen = SCREENS[current.name] || HomeScreen;
|
|
46
|
+
const openReader = useCallback(
|
|
47
|
+
(payload) => {
|
|
48
|
+
const pixel = onViewer && (caps.sixel || caps.kitty) && caps.chafa && !process.env.KOMADO_NO_PIXEL;
|
|
49
|
+
if (pixel) onViewer(payload);
|
|
50
|
+
else navigate("reader", payload);
|
|
51
|
+
},
|
|
52
|
+
[onViewer, caps, navigate]
|
|
53
|
+
);
|
|
54
|
+
const ctx = { navigate, goBack, replace, exit: quit, setTyping, dimensions, caps, openReader };
|
|
55
|
+
return /* @__PURE__ */ jsx(UIContext.Provider, { value: ctx, children: /* @__PURE__ */ jsx(Box, { flexDirection: "column", paddingX: 1, children: /* @__PURE__ */ jsx(Screen, { params: current.params }, `${stack.length}:${current.name}`) }) });
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
App
|
|
59
|
+
};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
function printHelp() {
|
|
5
|
+
console.log(`komado \u2014 a terminal manga reader (MangaDex + local files)
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
komado launch the interactive reader
|
|
9
|
+
komado doctor print terminal/image capabilities and config
|
|
10
|
+
komado render <img> render one image (path or URL) at best fidelity
|
|
11
|
+
komado --version print version
|
|
12
|
+
komado --help show this help
|
|
13
|
+
|
|
14
|
+
On a sixel/kitty terminal, opening a chapter launches a full-resolution pixel
|
|
15
|
+
viewer: \u2190/\u2192 or a/d page \xB7 \u2191/\u2193 pan \xB7 N/P chapter \xB7 f fit-width/whole-page \xB7 q back
|
|
16
|
+
|
|
17
|
+
Otherwise the in-terminal cell reader is used:
|
|
18
|
+
\u2191/\u2193 or j/k scroll \u2190/\u2192 or h/l prev/next page
|
|
19
|
+
space page down N / P next/prev chapter
|
|
20
|
+
f fit-to-screen r cycle renderer
|
|
21
|
+
g / G top / bottom esc back q quit
|
|
22
|
+
`);
|
|
23
|
+
}
|
|
24
|
+
async function doctor() {
|
|
25
|
+
const { detectCapabilities, probeTerminal } = await import("./render/detect.js");
|
|
26
|
+
const { paths, ensureDirs } = await import("./config.js");
|
|
27
|
+
const { getConfig } = await import("./state/store.js");
|
|
28
|
+
ensureDirs();
|
|
29
|
+
const caps = detectCapabilities();
|
|
30
|
+
const cfg = getConfig();
|
|
31
|
+
const probe = await probeTerminal();
|
|
32
|
+
console.log("komado doctor\n");
|
|
33
|
+
console.log("Terminal:");
|
|
34
|
+
console.log(` TERM=${caps.term} TERM_PROGRAM=${caps.termProgram || "(none)"}`);
|
|
35
|
+
console.log(` truecolor: ${caps.truecolor}`);
|
|
36
|
+
if (probe.queried) {
|
|
37
|
+
console.log(` kitty graphics: ${probe.kitty} (probed)`);
|
|
38
|
+
console.log(` sixel: ${probe.sixel} (probed)`);
|
|
39
|
+
if (probe.cellW || probe.cellH) {
|
|
40
|
+
console.log(` cell size: ${probe.cellW ?? "?"}x${probe.cellH ?? "?"} px`);
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
console.log(` kitty graphics: ${caps.kitty} (env guess \u2014 run in a real terminal to probe)`);
|
|
44
|
+
console.log(` sixel: ${caps.sixel} (env guess \u2014 run in a real terminal to probe)`);
|
|
45
|
+
}
|
|
46
|
+
console.log(` chafa: ${caps.chafa ? caps.chafaVersion : "not installed"}`);
|
|
47
|
+
console.log(` inline backend: ${caps.chafa ? "chafa-symbols" : "half-block"} (config.renderer=${cfg.renderer})`);
|
|
48
|
+
if (probe.queried) {
|
|
49
|
+
const protos = [probe.kitty && "kitty", probe.sixel && "sixel"].filter(Boolean);
|
|
50
|
+
console.log(
|
|
51
|
+
protos.length ? `
|
|
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 \u2014 rendering is limited to character cells."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
console.log("\nPaths:");
|
|
57
|
+
console.log(` home: ${paths.home}`);
|
|
58
|
+
console.log(` config: ${paths.configFile}`);
|
|
59
|
+
console.log(` progress: ${paths.progressFile}`);
|
|
60
|
+
console.log(` cache: ${paths.cacheDir}`);
|
|
61
|
+
console.log("\nConfig:");
|
|
62
|
+
console.log(` language: ${cfg.language}`);
|
|
63
|
+
console.log(` dataSaver: ${cfg.dataSaver}`);
|
|
64
|
+
console.log(` renderer: ${cfg.renderer}`);
|
|
65
|
+
console.log(` contentRating: ${cfg.contentRating.join(", ")}`);
|
|
66
|
+
console.log(` localLibraryPaths: ${cfg.localLibraryPaths.length ? cfg.localLibraryPaths.join(", ") : "(none \u2014 add in Settings)"}`);
|
|
67
|
+
}
|
|
68
|
+
async function renderCmd(target, rest) {
|
|
69
|
+
if (!target) {
|
|
70
|
+
console.error("usage: komado render <image-path-or-url> [width]");
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
const width = Number(rest[0]) || process.stdout.columns || 80;
|
|
74
|
+
let buf;
|
|
75
|
+
if (/^https?:/.test(target)) {
|
|
76
|
+
const { fetchWithBackoff } = await import("./lib/fetchWithBackoff.js");
|
|
77
|
+
const res = await fetchWithBackoff(target);
|
|
78
|
+
buf = Buffer.from(await res.arrayBuffer());
|
|
79
|
+
} else {
|
|
80
|
+
buf = await (await import("node:fs/promises")).readFile(target);
|
|
81
|
+
}
|
|
82
|
+
const { detectCapabilities } = await import("./render/detect.js");
|
|
83
|
+
const caps = detectCapabilities();
|
|
84
|
+
if (caps.chafa && process.stdout.isTTY) {
|
|
85
|
+
const os = await import("node:os");
|
|
86
|
+
const path = await import("node:path");
|
|
87
|
+
const { writeFile, unlink } = await import("node:fs/promises");
|
|
88
|
+
const sharp = (await import("sharp")).default;
|
|
89
|
+
const { imageSize } = await import("./render/image.js");
|
|
90
|
+
const { spawnChafaToTerminal } = await import("./render/chafa.js");
|
|
91
|
+
const { width: iw, height: ih } = await imageSize(buf);
|
|
92
|
+
const rows = Math.max(1, Math.round(ih / iw * width / 2));
|
|
93
|
+
const tmp = path.join(os.tmpdir(), `komado-render-${Date.now()}.png`);
|
|
94
|
+
await writeFile(tmp, await sharp(buf).png().toBuffer());
|
|
95
|
+
spawnChafaToTerminal(tmp, { cols: width, rows });
|
|
96
|
+
await unlink(tmp).catch(() => {
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
const { renderHalfBlock } = await import("./render/halfblock.js");
|
|
100
|
+
const out = await renderHalfBlock(buf, { cols: width });
|
|
101
|
+
process.stdout.write(out.lines.join("\n") + "\n");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function runApp() {
|
|
105
|
+
const { ensureDirs, paths } = await import("./config.js");
|
|
106
|
+
const { appendFileSync } = await import("node:fs");
|
|
107
|
+
ensureDirs();
|
|
108
|
+
if (!process.stdout.isTTY) {
|
|
109
|
+
console.error("komado needs an interactive terminal (TTY). Run it directly in your terminal.");
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
const logCrash = (label, err) => {
|
|
113
|
+
try {
|
|
114
|
+
appendFileSync(paths.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] ${label}: ${err?.stack || err}
|
|
115
|
+
`);
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const { detectCapabilities, probeTerminal } = await import("./render/detect.js");
|
|
120
|
+
const probed = process.env.KOMADO_FORCE_PIXEL ? { queried: true, sixel: true, kitty: false } : await probeTerminal();
|
|
121
|
+
const caps = { ...detectCapabilities(), ...probed };
|
|
122
|
+
const { render } = await import("ink");
|
|
123
|
+
const { App } = await import("./app.js");
|
|
124
|
+
const { runViewer } = await import("./sixel-reader.js");
|
|
125
|
+
const restore = () => process.stdout.write("\x1B[?25h\x1B[?1049l");
|
|
126
|
+
process.stdout.write("\x1B[?1049h\x1B[?25l");
|
|
127
|
+
process.on("exit", restore);
|
|
128
|
+
try {
|
|
129
|
+
let resumeRoute = null;
|
|
130
|
+
for (; ; ) {
|
|
131
|
+
let viewerRequest = null;
|
|
132
|
+
let instance;
|
|
133
|
+
const onViewer = (payload) => {
|
|
134
|
+
viewerRequest = payload;
|
|
135
|
+
setImmediate(() => {
|
|
136
|
+
try {
|
|
137
|
+
instance.unmount();
|
|
138
|
+
} catch (err) {
|
|
139
|
+
logCrash("unmount failed", err);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
instance = render(/* @__PURE__ */ jsx(App, { caps, onViewer, initialRoute: resumeRoute }), {
|
|
144
|
+
exitOnCtrlC: true
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
await instance.waitUntilExit();
|
|
148
|
+
} catch (err) {
|
|
149
|
+
logCrash("ink exited with error", err);
|
|
150
|
+
}
|
|
151
|
+
if (!viewerRequest) break;
|
|
152
|
+
process.stdin.resume();
|
|
153
|
+
try {
|
|
154
|
+
resumeRoute = await runViewer({ ...viewerRequest, caps });
|
|
155
|
+
} catch (err) {
|
|
156
|
+
logCrash("viewer crashed", err);
|
|
157
|
+
resumeRoute = { name: "manga", params: { sourceId: viewerRequest.sourceId, manga: viewerRequest.manga } };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
restore();
|
|
162
|
+
process.removeListener("exit", restore);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function main() {
|
|
166
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
167
|
+
switch (cmd) {
|
|
168
|
+
case "--help":
|
|
169
|
+
case "-h":
|
|
170
|
+
return printHelp();
|
|
171
|
+
case "--version":
|
|
172
|
+
case "-v": {
|
|
173
|
+
const { readFileSync } = await import("node:fs");
|
|
174
|
+
const url = new URL("../package.json", import.meta.url);
|
|
175
|
+
return console.log(JSON.parse(readFileSync(url, "utf8")).version);
|
|
176
|
+
}
|
|
177
|
+
case "doctor":
|
|
178
|
+
return doctor();
|
|
179
|
+
case "render":
|
|
180
|
+
return renderCmd(rest[0], rest.slice(1));
|
|
181
|
+
default:
|
|
182
|
+
return runApp();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
main().catch(async (err) => {
|
|
186
|
+
process.stdout.write("\x1B[?25h\x1B[?1049l");
|
|
187
|
+
try {
|
|
188
|
+
const { appendFileSync } = await import("node:fs");
|
|
189
|
+
const { paths } = await import("./config.js");
|
|
190
|
+
appendFileSync(paths.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] FATAL: ${err?.stack || err}
|
|
191
|
+
`);
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
console.error(err);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
function List({
|
|
5
|
+
items = [],
|
|
6
|
+
onSelect,
|
|
7
|
+
onHighlight,
|
|
8
|
+
renderItem,
|
|
9
|
+
height = 12,
|
|
10
|
+
isActive = true,
|
|
11
|
+
emptyText = "Nothing here yet."
|
|
12
|
+
}) {
|
|
13
|
+
const [index, setIndex] = useState(0);
|
|
14
|
+
const count = items.length;
|
|
15
|
+
const selected = count ? Math.min(index, count - 1) : 0;
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (count) onHighlight?.(items[selected], selected);
|
|
18
|
+
}, [selected, count]);
|
|
19
|
+
useInput((input, key) => {
|
|
20
|
+
if (!count) return;
|
|
21
|
+
if (key.downArrow || input === "j") setIndex(Math.min(count - 1, selected + 1));
|
|
22
|
+
else if (key.upArrow || input === "k") setIndex(Math.max(0, selected - 1));
|
|
23
|
+
else if (key.pageDown) setIndex(Math.min(count - 1, selected + height));
|
|
24
|
+
else if (key.pageUp) setIndex(Math.max(0, selected - height));
|
|
25
|
+
else if (input === "g") setIndex(0);
|
|
26
|
+
else if (input === "G") setIndex(count - 1);
|
|
27
|
+
else if (key.return) onSelect?.(items[selected], selected);
|
|
28
|
+
}, { isActive });
|
|
29
|
+
if (!count) {
|
|
30
|
+
return /* @__PURE__ */ jsx(Text, { dimColor: true, children: emptyText });
|
|
31
|
+
}
|
|
32
|
+
const start = Math.max(0, Math.min(selected - Math.floor(height / 2), Math.max(0, count - height)));
|
|
33
|
+
const slice = items.slice(start, start + height);
|
|
34
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
35
|
+
slice.map((item, i) => renderItem(item, start + i === selected, start + i)),
|
|
36
|
+
count > height ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` \xB7 ${selected + 1}/${count} \xB7` }, "more") : null
|
|
37
|
+
] });
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
List
|
|
41
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { useUI } from "../../ui-context.js";
|
|
5
|
+
import { getSource } from "../../sources/index.js";
|
|
6
|
+
import { getAllProgress } from "../../state/store.js";
|
|
7
|
+
import { makeManga } from "../../domain/shape.js";
|
|
8
|
+
import { List } from "../List.js";
|
|
9
|
+
import { Header, Spinner, ErrorView, KeyHints } from "../ui.js";
|
|
10
|
+
import { truncate, relativeTime } from "../../lib/text.js";
|
|
11
|
+
function ContinueScreen() {
|
|
12
|
+
const ui = useUI();
|
|
13
|
+
const entries = getAllProgress();
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
const open = async (entry) => {
|
|
17
|
+
const source = getSource(entry.source);
|
|
18
|
+
setLoading(true);
|
|
19
|
+
setError(null);
|
|
20
|
+
try {
|
|
21
|
+
const [manga, chRes] = await Promise.all([
|
|
22
|
+
source.getManga(entry.mangaId).catch(() => makeManga({ source: entry.source, id: entry.mangaId, title: entry.mangaTitle })),
|
|
23
|
+
source.listChapters(entry.mangaId, { limit: 500 })
|
|
24
|
+
]);
|
|
25
|
+
const idx = chRes.data.findIndex((c) => c.id === entry.chapterId);
|
|
26
|
+
ui.openReader({
|
|
27
|
+
sourceId: entry.source,
|
|
28
|
+
manga,
|
|
29
|
+
chapters: chRes.data,
|
|
30
|
+
chapterIndex: Math.max(0, idx),
|
|
31
|
+
startPage: entry.page || 0
|
|
32
|
+
});
|
|
33
|
+
} catch (err) {
|
|
34
|
+
setError(err);
|
|
35
|
+
} finally {
|
|
36
|
+
setLoading(false);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
if (loading) {
|
|
40
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
41
|
+
/* @__PURE__ */ jsx(Header, { title: "Continue reading" }),
|
|
42
|
+
/* @__PURE__ */ jsx(Spinner, { label: "Opening" })
|
|
43
|
+
] });
|
|
44
|
+
}
|
|
45
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
46
|
+
/* @__PURE__ */ jsx(Header, { title: "Continue reading", subtitle: "pick up where you left off" }),
|
|
47
|
+
error ? /* @__PURE__ */ jsx(ErrorView, { error }) : null,
|
|
48
|
+
/* @__PURE__ */ jsx(
|
|
49
|
+
List,
|
|
50
|
+
{
|
|
51
|
+
items: entries,
|
|
52
|
+
height: Math.max(5, (ui.dimensions.rows || 24) - 7),
|
|
53
|
+
onSelect: open,
|
|
54
|
+
emptyText: "No reading history yet.",
|
|
55
|
+
renderItem: (e, active) => /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
|
|
56
|
+
/* @__PURE__ */ jsx(Text, { inverse: active, color: active ? "cyanBright" : void 0, children: ` ${truncate(e.mangaTitle || e.mangaId, 40)} \xB7 ${e.chapterNumber != null ? `Ch.${e.chapterNumber}` : "Oneshot"} p.${(e.page || 0) + 1} ` }),
|
|
57
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: relativeTime(e.updatedAt) })
|
|
58
|
+
] }, `${e.source}:${e.mangaId}`)
|
|
59
|
+
}
|
|
60
|
+
),
|
|
61
|
+
/* @__PURE__ */ jsx(KeyHints, { hints: [["\u2191\u2193", "move"], ["enter", "resume"], ["esc", "back"]] })
|
|
62
|
+
] });
|
|
63
|
+
}
|
|
64
|
+
export {
|
|
65
|
+
ContinueScreen
|
|
66
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { useUI } from "../../ui-context.js";
|
|
4
|
+
import { List } from "../List.js";
|
|
5
|
+
import { Header, KeyHints } from "../ui.js";
|
|
6
|
+
import { getAllProgress } from "../../state/store.js";
|
|
7
|
+
import { isLoggedIn } from "../../sources/mangadex/auth.js";
|
|
8
|
+
function HomeScreen() {
|
|
9
|
+
const ui = useUI();
|
|
10
|
+
const hasProgress = getAllProgress().length > 0;
|
|
11
|
+
const items = [
|
|
12
|
+
{ id: "search", label: "Search MangaDex", hint: "online catalog" },
|
|
13
|
+
{ id: "browse", label: "Popular on MangaDex", hint: "most followed" },
|
|
14
|
+
...isLoggedIn() ? [{ id: "library", label: "My Library (MangaDex)", hint: "your follows" }] : [],
|
|
15
|
+
{ id: "local", label: "Local library", hint: "your CBZ / folders" },
|
|
16
|
+
...hasProgress ? [{ id: "continue", label: "Continue reading", hint: "resume" }] : [],
|
|
17
|
+
{ id: "settings", label: "Settings", hint: "config & library paths" },
|
|
18
|
+
{ id: "quit", label: "Quit", hint: "" }
|
|
19
|
+
];
|
|
20
|
+
const onSelect = (item) => {
|
|
21
|
+
switch (item.id) {
|
|
22
|
+
case "search":
|
|
23
|
+
return ui.navigate("search", { sourceId: "mangadex", mode: "search" });
|
|
24
|
+
case "browse":
|
|
25
|
+
return ui.navigate("search", { sourceId: "mangadex", mode: "browse" });
|
|
26
|
+
case "library":
|
|
27
|
+
return ui.navigate("library", { sourceId: "mangadex" });
|
|
28
|
+
case "local":
|
|
29
|
+
return ui.navigate("search", { sourceId: "local", mode: "browse" });
|
|
30
|
+
case "continue":
|
|
31
|
+
return ui.navigate("continue");
|
|
32
|
+
case "settings":
|
|
33
|
+
return ui.navigate("settings");
|
|
34
|
+
case "quit":
|
|
35
|
+
return ui.exit();
|
|
36
|
+
default:
|
|
37
|
+
return void 0;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
41
|
+
/* @__PURE__ */ jsx(Header, { title: "komado", subtitle: "a terminal manga reader \xB7 MangaDex + local files" }),
|
|
42
|
+
/* @__PURE__ */ jsx(
|
|
43
|
+
List,
|
|
44
|
+
{
|
|
45
|
+
items,
|
|
46
|
+
height: items.length,
|
|
47
|
+
onSelect,
|
|
48
|
+
renderItem: (item, active) => /* @__PURE__ */ jsxs(Box, { children: [
|
|
49
|
+
/* @__PURE__ */ jsx(Text, { inverse: active, color: active ? "cyanBright" : void 0, children: ` ${active ? "\u203A" : " "} ${item.label} ` }),
|
|
50
|
+
item.hint ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: ` ${item.hint}` }) : null
|
|
51
|
+
] }, item.id)
|
|
52
|
+
}
|
|
53
|
+
),
|
|
54
|
+
/* @__PURE__ */ jsx(KeyHints, { hints: [["\u2191\u2193", "move"], ["enter", "select"], ["q", "quit"]] })
|
|
55
|
+
] });
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
HomeScreen
|
|
59
|
+
};
|