komado 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -4
- package/dist/components/screens/ReaderScreen.js +6 -6
- package/dist/components/screens/SettingsScreen.js +35 -3
- package/dist/sixel-reader.js +6 -9
- package/dist/state/store.js +9 -0
- package/dist/uninstall.js +58 -0
- package/package.json +16 -3
- package/src/components/screens/ReaderScreen.js +6 -6
- package/src/components/screens/SettingsScreen.js +39 -2
- package/src/sixel-reader.js +12 -11
- package/src/state/store.js +12 -0
- package/src/uninstall.js +75 -0
package/README.md
CHANGED
|
@@ -41,7 +41,8 @@ with [Ink](https://github.com/vadimdemedes/ink).
|
|
|
41
41
|
|
|
42
42
|
## Install
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
**macOS / Linux** — one line fetches the latest, builds it, and drops a `komado`
|
|
45
|
+
launcher on your PATH:
|
|
45
46
|
|
|
46
47
|
```bash
|
|
47
48
|
curl -fsSL https://raw.githubusercontent.com/RyuPrad/komado/main/install.sh | bash
|
|
@@ -51,8 +52,26 @@ Then just type **`komado`**. Re-run that same command any time to update. It nee
|
|
|
51
52
|
`git`, **Node ≥ 20**, and `npm`; it installs to `~/.local/share/komado` with the
|
|
52
53
|
launcher in `~/.local/bin` (override via `KOMADO_APP_DIR` / `KOMADO_BIN_DIR`).
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
**Windows** — the PowerShell one-liner installs Node.js for you (via `winget`) if it's
|
|
56
|
+
missing, then komado, leaving a native `komado` command on your `PATH`:
|
|
57
|
+
|
|
58
|
+
```powershell
|
|
59
|
+
irm https://raw.githubusercontent.com/RyuPrad/komado/main/install.ps1 | iex
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Already have **Node ≥ 20**? Install straight from PowerShell or CMD instead:
|
|
63
|
+
|
|
64
|
+
```powershell
|
|
65
|
+
npm i -g komado
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Don't use the `curl … | bash` line on Windows — under **Git Bash** or **WSL** it
|
|
69
|
+
installs a launcher that only runs inside that shell, never from CMD/PowerShell.
|
|
70
|
+
|
|
71
|
+
Uninstall from inside the app — **Settings → Uninstall komado…** (type `uninstall` to
|
|
72
|
+
confirm) — removes the app, its launcher, and `~/.komado` (config, reading progress,
|
|
73
|
+
MangaDex login). By hand that's
|
|
74
|
+
`rm -rf ~/.local/share/komado ~/.local/bin/komado ~/.komado`.
|
|
56
75
|
|
|
57
76
|
### From source (for development)
|
|
58
77
|
|
|
@@ -97,7 +116,7 @@ Readable text needs real pixels. At startup the app probes your terminal (run
|
|
|
97
116
|
- **sixel or kitty graphics supported** → opening a chapter launches a
|
|
98
117
|
full-resolution **pixel viewer** (chafa straight to the terminal). It renders
|
|
99
118
|
full-width with vertical pan by default; press `f` to toggle whole-page fit.
|
|
100
|
-
Keys: `←`/`→` or `a`/`d` page · `↑`/`↓` pan · `
|
|
119
|
+
Keys: `←`/`→` or `a`/`d` page · `↑`/`↓` pan · `n`/`p` chapter · `f` fit · `q` back.
|
|
101
120
|
- **neither** → the in-Ink **cell reader** is used (Unicode half-blocks, or
|
|
102
121
|
chafa symbols when available). Fine for art, coarse for small lettering —
|
|
103
122
|
that's the hard ceiling of character-cell rendering.
|
|
@@ -155,12 +155,12 @@ function ReaderScreen({ params }) {
|
|
|
155
155
|
if (scroll >= maxScroll) nextPage();
|
|
156
156
|
else setScroll((s) => Math.min(maxScroll, s + viewportRows - 1));
|
|
157
157
|
} else if (key.pageUp) setScroll((s) => Math.max(0, s - (viewportRows - 1)));
|
|
158
|
-
else if (key.rightArrow || input === "l"
|
|
159
|
-
else if (key.leftArrow || input === "h"
|
|
158
|
+
else if (key.rightArrow || input === "l") nextPage();
|
|
159
|
+
else if (key.leftArrow || input === "h") prevPage();
|
|
160
160
|
else if (input === "g") setScroll(0);
|
|
161
161
|
else if (input === "G") setScroll(maxScroll);
|
|
162
|
-
else if (input === "N") changeChapter(1);
|
|
163
|
-
else if (input === "P") changeChapter(-1);
|
|
162
|
+
else if (input === "n" || input === "N") changeChapter(1);
|
|
163
|
+
else if (input === "p" || input === "P") changeChapter(-1);
|
|
164
164
|
else if (input === "f") setFitMode((f) => !f);
|
|
165
165
|
else if (input === "r") cycleRenderer();
|
|
166
166
|
});
|
|
@@ -173,7 +173,7 @@ function ReaderScreen({ params }) {
|
|
|
173
173
|
] }),
|
|
174
174
|
/* @__PURE__ */ jsx(Box, { height: viewportRows, flexDirection: "column", children: status === "loading" ? /* @__PURE__ */ jsx(Spinner, { label: pages ? `Rendering page ${pageIndex + 1}` : "Loading chapter" }) : status === "error" ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
175
175
|
/* @__PURE__ */ jsx(ErrorView, { error }),
|
|
176
|
-
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press
|
|
176
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "Press n / p to skip to another chapter, or Esc to go back." })
|
|
177
177
|
] }) : slice.map((ln, i) => /* @__PURE__ */ jsx(Text, { wrap: "truncate-end", children: ln }, scroll + i)) }),
|
|
178
178
|
/* @__PURE__ */ jsx(
|
|
179
179
|
KeyHints,
|
|
@@ -181,7 +181,7 @@ function ReaderScreen({ params }) {
|
|
|
181
181
|
hints: [
|
|
182
182
|
["\u2190\u2192", "page"],
|
|
183
183
|
["\u2191\u2193", "scroll"],
|
|
184
|
-
["
|
|
184
|
+
["n/p", "chapter"],
|
|
185
185
|
["f", fitMode ? "scroll" : "fit"],
|
|
186
186
|
["r", `render:${rendererPref}`],
|
|
187
187
|
["esc", "back"]
|
|
@@ -10,6 +10,7 @@ import { detectCapabilities } from "../../render/detect.js";
|
|
|
10
10
|
import { List } from "../List.js";
|
|
11
11
|
import { Header, KeyHints } from "../ui.js";
|
|
12
12
|
import { truncate } from "../../lib/text.js";
|
|
13
|
+
import { performUninstall, uninstallTargets, displayPath, formatUninstallSummary } from "../../uninstall.js";
|
|
13
14
|
const RENDERERS = ["auto", "halfblock", "chafa"];
|
|
14
15
|
const RATING_PRESETS = [
|
|
15
16
|
["safe"],
|
|
@@ -52,7 +53,8 @@ function SettingsScreen() {
|
|
|
52
53
|
pathIndex: i,
|
|
53
54
|
label: `Library: ${truncate(p, 48)}`,
|
|
54
55
|
value: "d to remove"
|
|
55
|
-
}))
|
|
56
|
+
})),
|
|
57
|
+
{ id: "uninstall", kind: "danger", label: "Uninstall komado\u2026", value: "removes the app + all data" }
|
|
56
58
|
];
|
|
57
59
|
const activate = (item) => {
|
|
58
60
|
switch (item.kind) {
|
|
@@ -78,11 +80,27 @@ function SettingsScreen() {
|
|
|
78
80
|
case "action":
|
|
79
81
|
setDraft("");
|
|
80
82
|
return setEditing("addPath");
|
|
83
|
+
case "danger":
|
|
84
|
+
setDraft("");
|
|
85
|
+
return setEditing("uninstall");
|
|
81
86
|
default:
|
|
82
87
|
return void 0;
|
|
83
88
|
}
|
|
84
89
|
};
|
|
85
90
|
const submitEdit = () => {
|
|
91
|
+
if (editing === "uninstall") {
|
|
92
|
+
if (draft.trim().toLowerCase() === "uninstall") {
|
|
93
|
+
const results = performUninstall();
|
|
94
|
+
process.once("exit", () => process.stdout.write(`
|
|
95
|
+
${formatUninstallSummary(results)}
|
|
96
|
+
`));
|
|
97
|
+
ui.exit();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
setEditing(null);
|
|
101
|
+
setDraft("");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
86
104
|
if (editing === "language") {
|
|
87
105
|
save({ language: draft.trim() || "en" });
|
|
88
106
|
} else if (editing === "addPath" && draft.trim()) {
|
|
@@ -113,7 +131,21 @@ function SettingsScreen() {
|
|
|
113
131
|
subtitle: `chafa: ${caps.chafa ? caps.chafaVersion : "not installed"} \xB7 backend: ${caps.chafa ? "chafa-symbols" : "half-block"}`
|
|
114
132
|
}
|
|
115
133
|
),
|
|
116
|
-
editing ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
134
|
+
editing === "uninstall" ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
135
|
+
/* @__PURE__ */ jsx(Text, { color: "redBright", bold: true, children: "\u26A0 Uninstall komado" }),
|
|
136
|
+
/* @__PURE__ */ jsx(Text, { children: "This permanently deletes:" }),
|
|
137
|
+
uninstallTargets().map((t) => /* @__PURE__ */ jsxs(Text, { color: "red", children: [
|
|
138
|
+
` \u2022 ${displayPath(t.path)}`,
|
|
139
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: ` \u2014 ${t.label}` })
|
|
140
|
+
] }, t.path)),
|
|
141
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
|
|
142
|
+
/* @__PURE__ */ jsx(Text, { children: "Type " }),
|
|
143
|
+
/* @__PURE__ */ jsx(Text, { color: "redBright", bold: true, children: "uninstall" }),
|
|
144
|
+
/* @__PURE__ */ jsx(Text, { children: " to confirm: " }),
|
|
145
|
+
/* @__PURE__ */ jsx(TextInput, { value: draft, onChange: setDraft, onSubmit: submitEdit, focus: true })
|
|
146
|
+
] }),
|
|
147
|
+
/* @__PURE__ */ jsx(KeyHints, { hints: [["enter", "confirm"], ["esc", "cancel"]] })
|
|
148
|
+
] }) : editing ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
117
149
|
/* @__PURE__ */ jsx(Text, { color: "cyanBright", children: editing === "addPath" ? "New library path (folder of manga / CBZ):" : "Language code (e.g. en, fr, ja):" }),
|
|
118
150
|
/* @__PURE__ */ jsxs(Box, { children: [
|
|
119
151
|
/* @__PURE__ */ jsx(Text, { color: "cyanBright", children: "\u203A " }),
|
|
@@ -130,7 +162,7 @@ function SettingsScreen() {
|
|
|
130
162
|
onSelect: activate,
|
|
131
163
|
onHighlight: (it) => setHighlighted(it),
|
|
132
164
|
renderItem: (it, active) => /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
|
|
133
|
-
/* @__PURE__ */ jsx(Text, { inverse: active, color: active ? "cyanBright" : it.kind === "path" ? "blue" : void 0, children: ` ${it.label} ` }),
|
|
165
|
+
/* @__PURE__ */ jsx(Text, { inverse: active, color: active ? "cyanBright" : it.kind === "danger" ? "red" : it.kind === "path" ? "blue" : void 0, children: ` ${it.label} ` }),
|
|
134
166
|
it.value ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: it.value }) : null
|
|
135
167
|
] }, it.id)
|
|
136
168
|
}
|
package/dist/sixel-reader.js
CHANGED
|
@@ -68,7 +68,7 @@ async function runViewer({ sourceId, manga, chapters, chapterIndex, startPage =
|
|
|
68
68
|
function statusBar() {
|
|
69
69
|
const { cols } = size();
|
|
70
70
|
const left = `${manga.title} \xB7 ${chapterLabel(chapters[ci])} \xB7 ${pi + 1}/${pages ? pages.length : "?"}${fitWidth ? "" : " \xB7 fit"}`;
|
|
71
|
-
const right = "\u2190\u2192 page \xB7 \u2191\u2193 pan \xB7
|
|
71
|
+
const right = "\u2190\u2192 page \xB7 \u2191\u2193 pan \xB7 n/p ch \xB7 f fit \xB7 q back";
|
|
72
72
|
const gap = Math.max(1, cols - left.length - right.length - 2);
|
|
73
73
|
return ` ${left}${" ".repeat(gap)}${right} `.slice(0, cols);
|
|
74
74
|
}
|
|
@@ -152,7 +152,7 @@ async function runViewer({ sourceId, manga, chapters, chapterIndex, startPage =
|
|
|
152
152
|
chapterNumber: chapters[ci].number,
|
|
153
153
|
page: pi
|
|
154
154
|
});
|
|
155
|
-
if (pi === pages.length - 1 && source.syncChapterRead) {
|
|
155
|
+
if (pages && pi === pages.length - 1 && source.syncChapterRead) {
|
|
156
156
|
source.syncChapterRead(manga.id, chapters[ci].id);
|
|
157
157
|
}
|
|
158
158
|
} catch (err) {
|
|
@@ -160,7 +160,7 @@ async function runViewer({ sourceId, manga, chapters, chapterIndex, startPage =
|
|
|
160
160
|
stdout.write(`${ESC}[2J${ESC}[H${ESC}[0m`);
|
|
161
161
|
stdout.write(`Error: ${err.message}\r
|
|
162
162
|
\r
|
|
163
|
-
|
|
163
|
+
n/p chapter \xB7 q back\r
|
|
164
164
|
`);
|
|
165
165
|
}
|
|
166
166
|
}
|
|
@@ -250,12 +250,9 @@ N/P chapter \xB7 q back\r
|
|
|
250
250
|
resolve();
|
|
251
251
|
return;
|
|
252
252
|
}
|
|
253
|
-
if (k === "
|
|
253
|
+
if (k === " ") {
|
|
254
254
|
if (fitWidth && scroll < maxScroll) scroll = Math.min(maxScroll, scroll + pageStep);
|
|
255
255
|
else nextPage();
|
|
256
|
-
} else if (k === "p") {
|
|
257
|
-
if (fitWidth && scroll > 0) scroll = Math.max(0, scroll - pageStep);
|
|
258
|
-
else prevPage();
|
|
259
256
|
} else if (k === "d" || k === `${ESC}[C`) {
|
|
260
257
|
nextPage();
|
|
261
258
|
} else if (k === "a" || k === `${ESC}[D`) {
|
|
@@ -264,9 +261,9 @@ N/P chapter \xB7 q back\r
|
|
|
264
261
|
scroll = Math.min(maxScroll, scroll + scrollStep);
|
|
265
262
|
} else if (k === "k" || k === `${ESC}[A`) {
|
|
266
263
|
scroll = Math.max(0, scroll - scrollStep);
|
|
267
|
-
} else if (k === "N") {
|
|
264
|
+
} else if (k === "n" || k === "N") {
|
|
268
265
|
changeChapter(1);
|
|
269
|
-
} else if (k === "P") {
|
|
266
|
+
} else if (k === "p" || k === "P") {
|
|
270
267
|
changeChapter(-1);
|
|
271
268
|
} else if (k === "f") {
|
|
272
269
|
fitWidth = !fitWidth;
|
package/dist/state/store.js
CHANGED
|
@@ -9,7 +9,13 @@ function readJson(file, fallback) {
|
|
|
9
9
|
return fallback;
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
let persistenceDisabled = false;
|
|
13
|
+
function disablePersistence() {
|
|
14
|
+
persistenceDisabled = true;
|
|
15
|
+
clearTimeout(saveTimer);
|
|
16
|
+
}
|
|
12
17
|
function writeJsonAtomic(file, data) {
|
|
18
|
+
if (persistenceDisabled) return;
|
|
13
19
|
ensureDirs();
|
|
14
20
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
15
21
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
@@ -32,6 +38,7 @@ function loadProgress() {
|
|
|
32
38
|
}
|
|
33
39
|
let saveTimer = null;
|
|
34
40
|
function scheduleSave() {
|
|
41
|
+
if (persistenceDisabled) return;
|
|
35
42
|
clearTimeout(saveTimer);
|
|
36
43
|
saveTimer = setTimeout(() => writeJsonAtomic(paths.progressFile, progress), 400);
|
|
37
44
|
saveTimer.unref?.();
|
|
@@ -52,6 +59,7 @@ function flushProgress() {
|
|
|
52
59
|
}
|
|
53
60
|
let credentials = null;
|
|
54
61
|
function writeCredentialsAtomic(data) {
|
|
62
|
+
if (persistenceDisabled) return;
|
|
55
63
|
ensureDirs();
|
|
56
64
|
const file = paths.credentialsFile;
|
|
57
65
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
@@ -80,6 +88,7 @@ function clearCredentials() {
|
|
|
80
88
|
}
|
|
81
89
|
export {
|
|
82
90
|
clearCredentials,
|
|
91
|
+
disablePersistence,
|
|
83
92
|
flushProgress,
|
|
84
93
|
getAllProgress,
|
|
85
94
|
getConfig,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { paths } from "./config.js";
|
|
5
|
+
import { disablePersistence } from "./state/store.js";
|
|
6
|
+
function appDir() {
|
|
7
|
+
if (process.env.KOMADO_APP_DIR) return path.resolve(process.env.KOMADO_APP_DIR);
|
|
8
|
+
const share = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
|
9
|
+
return path.join(share, "komado");
|
|
10
|
+
}
|
|
11
|
+
function launcherFile() {
|
|
12
|
+
const bin = process.env.KOMADO_BIN_DIR ? path.resolve(process.env.KOMADO_BIN_DIR) : path.join(os.homedir(), ".local", "bin");
|
|
13
|
+
return path.join(bin, "komado");
|
|
14
|
+
}
|
|
15
|
+
function uninstallTargets() {
|
|
16
|
+
return [
|
|
17
|
+
{ label: "application files", path: appDir() },
|
|
18
|
+
{ label: "launcher", path: launcherFile() },
|
|
19
|
+
{ label: "config, reading progress & MangaDex login", path: paths.home }
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
function displayPath(p) {
|
|
23
|
+
const home = os.homedir();
|
|
24
|
+
if (p === home) return "~";
|
|
25
|
+
return p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
|
|
26
|
+
}
|
|
27
|
+
function assertSafe(p) {
|
|
28
|
+
const resolved = path.resolve(p);
|
|
29
|
+
if (!resolved || resolved === path.parse(resolved).root || resolved === os.homedir()) {
|
|
30
|
+
throw new Error(`refusing to delete unsafe path: ${resolved}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function performUninstall() {
|
|
34
|
+
disablePersistence();
|
|
35
|
+
return uninstallTargets().map((t) => {
|
|
36
|
+
try {
|
|
37
|
+
assertSafe(t.path);
|
|
38
|
+
const existed = fs.existsSync(t.path);
|
|
39
|
+
fs.rmSync(t.path, { recursive: true, force: true });
|
|
40
|
+
return { ...t, status: existed ? "removed" : "absent" };
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return { ...t, status: "failed", error: err.message };
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function formatUninstallSummary(results) {
|
|
47
|
+
const ok = results.every((r) => r.status !== "failed");
|
|
48
|
+
const tag = { removed: "removed", absent: "absent ", failed: "FAILED " };
|
|
49
|
+
const lines = results.map((r) => ` ${tag[r.status]} ${displayPath(r.path)}${r.error ? ` (${r.error})` : ""}`);
|
|
50
|
+
const head = ok ? "komado has been uninstalled." : "komado uninstall finished with errors.";
|
|
51
|
+
return [head, "", ...lines, ""].join("\n");
|
|
52
|
+
}
|
|
53
|
+
export {
|
|
54
|
+
displayPath,
|
|
55
|
+
formatUninstallSummary,
|
|
56
|
+
performUninstall,
|
|
57
|
+
uninstallTargets
|
|
58
|
+
};
|
package/package.json
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "komado",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "A terminal manga reader (MangaDex + local files) rendered with Ink.",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"manga",
|
|
7
|
+
"mangadex",
|
|
8
|
+
"reader",
|
|
9
|
+
"terminal",
|
|
10
|
+
"tui",
|
|
11
|
+
"cli",
|
|
12
|
+
"sixel",
|
|
13
|
+
"cbz",
|
|
14
|
+
"comic"
|
|
15
|
+
],
|
|
6
16
|
"type": "module",
|
|
7
17
|
"bin": {
|
|
8
18
|
"komado": "dist/cli.js"
|
|
@@ -26,7 +36,10 @@
|
|
|
26
36
|
"node": ">=20"
|
|
27
37
|
},
|
|
28
38
|
"license": "MIT",
|
|
29
|
-
"repository": {
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/RyuPrad/komado.git"
|
|
42
|
+
},
|
|
30
43
|
"homepage": "https://github.com/RyuPrad/komado#readme",
|
|
31
44
|
"bugs": "https://github.com/RyuPrad/komado/issues",
|
|
32
45
|
"dependencies": {
|
|
@@ -179,12 +179,12 @@ export function ReaderScreen({ params }) {
|
|
|
179
179
|
if (scroll >= maxScroll) nextPage();
|
|
180
180
|
else setScroll((s) => Math.min(maxScroll, s + viewportRows - 1));
|
|
181
181
|
} else if (key.pageUp) setScroll((s) => Math.max(0, s - (viewportRows - 1)));
|
|
182
|
-
else if (key.rightArrow || input === 'l'
|
|
183
|
-
else if (key.leftArrow || input === 'h'
|
|
182
|
+
else if (key.rightArrow || input === 'l') nextPage();
|
|
183
|
+
else if (key.leftArrow || input === 'h') prevPage();
|
|
184
184
|
else if (input === 'g') setScroll(0);
|
|
185
185
|
else if (input === 'G') setScroll(maxScroll);
|
|
186
|
-
else if (input === 'N') changeChapter(1);
|
|
187
|
-
else if (input === 'P') changeChapter(-1);
|
|
186
|
+
else if (input === 'n' || input === 'N') changeChapter(1);
|
|
187
|
+
else if (input === 'p' || input === 'P') changeChapter(-1);
|
|
188
188
|
else if (input === 'f') setFitMode((f) => !f);
|
|
189
189
|
else if (input === 'r') cycleRenderer();
|
|
190
190
|
});
|
|
@@ -206,7 +206,7 @@ export function ReaderScreen({ params }) {
|
|
|
206
206
|
) : status === 'error' ? (
|
|
207
207
|
<Box flexDirection="column">
|
|
208
208
|
<ErrorView error={error} />
|
|
209
|
-
<Text dimColor>Press
|
|
209
|
+
<Text dimColor>Press n / p to skip to another chapter, or Esc to go back.</Text>
|
|
210
210
|
</Box>
|
|
211
211
|
) : (
|
|
212
212
|
slice.map((ln, i) => (
|
|
@@ -219,7 +219,7 @@ export function ReaderScreen({ params }) {
|
|
|
219
219
|
hints={[
|
|
220
220
|
['←→', 'page'],
|
|
221
221
|
['↑↓', 'scroll'],
|
|
222
|
-
['
|
|
222
|
+
['n/p', 'chapter'],
|
|
223
223
|
['f', fitMode ? 'scroll' : 'fit'],
|
|
224
224
|
['r', `render:${rendererPref}`],
|
|
225
225
|
['esc', 'back'],
|
|
@@ -9,6 +9,7 @@ import { detectCapabilities } from '../../render/detect.js';
|
|
|
9
9
|
import { List } from '../List.js';
|
|
10
10
|
import { Header, KeyHints } from '../ui.js';
|
|
11
11
|
import { truncate } from '../../lib/text.js';
|
|
12
|
+
import { performUninstall, uninstallTargets, displayPath, formatUninstallSummary } from '../../uninstall.js';
|
|
12
13
|
|
|
13
14
|
const RENDERERS = ['auto', 'halfblock', 'chafa'];
|
|
14
15
|
const RATING_PRESETS = [
|
|
@@ -50,6 +51,7 @@ export function SettingsScreen() {
|
|
|
50
51
|
...cfg.localLibraryPaths.map((p, i) => ({
|
|
51
52
|
id: `path:${i}`, kind: 'path', pathIndex: i, label: `Library: ${truncate(p, 48)}`, value: 'd to remove',
|
|
52
53
|
})),
|
|
54
|
+
{ id: 'uninstall', kind: 'danger', label: 'Uninstall komado…', value: 'removes the app + all data' },
|
|
53
55
|
];
|
|
54
56
|
|
|
55
57
|
const activate = (item) => {
|
|
@@ -73,12 +75,29 @@ export function SettingsScreen() {
|
|
|
73
75
|
case 'action':
|
|
74
76
|
setDraft('');
|
|
75
77
|
return setEditing('addPath');
|
|
78
|
+
case 'danger':
|
|
79
|
+
setDraft('');
|
|
80
|
+
return setEditing('uninstall');
|
|
76
81
|
default:
|
|
77
82
|
return undefined;
|
|
78
83
|
}
|
|
79
84
|
};
|
|
80
85
|
|
|
81
86
|
const submitEdit = () => {
|
|
87
|
+
if (editing === 'uninstall') {
|
|
88
|
+
// Only the exact word commits. performUninstall() disables persistence and
|
|
89
|
+
// deletes everything; we register a goodbye to print AFTER Ink tears the
|
|
90
|
+
// alt-screen down (cli.js's restore runs first), then quit.
|
|
91
|
+
if (draft.trim().toLowerCase() === 'uninstall') {
|
|
92
|
+
const results = performUninstall();
|
|
93
|
+
process.once('exit', () => process.stdout.write(`\n${formatUninstallSummary(results)}\n`));
|
|
94
|
+
ui.exit();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setEditing(null);
|
|
98
|
+
setDraft('');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
82
101
|
if (editing === 'language') {
|
|
83
102
|
save({ language: draft.trim() || 'en' });
|
|
84
103
|
} else if (editing === 'addPath' && draft.trim()) {
|
|
@@ -110,7 +129,25 @@ export function SettingsScreen() {
|
|
|
110
129
|
subtitle={`chafa: ${caps.chafa ? caps.chafaVersion : 'not installed'} · backend: ${caps.chafa ? 'chafa-symbols' : 'half-block'}`}
|
|
111
130
|
/>
|
|
112
131
|
|
|
113
|
-
{editing ? (
|
|
132
|
+
{editing === 'uninstall' ? (
|
|
133
|
+
<Box flexDirection="column">
|
|
134
|
+
<Text color="redBright" bold>{'⚠ Uninstall komado'}</Text>
|
|
135
|
+
<Text>This permanently deletes:</Text>
|
|
136
|
+
{uninstallTargets().map((t) => (
|
|
137
|
+
<Text key={t.path} color="red">
|
|
138
|
+
{` • ${displayPath(t.path)}`}
|
|
139
|
+
<Text dimColor>{` — ${t.label}`}</Text>
|
|
140
|
+
</Text>
|
|
141
|
+
))}
|
|
142
|
+
<Box marginTop={1}>
|
|
143
|
+
<Text>{'Type '}</Text>
|
|
144
|
+
<Text color="redBright" bold>uninstall</Text>
|
|
145
|
+
<Text>{' to confirm: '}</Text>
|
|
146
|
+
<TextInput value={draft} onChange={setDraft} onSubmit={submitEdit} focus={true} />
|
|
147
|
+
</Box>
|
|
148
|
+
<KeyHints hints={[['enter', 'confirm'], ['esc', 'cancel']]} />
|
|
149
|
+
</Box>
|
|
150
|
+
) : editing ? (
|
|
114
151
|
<Box flexDirection="column">
|
|
115
152
|
<Text color="cyanBright">
|
|
116
153
|
{editing === 'addPath' ? 'New library path (folder of manga / CBZ):' : 'Language code (e.g. en, fr, ja):'}
|
|
@@ -131,7 +168,7 @@ export function SettingsScreen() {
|
|
|
131
168
|
onHighlight={(it) => setHighlighted(it)}
|
|
132
169
|
renderItem={(it, active) => (
|
|
133
170
|
<Box key={it.id} justifyContent="space-between">
|
|
134
|
-
<Text inverse={active} color={active ? 'cyanBright' : it.kind === 'path' ? 'blue' : undefined}>
|
|
171
|
+
<Text inverse={active} color={active ? 'cyanBright' : it.kind === 'danger' ? 'red' : it.kind === 'path' ? 'blue' : undefined}>
|
|
135
172
|
{` ${it.label} `}
|
|
136
173
|
</Text>
|
|
137
174
|
{it.value ? <Text dimColor>{it.value}</Text> : null}
|
package/src/sixel-reader.js
CHANGED
|
@@ -106,7 +106,7 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
106
106
|
function statusBar() {
|
|
107
107
|
const { cols } = size();
|
|
108
108
|
const left = `${manga.title} · ${chapterLabel(chapters[ci])} · ${pi + 1}/${pages ? pages.length : '?'}${fitWidth ? '' : ' · fit'}`;
|
|
109
|
-
const right = '←→ page · ↑↓ pan ·
|
|
109
|
+
const right = '←→ page · ↑↓ pan · n/p ch · f fit · q back';
|
|
110
110
|
const gap = Math.max(1, cols - left.length - right.length - 2);
|
|
111
111
|
return ` ${left}${' '.repeat(gap)}${right} `.slice(0, cols);
|
|
112
112
|
}
|
|
@@ -199,13 +199,16 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
199
199
|
page: pi,
|
|
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 — guard before reading .length, or the
|
|
204
|
+
// stale tail throws "Cannot read properties of null (reading 'length')".
|
|
205
|
+
if (pages && pi === pages.length - 1 && source.syncChapterRead) {
|
|
203
206
|
source.syncChapterRead(manga.id, chapters[ci].id);
|
|
204
207
|
}
|
|
205
208
|
} catch (err) {
|
|
206
209
|
logger.warn('viewer draw failed', err);
|
|
207
210
|
stdout.write(`${ESC}[2J${ESC}[H${ESC}[0m`);
|
|
208
|
-
stdout.write(`Error: ${err.message}\r\n\r\
|
|
211
|
+
stdout.write(`Error: ${err.message}\r\n\r\nn/p chapter · q back\r\n`);
|
|
209
212
|
}
|
|
210
213
|
}
|
|
211
214
|
|
|
@@ -303,12 +306,10 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
303
306
|
const pageStep = Math.max(1, size().rows - 2);
|
|
304
307
|
|
|
305
308
|
if (k === 'q' || k === ESC) { resolve(); return; }
|
|
306
|
-
if (k === '
|
|
309
|
+
if (k === ' ') {
|
|
310
|
+
// space = read-through: scroll a full page, then advance at the bottom
|
|
307
311
|
if (fitWidth && scroll < maxScroll) scroll = Math.min(maxScroll, scroll + pageStep);
|
|
308
312
|
else nextPage();
|
|
309
|
-
} else if (k === 'p') {
|
|
310
|
-
if (fitWidth && scroll > 0) scroll = Math.max(0, scroll - pageStep);
|
|
311
|
-
else prevPage();
|
|
312
313
|
} else if (k === 'd' || k === `${ESC}[C`) {
|
|
313
314
|
nextPage(); // → / d : next page
|
|
314
315
|
} else if (k === 'a' || k === `${ESC}[D`) {
|
|
@@ -317,10 +318,10 @@ export async function runViewer({ sourceId, manga, chapters, chapterIndex, start
|
|
|
317
318
|
scroll = Math.min(maxScroll, scroll + scrollStep);
|
|
318
319
|
} else if (k === 'k' || k === `${ESC}[A`) {
|
|
319
320
|
scroll = Math.max(0, scroll - scrollStep);
|
|
320
|
-
} else if (k === 'N') {
|
|
321
|
-
changeChapter(1);
|
|
322
|
-
} else if (k === 'P') {
|
|
323
|
-
changeChapter(-1);
|
|
321
|
+
} else if (k === 'n' || k === 'N') {
|
|
322
|
+
changeChapter(1); // n / N : next chapter
|
|
323
|
+
} else if (k === 'p' || k === 'P') {
|
|
324
|
+
changeChapter(-1); // p / P : previous chapter
|
|
324
325
|
} else if (k === 'f') {
|
|
325
326
|
fitWidth = !fitWidth;
|
|
326
327
|
scroll = 0;
|
package/src/state/store.js
CHANGED
|
@@ -11,8 +11,18 @@ function readJson(file, fallback) {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// While the app is uninstalling itself, every writer no-ops so a pending debounced
|
|
15
|
+
// save (or the flushProgress() that quit() runs) can't recreate the data directory
|
|
16
|
+
// we just deleted. Set once, never unset — the process is on its way out.
|
|
17
|
+
let persistenceDisabled = false;
|
|
18
|
+
export function disablePersistence() {
|
|
19
|
+
persistenceDisabled = true;
|
|
20
|
+
clearTimeout(saveTimer);
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
// Write-to-temp + rename so a crash mid-write never corrupts the file.
|
|
15
24
|
function writeJsonAtomic(file, data) {
|
|
25
|
+
if (persistenceDisabled) return;
|
|
16
26
|
ensureDirs();
|
|
17
27
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
18
28
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
@@ -42,6 +52,7 @@ function loadProgress() {
|
|
|
42
52
|
// Debounced save — the reader updates progress on every page turn, so coalesce.
|
|
43
53
|
let saveTimer = null;
|
|
44
54
|
function scheduleSave() {
|
|
55
|
+
if (persistenceDisabled) return;
|
|
45
56
|
clearTimeout(saveTimer);
|
|
46
57
|
saveTimer = setTimeout(() => writeJsonAtomic(paths.progressFile, progress), 400);
|
|
47
58
|
saveTimer.unref?.();
|
|
@@ -68,6 +79,7 @@ export function flushProgress() {
|
|
|
68
79
|
// never written to disk. File is 0600 since it holds a long-lived secret.
|
|
69
80
|
let credentials = null;
|
|
70
81
|
function writeCredentialsAtomic(data) {
|
|
82
|
+
if (persistenceDisabled) return;
|
|
71
83
|
ensureDirs();
|
|
72
84
|
const file = paths.credentialsFile;
|
|
73
85
|
const tmp = `${file}.${process.pid}.tmp`;
|
package/src/uninstall.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { paths } from './config.js';
|
|
5
|
+
import { disablePersistence } from './state/store.js';
|
|
6
|
+
|
|
7
|
+
// Where install.sh put things. Mirrors the installer's own defaults + env
|
|
8
|
+
// overrides (KOMADO_APP_DIR / KOMADO_BIN_DIR) so an in-app uninstall targets
|
|
9
|
+
// exactly what was installed — and never a dev checkout, which lives elsewhere.
|
|
10
|
+
function appDir() {
|
|
11
|
+
if (process.env.KOMADO_APP_DIR) return path.resolve(process.env.KOMADO_APP_DIR);
|
|
12
|
+
const share = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
13
|
+
return path.join(share, 'komado');
|
|
14
|
+
}
|
|
15
|
+
function launcherFile() {
|
|
16
|
+
const bin = process.env.KOMADO_BIN_DIR
|
|
17
|
+
? path.resolve(process.env.KOMADO_BIN_DIR)
|
|
18
|
+
: path.join(os.homedir(), '.local', 'bin');
|
|
19
|
+
// The launcher FILE only — never the whole bin dir, which is shared with other tools.
|
|
20
|
+
return path.join(bin, 'komado');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The three things a full install creates. `paths.home` is the runtime data dir
|
|
24
|
+
// (~/.komado or KOMADO_HOME) and holds config, reading progress, the MangaDex
|
|
25
|
+
// login, the page cache, and the log — all under one dir, so one delete clears it.
|
|
26
|
+
export function uninstallTargets() {
|
|
27
|
+
return [
|
|
28
|
+
{ label: 'application files', path: appDir() },
|
|
29
|
+
{ label: 'launcher', path: launcherFile() },
|
|
30
|
+
{ label: 'config, reading progress & MangaDex login', path: paths.home },
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// $HOME → ~ for display.
|
|
35
|
+
export function displayPath(p) {
|
|
36
|
+
const home = os.homedir();
|
|
37
|
+
if (p === home) return '~';
|
|
38
|
+
return p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Defensive: a recursive force-delete must never escape to a filesystem or home
|
|
42
|
+
// root, however the paths above were derived.
|
|
43
|
+
function assertSafe(p) {
|
|
44
|
+
const resolved = path.resolve(p);
|
|
45
|
+
if (!resolved || resolved === path.parse(resolved).root || resolved === os.homedir()) {
|
|
46
|
+
throw new Error(`refusing to delete unsafe path: ${resolved}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Remove the installed app, its launcher, and all runtime data. Synchronous — a
|
|
51
|
+
// one-shot teardown run right before exit. It first switches off the store's
|
|
52
|
+
// persistence so a pending debounced save (or the flushProgress() that quit()
|
|
53
|
+
// runs) can't recreate the data dir we're deleting. One failing target never
|
|
54
|
+
// aborts the rest; returns a per-target status list for the goodbye summary.
|
|
55
|
+
export function performUninstall() {
|
|
56
|
+
disablePersistence();
|
|
57
|
+
return uninstallTargets().map((t) => {
|
|
58
|
+
try {
|
|
59
|
+
assertSafe(t.path);
|
|
60
|
+
const existed = fs.existsSync(t.path);
|
|
61
|
+
fs.rmSync(t.path, { recursive: true, force: true });
|
|
62
|
+
return { ...t, status: existed ? 'removed' : 'absent' };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return { ...t, status: 'failed', error: err.message };
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatUninstallSummary(results) {
|
|
70
|
+
const ok = results.every((r) => r.status !== 'failed');
|
|
71
|
+
const tag = { removed: 'removed', absent: 'absent ', failed: 'FAILED ' };
|
|
72
|
+
const lines = results.map((r) => ` ${tag[r.status]} ${displayPath(r.path)}${r.error ? ` (${r.error})` : ''}`);
|
|
73
|
+
const head = ok ? 'komado has been uninstalled.' : 'komado uninstall finished with errors.';
|
|
74
|
+
return [head, '', ...lines, ''].join('\n');
|
|
75
|
+
}
|