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.
Files changed (78) hide show
  1. package/README.md +208 -0
  2. package/dist/app.js +59 -0
  3. package/dist/cli.js +196 -0
  4. package/dist/components/List.js +41 -0
  5. package/dist/components/screens/ContinueScreen.js +66 -0
  6. package/dist/components/screens/HomeScreen.js +59 -0
  7. package/dist/components/screens/LibraryScreen.js +69 -0
  8. package/dist/components/screens/LoginScreen.js +85 -0
  9. package/dist/components/screens/MangaScreen.js +107 -0
  10. package/dist/components/screens/ReaderScreen.js +195 -0
  11. package/dist/components/screens/SearchScreen.js +111 -0
  12. package/dist/components/screens/SettingsScreen.js +144 -0
  13. package/dist/components/ui.js +33 -0
  14. package/dist/config.js +51 -0
  15. package/dist/domain/shape.js +44 -0
  16. package/dist/hooks/useStdoutDimensions.js +17 -0
  17. package/dist/lib/AppError.js +37 -0
  18. package/dist/lib/cache.js +54 -0
  19. package/dist/lib/catchAsync.js +12 -0
  20. package/dist/lib/envelope.js +15 -0
  21. package/dist/lib/fetchWithBackoff.js +65 -0
  22. package/dist/lib/logger.js +41 -0
  23. package/dist/lib/natsort.js +7 -0
  24. package/dist/lib/text.js +20 -0
  25. package/dist/render/chafa.js +56 -0
  26. package/dist/render/detect.js +86 -0
  27. package/dist/render/halfblock.js +42 -0
  28. package/dist/render/image.js +23 -0
  29. package/dist/render/sixel.js +88 -0
  30. package/dist/sixel-reader.js +309 -0
  31. package/dist/sources/index.js +17 -0
  32. package/dist/sources/local/archive.js +68 -0
  33. package/dist/sources/local/index.js +147 -0
  34. package/dist/sources/mangadex/auth.js +102 -0
  35. package/dist/sources/mangadex/client.js +76 -0
  36. package/dist/sources/mangadex/index.js +156 -0
  37. package/dist/sources/mangadex/normalize.js +54 -0
  38. package/dist/state/store.js +91 -0
  39. package/dist/ui-context.js +11 -0
  40. package/package.json +50 -0
  41. package/src/app.js +73 -0
  42. package/src/cli.js +218 -0
  43. package/src/components/List.js +60 -0
  44. package/src/components/screens/ContinueScreen.js +73 -0
  45. package/src/components/screens/HomeScreen.js +54 -0
  46. package/src/components/screens/LibraryScreen.js +79 -0
  47. package/src/components/screens/LoginScreen.js +92 -0
  48. package/src/components/screens/MangaScreen.js +125 -0
  49. package/src/components/screens/ReaderScreen.js +230 -0
  50. package/src/components/screens/SearchScreen.js +123 -0
  51. package/src/components/screens/SettingsScreen.js +146 -0
  52. package/src/components/ui.js +42 -0
  53. package/src/config.js +49 -0
  54. package/src/domain/shape.js +47 -0
  55. package/src/hooks/useStdoutDimensions.js +19 -0
  56. package/src/lib/AppError.js +26 -0
  57. package/src/lib/cache.js +57 -0
  58. package/src/lib/catchAsync.js +12 -0
  59. package/src/lib/envelope.js +14 -0
  60. package/src/lib/fetchWithBackoff.js +74 -0
  61. package/src/lib/logger.js +41 -0
  62. package/src/lib/natsort.js +7 -0
  63. package/src/lib/text.js +18 -0
  64. package/src/render/chafa.js +64 -0
  65. package/src/render/detect.js +112 -0
  66. package/src/render/halfblock.js +46 -0
  67. package/src/render/image.js +24 -0
  68. package/src/render/sixel.js +141 -0
  69. package/src/sixel-reader.js +359 -0
  70. package/src/sources/index.js +17 -0
  71. package/src/sources/local/archive.js +74 -0
  72. package/src/sources/local/index.js +155 -0
  73. package/src/sources/mangadex/auth.js +125 -0
  74. package/src/sources/mangadex/client.js +83 -0
  75. package/src/sources/mangadex/index.js +166 -0
  76. package/src/sources/mangadex/normalize.js +70 -0
  77. package/src/state/store.js +90 -0
  78. 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,12 @@
1
+ function catchAsync(fn) {
2
+ return async (...args) => {
3
+ try {
4
+ return { data: await fn(...args), error: null };
5
+ } catch (error) {
6
+ return { data: null, error };
7
+ }
8
+ };
9
+ }
10
+ export {
11
+ catchAsync
12
+ };
@@ -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
+ };
@@ -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
+ };