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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import TextInput from "ink-text-input";
|
|
5
|
+
import { useUI } from "../../ui-context.js";
|
|
6
|
+
import { getConfig, setConfig } from "../../state/store.js";
|
|
7
|
+
import { scan } from "../../sources/local/index.js";
|
|
8
|
+
import { isLoggedIn, logout } from "../../sources/mangadex/auth.js";
|
|
9
|
+
import { detectCapabilities } from "../../render/detect.js";
|
|
10
|
+
import { List } from "../List.js";
|
|
11
|
+
import { Header, KeyHints } from "../ui.js";
|
|
12
|
+
import { truncate } from "../../lib/text.js";
|
|
13
|
+
const RENDERERS = ["auto", "halfblock", "chafa"];
|
|
14
|
+
const RATING_PRESETS = [
|
|
15
|
+
["safe"],
|
|
16
|
+
["safe", "suggestive"],
|
|
17
|
+
["safe", "suggestive", "erotica"],
|
|
18
|
+
["safe", "suggestive", "erotica", "pornographic"]
|
|
19
|
+
];
|
|
20
|
+
const ratingLabel = (arr) => arr.join("+");
|
|
21
|
+
const cycle = (list, current) => list[(list.indexOf(current) + 1) % list.length];
|
|
22
|
+
function SettingsScreen() {
|
|
23
|
+
const ui = useUI();
|
|
24
|
+
const caps = detectCapabilities();
|
|
25
|
+
const [cfg, setCfg] = useState(getConfig());
|
|
26
|
+
const [editing, setEditing] = useState(null);
|
|
27
|
+
const [draft, setDraft] = useState("");
|
|
28
|
+
const [highlighted, setHighlighted] = useState(null);
|
|
29
|
+
const [, setTick] = useState(0);
|
|
30
|
+
const loggedIn = isLoggedIn();
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
ui.setTyping(!!editing);
|
|
33
|
+
return () => ui.setTyping(false);
|
|
34
|
+
}, [editing]);
|
|
35
|
+
const save = (patch) => setCfg({ ...setConfig(patch) });
|
|
36
|
+
const items = [
|
|
37
|
+
{
|
|
38
|
+
id: "account",
|
|
39
|
+
kind: "account",
|
|
40
|
+
label: loggedIn ? "MangaDex account" : "Log in to MangaDex\u2026",
|
|
41
|
+
value: loggedIn ? "logged in \xB7 enter to log out" : "enter to log in"
|
|
42
|
+
},
|
|
43
|
+
...loggedIn ? [{ id: "syncProgress", kind: "toggle", label: "Sync reading progress (MangaDex)", value: cfg.syncProgress ? "on" : "off" }] : [],
|
|
44
|
+
{ id: "renderer", kind: "cycle", label: "Renderer", value: cfg.renderer + (caps.chafa ? "" : " (chafa N/A)") },
|
|
45
|
+
{ id: "dataSaver", kind: "toggle", label: "Data saver (smaller images)", value: cfg.dataSaver ? "on" : "off" },
|
|
46
|
+
{ id: "rating", kind: "cycle", label: "Content rating", value: ratingLabel(cfg.contentRating) },
|
|
47
|
+
{ id: "language", kind: "edit", label: "Language (MangaDex)", value: cfg.language },
|
|
48
|
+
{ id: "addPath", kind: "action", label: "Add library path\u2026", value: "" },
|
|
49
|
+
...cfg.localLibraryPaths.map((p, i) => ({
|
|
50
|
+
id: `path:${i}`,
|
|
51
|
+
kind: "path",
|
|
52
|
+
pathIndex: i,
|
|
53
|
+
label: `Library: ${truncate(p, 48)}`,
|
|
54
|
+
value: "d to remove"
|
|
55
|
+
}))
|
|
56
|
+
];
|
|
57
|
+
const activate = (item) => {
|
|
58
|
+
switch (item.kind) {
|
|
59
|
+
case "account":
|
|
60
|
+
if (loggedIn) {
|
|
61
|
+
logout();
|
|
62
|
+
return setTick((t) => t + 1);
|
|
63
|
+
}
|
|
64
|
+
return ui.navigate("login");
|
|
65
|
+
case "toggle":
|
|
66
|
+
if (item.id === "syncProgress") return save({ syncProgress: !cfg.syncProgress });
|
|
67
|
+
return save({ dataSaver: !cfg.dataSaver });
|
|
68
|
+
case "cycle":
|
|
69
|
+
if (item.id === "renderer") return save({ renderer: cycle(RENDERERS, cfg.renderer) });
|
|
70
|
+
if (item.id === "rating") {
|
|
71
|
+
const idx = RATING_PRESETS.findIndex((p) => ratingLabel(p) === ratingLabel(cfg.contentRating));
|
|
72
|
+
return save({ contentRating: RATING_PRESETS[(idx + 1) % RATING_PRESETS.length] });
|
|
73
|
+
}
|
|
74
|
+
return void 0;
|
|
75
|
+
case "edit":
|
|
76
|
+
setDraft(cfg.language);
|
|
77
|
+
return setEditing("language");
|
|
78
|
+
case "action":
|
|
79
|
+
setDraft("");
|
|
80
|
+
return setEditing("addPath");
|
|
81
|
+
default:
|
|
82
|
+
return void 0;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const submitEdit = () => {
|
|
86
|
+
if (editing === "language") {
|
|
87
|
+
save({ language: draft.trim() || "en" });
|
|
88
|
+
} else if (editing === "addPath" && draft.trim()) {
|
|
89
|
+
save({ localLibraryPaths: [...cfg.localLibraryPaths, draft.trim()] });
|
|
90
|
+
scan();
|
|
91
|
+
}
|
|
92
|
+
setEditing(null);
|
|
93
|
+
setDraft("");
|
|
94
|
+
};
|
|
95
|
+
useInput((input, key) => {
|
|
96
|
+
if (editing) {
|
|
97
|
+
if (key.escape) {
|
|
98
|
+
setEditing(null);
|
|
99
|
+
setDraft("");
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (input === "d" && highlighted?.kind === "path") {
|
|
104
|
+
save({ localLibraryPaths: cfg.localLibraryPaths.filter((_, i) => i !== highlighted.pathIndex) });
|
|
105
|
+
scan();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
109
|
+
/* @__PURE__ */ jsx(
|
|
110
|
+
Header,
|
|
111
|
+
{
|
|
112
|
+
title: "Settings",
|
|
113
|
+
subtitle: `chafa: ${caps.chafa ? caps.chafaVersion : "not installed"} \xB7 backend: ${caps.chafa ? "chafa-symbols" : "half-block"}`
|
|
114
|
+
}
|
|
115
|
+
),
|
|
116
|
+
editing ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
117
|
+
/* @__PURE__ */ jsx(Text, { color: "cyanBright", children: editing === "addPath" ? "New library path (folder of manga / CBZ):" : "Language code (e.g. en, fr, ja):" }),
|
|
118
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
119
|
+
/* @__PURE__ */ jsx(Text, { color: "cyanBright", children: "\u203A " }),
|
|
120
|
+
/* @__PURE__ */ jsx(TextInput, { value: draft, onChange: setDraft, onSubmit: submitEdit, focus: true })
|
|
121
|
+
] }),
|
|
122
|
+
/* @__PURE__ */ jsx(KeyHints, { hints: [["enter", "save"], ["esc", "cancel"]] })
|
|
123
|
+
] }) : /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
124
|
+
/* @__PURE__ */ jsx(
|
|
125
|
+
List,
|
|
126
|
+
{
|
|
127
|
+
items,
|
|
128
|
+
isActive: true,
|
|
129
|
+
height: Math.max(6, (ui.dimensions.rows || 24) - 7),
|
|
130
|
+
onSelect: activate,
|
|
131
|
+
onHighlight: (it) => setHighlighted(it),
|
|
132
|
+
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} ` }),
|
|
134
|
+
it.value ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: it.value }) : null
|
|
135
|
+
] }, it.id)
|
|
136
|
+
}
|
|
137
|
+
),
|
|
138
|
+
/* @__PURE__ */ jsx(KeyHints, { hints: [["\u2191\u2193", "move"], ["enter", "change"], ["d", "remove path"], ["esc", "back"]] })
|
|
139
|
+
] })
|
|
140
|
+
] });
|
|
141
|
+
}
|
|
142
|
+
export {
|
|
143
|
+
SettingsScreen
|
|
144
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
const FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
5
|
+
function Spinner({ label = "Loading" }) {
|
|
6
|
+
const [frame, setFrame] = useState(0);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const t = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
|
|
9
|
+
return () => clearInterval(t);
|
|
10
|
+
}, []);
|
|
11
|
+
return /* @__PURE__ */ jsx(Text, { color: "cyan", children: `${FRAMES[frame]} ${label}\u2026` });
|
|
12
|
+
}
|
|
13
|
+
function Header({ title, subtitle }) {
|
|
14
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
|
|
15
|
+
/* @__PURE__ */ jsx(Text, { color: "magentaBright", bold: true, children: title }),
|
|
16
|
+
subtitle ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: subtitle }) : null
|
|
17
|
+
] });
|
|
18
|
+
}
|
|
19
|
+
function ErrorView({ error }) {
|
|
20
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
21
|
+
/* @__PURE__ */ jsx(Text, { color: "red", bold: true, children: `\u2716 ${error?.message || "Something went wrong"}` }),
|
|
22
|
+
error?.statusCode ? /* @__PURE__ */ jsx(Text, { dimColor: true, children: `status ${error.statusCode}` }) : null
|
|
23
|
+
] });
|
|
24
|
+
}
|
|
25
|
+
function KeyHints({ hints = [] }) {
|
|
26
|
+
return /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: hints.map(([k, l]) => `${k} ${l}`).join(" ") }) });
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
ErrorView,
|
|
30
|
+
Header,
|
|
31
|
+
KeyHints,
|
|
32
|
+
Spinner
|
|
33
|
+
};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
const usingDefaultHome = !process.env.KOMADO_HOME;
|
|
5
|
+
const HOME = usingDefaultHome ? path.join(os.homedir(), ".komado") : path.resolve(process.env.KOMADO_HOME);
|
|
6
|
+
const LEGACY_HOME = path.join(os.homedir(), ".manga-tui");
|
|
7
|
+
const paths = {
|
|
8
|
+
home: HOME,
|
|
9
|
+
configFile: path.join(HOME, "config.json"),
|
|
10
|
+
progressFile: path.join(HOME, "progress.json"),
|
|
11
|
+
credentialsFile: path.join(HOME, "credentials.json"),
|
|
12
|
+
cacheDir: path.join(HOME, "cache"),
|
|
13
|
+
logFile: path.join(HOME, "komado.log")
|
|
14
|
+
};
|
|
15
|
+
function ensureDirs() {
|
|
16
|
+
if (usingDefaultHome && !fs.existsSync(HOME) && fs.existsSync(LEGACY_HOME)) {
|
|
17
|
+
try {
|
|
18
|
+
fs.renameSync(LEGACY_HOME, HOME);
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
fs.mkdirSync(paths.home, { recursive: true });
|
|
23
|
+
fs.mkdirSync(paths.cacheDir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
const DEFAULT_CONFIG = {
|
|
26
|
+
localLibraryPaths: [],
|
|
27
|
+
// directories scanned by the local source
|
|
28
|
+
language: "en",
|
|
29
|
+
// preferred MangaDex translatedLanguage
|
|
30
|
+
contentRating: ["safe", "suggestive"],
|
|
31
|
+
dataSaver: true,
|
|
32
|
+
// smaller MangaDex page images — ideal for a terminal
|
|
33
|
+
renderer: "auto",
|
|
34
|
+
// auto | halfblock | chafa
|
|
35
|
+
theme: "default",
|
|
36
|
+
syncProgress: true
|
|
37
|
+
// push read-markers to MangaDex while logged in
|
|
38
|
+
};
|
|
39
|
+
const MANGADEX = {
|
|
40
|
+
api: "https://api.mangadex.org",
|
|
41
|
+
auth: "https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token",
|
|
42
|
+
uploads: "https://uploads.mangadex.org",
|
|
43
|
+
userAgent: "komado/0.1 (+https://github.com/RyuPrad/komado)",
|
|
44
|
+
pageLimit: 20
|
|
45
|
+
};
|
|
46
|
+
export {
|
|
47
|
+
DEFAULT_CONFIG,
|
|
48
|
+
MANGADEX,
|
|
49
|
+
ensureDirs,
|
|
50
|
+
paths
|
|
51
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const globalKey = (source, id) => `${source}:${id}`;
|
|
2
|
+
function makeManga(p) {
|
|
3
|
+
return {
|
|
4
|
+
source: p.source,
|
|
5
|
+
id: String(p.id),
|
|
6
|
+
key: globalKey(p.source, p.id),
|
|
7
|
+
title: p.title || "Untitled",
|
|
8
|
+
altTitles: p.altTitles || [],
|
|
9
|
+
description: p.description || "",
|
|
10
|
+
authors: p.authors || [],
|
|
11
|
+
status: p.status || "unknown",
|
|
12
|
+
tags: p.tags || [],
|
|
13
|
+
coverUrl: p.coverUrl || null,
|
|
14
|
+
language: p.language || "en",
|
|
15
|
+
chaptersCount: p.chaptersCount ?? null,
|
|
16
|
+
raw: p.raw
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function makeChapter(p) {
|
|
20
|
+
return {
|
|
21
|
+
source: p.source,
|
|
22
|
+
id: String(p.id),
|
|
23
|
+
key: globalKey(p.source, p.id),
|
|
24
|
+
mangaKey: p.mangaKey,
|
|
25
|
+
number: p.number ?? null,
|
|
26
|
+
// string like "12.5", or null for oneshots
|
|
27
|
+
volume: p.volume ?? null,
|
|
28
|
+
title: p.title || "",
|
|
29
|
+
language: p.language || "en",
|
|
30
|
+
pages: p.pages ?? null,
|
|
31
|
+
publishedAt: p.publishedAt || null,
|
|
32
|
+
raw: p.raw
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function chapterLabel(ch) {
|
|
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} \u2014 ${ch.title}` : head;
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
chapterLabel,
|
|
41
|
+
globalKey,
|
|
42
|
+
makeChapter,
|
|
43
|
+
makeManga
|
|
44
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
function useStdoutDimensions() {
|
|
3
|
+
const read = () => ({
|
|
4
|
+
cols: process.stdout.columns || 80,
|
|
5
|
+
rows: process.stdout.rows || 24
|
|
6
|
+
});
|
|
7
|
+
const [size, setSize] = useState(read);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const onResize = () => setSize(read());
|
|
10
|
+
process.stdout.on("resize", onResize);
|
|
11
|
+
return () => process.stdout.off("resize", onResize);
|
|
12
|
+
}, []);
|
|
13
|
+
return size;
|
|
14
|
+
}
|
|
15
|
+
export {
|
|
16
|
+
useStdoutDimensions
|
|
17
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
class AppError extends Error {
|
|
2
|
+
constructor(message, statusCode = 500, opts = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = this.constructor.name;
|
|
5
|
+
this.statusCode = statusCode;
|
|
6
|
+
this.isOperational = true;
|
|
7
|
+
if (opts.cause) this.cause = opts.cause;
|
|
8
|
+
if (opts.meta) Object.assign(this, opts.meta);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
class NotFoundError extends AppError {
|
|
12
|
+
constructor(message = "Not found", opts) {
|
|
13
|
+
super(message, 404, opts);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
class SourceError extends AppError {
|
|
17
|
+
constructor(message = "Source unavailable", opts) {
|
|
18
|
+
super(message, 502, opts);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
class UnsupportedError extends AppError {
|
|
22
|
+
constructor(message = "Unsupported", opts) {
|
|
23
|
+
super(message, 422, opts);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
class AuthError extends AppError {
|
|
27
|
+
constructor(message = "Authentication required", opts) {
|
|
28
|
+
super(message, 401, opts);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export {
|
|
32
|
+
AppError,
|
|
33
|
+
AuthError,
|
|
34
|
+
NotFoundError,
|
|
35
|
+
SourceError,
|
|
36
|
+
UnsupportedError
|
|
37
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function createCache({ ttlMs = 6e4, negativeTtlMs = 5e3, max = 500 } = {}) {
|
|
2
|
+
const store = /* @__PURE__ */ new Map();
|
|
3
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
4
|
+
function get(key) {
|
|
5
|
+
const entry = store.get(key);
|
|
6
|
+
if (!entry) return void 0;
|
|
7
|
+
if (entry.expires <= Date.now()) {
|
|
8
|
+
store.delete(key);
|
|
9
|
+
return void 0;
|
|
10
|
+
}
|
|
11
|
+
return entry.value;
|
|
12
|
+
}
|
|
13
|
+
function set(key, value, ttl) {
|
|
14
|
+
const isEmpty = value === null || value === void 0;
|
|
15
|
+
const life = ttl ?? (isEmpty ? negativeTtlMs : ttlMs);
|
|
16
|
+
store.set(key, { value, expires: Date.now() + life });
|
|
17
|
+
if (store.size > max) {
|
|
18
|
+
const oldest = store.keys().next().value;
|
|
19
|
+
store.delete(oldest);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function wrap(key, fn, ttl) {
|
|
23
|
+
const cached = get(key);
|
|
24
|
+
if (cached !== void 0) return cached;
|
|
25
|
+
if (inflight.has(key)) return inflight.get(key);
|
|
26
|
+
const promise = (async () => {
|
|
27
|
+
try {
|
|
28
|
+
const value = await fn();
|
|
29
|
+
set(key, value, ttl);
|
|
30
|
+
return value;
|
|
31
|
+
} finally {
|
|
32
|
+
inflight.delete(key);
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
inflight.set(key, promise);
|
|
36
|
+
return promise;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
get,
|
|
40
|
+
set,
|
|
41
|
+
wrap,
|
|
42
|
+
delete: (key) => store.delete(key),
|
|
43
|
+
clear: () => {
|
|
44
|
+
store.clear();
|
|
45
|
+
inflight.clear();
|
|
46
|
+
},
|
|
47
|
+
get size() {
|
|
48
|
+
return store.size;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export {
|
|
53
|
+
createCache
|
|
54
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
function envelope(data, { pagination = null, meta = {} } = {}) {
|
|
2
|
+
return { data, pagination, meta };
|
|
3
|
+
}
|
|
4
|
+
function paginate({ offset = 0, limit = 0, total = 0 } = {}) {
|
|
5
|
+
return {
|
|
6
|
+
offset,
|
|
7
|
+
limit,
|
|
8
|
+
total,
|
|
9
|
+
hasMore: total > 0 ? offset + limit < total : false
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export {
|
|
13
|
+
envelope,
|
|
14
|
+
paginate
|
|
15
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { SourceError } from "./AppError.js";
|
|
2
|
+
function sleep(ms, signal) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
if (signal?.aborted) return reject(signal.reason ?? new Error("aborted"));
|
|
5
|
+
const timer = setTimeout(resolve, ms);
|
|
6
|
+
signal?.addEventListener("abort", () => {
|
|
7
|
+
clearTimeout(timer);
|
|
8
|
+
reject(signal.reason ?? new Error("aborted"));
|
|
9
|
+
}, { once: true });
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
async function fetchWithBackoff(url, options = {}) {
|
|
13
|
+
const {
|
|
14
|
+
retries = 4,
|
|
15
|
+
baseDelayMs = 500,
|
|
16
|
+
maxDelayMs = 8e3,
|
|
17
|
+
timeoutMs = 2e4,
|
|
18
|
+
signal: extSignal,
|
|
19
|
+
...fetchOpts
|
|
20
|
+
} = options;
|
|
21
|
+
let attempt = 0;
|
|
22
|
+
for (; ; ) {
|
|
23
|
+
const ctrl = new AbortController();
|
|
24
|
+
const onExtAbort = () => ctrl.abort(extSignal.reason);
|
|
25
|
+
if (extSignal) {
|
|
26
|
+
if (extSignal.aborted) ctrl.abort(extSignal.reason);
|
|
27
|
+
else extSignal.addEventListener("abort", onExtAbort, { once: true });
|
|
28
|
+
}
|
|
29
|
+
const timer = setTimeout(() => ctrl.abort(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(url, { ...fetchOpts, signal: ctrl.signal });
|
|
32
|
+
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
33
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
34
|
+
const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : Math.min(maxDelayMs, baseDelayMs * 2 ** attempt) + Math.random() * 200;
|
|
35
|
+
attempt += 1;
|
|
36
|
+
await sleep(delay, extSignal);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
return res;
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (extSignal?.aborted) throw err;
|
|
42
|
+
if (attempt < retries) {
|
|
43
|
+
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt) + Math.random() * 200;
|
|
44
|
+
attempt += 1;
|
|
45
|
+
await sleep(delay, extSignal);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
throw new SourceError(`Request failed: ${url}`, { cause: err });
|
|
49
|
+
} finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
extSignal?.removeEventListener("abort", onExtAbort);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function fetchJson(url, options = {}) {
|
|
56
|
+
const res = await fetchWithBackoff(url, options);
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
throw new SourceError(`HTTP ${res.status} for ${url}`, { meta: { statusCode: res.status } });
|
|
59
|
+
}
|
|
60
|
+
return res.json();
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
fetchJson,
|
|
64
|
+
fetchWithBackoff
|
|
65
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { paths } from "../config.js";
|
|
3
|
+
const enabled = !!process.env.KOMADO_DEBUG;
|
|
4
|
+
let stream = null;
|
|
5
|
+
function out() {
|
|
6
|
+
if (!enabled) return null;
|
|
7
|
+
if (!stream) {
|
|
8
|
+
try {
|
|
9
|
+
fs.mkdirSync(paths.home, { recursive: true });
|
|
10
|
+
stream = fs.createWriteStream(paths.logFile, { flags: "a" });
|
|
11
|
+
} catch {
|
|
12
|
+
stream = null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return stream;
|
|
16
|
+
}
|
|
17
|
+
function fmt(arg) {
|
|
18
|
+
if (typeof arg === "string") return arg;
|
|
19
|
+
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.stringify(arg);
|
|
22
|
+
} catch {
|
|
23
|
+
return String(arg);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function write(level, args) {
|
|
27
|
+
const s = out();
|
|
28
|
+
if (!s) return;
|
|
29
|
+
s.write(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${level} ${args.map(fmt).join(" ")}
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
const logger = {
|
|
33
|
+
enabled,
|
|
34
|
+
debug: (...a) => write("DEBUG", a),
|
|
35
|
+
info: (...a) => write("INFO", a),
|
|
36
|
+
warn: (...a) => write("WARN", a),
|
|
37
|
+
error: (...a) => write("ERROR", a)
|
|
38
|
+
};
|
|
39
|
+
export {
|
|
40
|
+
logger
|
|
41
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const collator = new Intl.Collator(void 0, { numeric: true, sensitivity: "base" });
|
|
2
|
+
const naturalCompare = (a, b) => collator.compare(String(a), String(b));
|
|
3
|
+
const naturalSort = (arr, key = (x) => x) => [...arr].sort((a, b) => naturalCompare(key(a), key(b)));
|
|
4
|
+
export {
|
|
5
|
+
naturalCompare,
|
|
6
|
+
naturalSort
|
|
7
|
+
};
|
package/dist/lib/text.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
function truncate(str, max) {
|
|
2
|
+
const s = String(str ?? "");
|
|
3
|
+
if (max <= 1 || s.length <= max) return s;
|
|
4
|
+
return s.slice(0, Math.max(1, max - 1)) + "\u2026";
|
|
5
|
+
}
|
|
6
|
+
function relativeTime(ts) {
|
|
7
|
+
if (!ts) return "";
|
|
8
|
+
const diff = Date.now() - ts;
|
|
9
|
+
const mins = Math.round(diff / 6e4);
|
|
10
|
+
if (mins < 1) return "just now";
|
|
11
|
+
if (mins < 60) return `${mins}m ago`;
|
|
12
|
+
const hrs = Math.round(mins / 60);
|
|
13
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
14
|
+
const days = Math.round(hrs / 24);
|
|
15
|
+
return `${days}d ago`;
|
|
16
|
+
}
|
|
17
|
+
export {
|
|
18
|
+
relativeTime,
|
|
19
|
+
truncate
|
|
20
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { writeFile, unlink, mkdir } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import sharp from "sharp";
|
|
6
|
+
import { paths } from "../config.js";
|
|
7
|
+
import { detectCapabilities } from "./detect.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
let dirReady = false;
|
|
10
|
+
async function ensureTempDir() {
|
|
11
|
+
if (dirReady) return;
|
|
12
|
+
await mkdir(paths.cacheDir, { recursive: true });
|
|
13
|
+
dirReady = true;
|
|
14
|
+
}
|
|
15
|
+
async function withTempImage(buffer, fn) {
|
|
16
|
+
await ensureTempDir();
|
|
17
|
+
const file = path.join(
|
|
18
|
+
paths.cacheDir,
|
|
19
|
+
`chafa-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.png`
|
|
20
|
+
);
|
|
21
|
+
await writeFile(file, buffer);
|
|
22
|
+
try {
|
|
23
|
+
return await fn(file);
|
|
24
|
+
} finally {
|
|
25
|
+
unlink(file).catch(() => {
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function renderChafaSymbols(buffer, { cols = 80 } = {}) {
|
|
30
|
+
const meta = await sharp(buffer).metadata();
|
|
31
|
+
const aspect = (meta.height || 1) / (meta.width || 1);
|
|
32
|
+
const rows = Math.max(1, Math.round(aspect * cols / 2));
|
|
33
|
+
const stdout = await withTempImage(
|
|
34
|
+
buffer,
|
|
35
|
+
(file) => execFileAsync(
|
|
36
|
+
"chafa",
|
|
37
|
+
["--format", "symbols", "--colors", "full", "--size", `${cols}x${rows}`, file],
|
|
38
|
+
{ maxBuffer: 96 * 1024 * 1024 }
|
|
39
|
+
).then((r) => r.stdout)
|
|
40
|
+
);
|
|
41
|
+
const lines = stdout.replace(/\n+$/, "").split("\n");
|
|
42
|
+
return { lines, cols, rows: lines.length };
|
|
43
|
+
}
|
|
44
|
+
function spawnChafaToTerminal(file, { cols, rows } = {}) {
|
|
45
|
+
if (!detectCapabilities().chafa) return false;
|
|
46
|
+
const args = ["--colors", "full"];
|
|
47
|
+
if (cols && rows) args.push("--size", `${cols}x${rows}`);
|
|
48
|
+
args.push(file);
|
|
49
|
+
const res = spawnSync("chafa", args, { stdio: ["ignore", "inherit", "inherit"] });
|
|
50
|
+
return res.status === 0;
|
|
51
|
+
}
|
|
52
|
+
export {
|
|
53
|
+
renderChafaSymbols,
|
|
54
|
+
spawnChafaToTerminal,
|
|
55
|
+
withTempImage
|
|
56
|
+
};
|