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,86 @@
1
+ import { execFileSync } from "node:child_process";
2
+ let cached = null;
3
+ function detectCapabilities() {
4
+ if (cached) return cached;
5
+ const env = process.env;
6
+ const term = env.TERM || "";
7
+ const termProgram = env.TERM_PROGRAM || "";
8
+ const kitty = !!env.KITTY_WINDOW_ID || term.includes("kitty") || termProgram === "ghostty" || termProgram === "WezTerm";
9
+ const sixel = /sixel/i.test(env.KOMADO_CAPS || "") || term === "foot" || term.includes("foot") || term.includes("mlterm");
10
+ const truecolor = env.COLORTERM === "truecolor" || env.COLORTERM === "24bit";
11
+ let chafa = false;
12
+ let chafaVersion = null;
13
+ try {
14
+ const out = execFileSync("chafa", ["--version"], {
15
+ stdio: ["ignore", "pipe", "ignore"]
16
+ }).toString();
17
+ chafa = true;
18
+ chafaVersion = (out.match(/version\s+([\d.]+)/i) || [])[1] || "unknown";
19
+ } catch {
20
+ }
21
+ cached = { term, termProgram, kitty, sixel, truecolor, chafa, chafaVersion };
22
+ return cached;
23
+ }
24
+ function pickInlineBackend(config, caps = detectCapabilities()) {
25
+ const pref = config?.renderer || "auto";
26
+ if (pref === "halfblock") return "halfblock";
27
+ if (pref === "chafa") return caps.chafa ? "chafa-symbols" : "halfblock";
28
+ return caps.chafa ? "chafa-symbols" : "halfblock";
29
+ }
30
+ const RENDERER_CYCLE = ["auto", "halfblock", "chafa"];
31
+ async function probeTerminal({ timeoutMs = 350 } = {}) {
32
+ const { stdin, stdout } = process;
33
+ if (!stdout.isTTY || !stdin.isTTY) {
34
+ return { queried: false, sixel: false, kitty: false, cellW: null, cellH: null };
35
+ }
36
+ return new Promise((resolve) => {
37
+ let buf = "";
38
+ const prevRaw = stdin.isRaw;
39
+ const onData = (d) => {
40
+ buf += d.toString("latin1");
41
+ };
42
+ try {
43
+ stdin.setRawMode(true);
44
+ } catch {
45
+ }
46
+ stdin.resume();
47
+ stdin.on("data", onData);
48
+ stdout.write("\x1B_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1B\\\x1B[16t\x1B[14t\x1B[18t\x1B[c");
49
+ setTimeout(() => {
50
+ stdin.removeListener("data", onData);
51
+ try {
52
+ stdin.setRawMode(prevRaw);
53
+ } catch {
54
+ }
55
+ stdin.pause();
56
+ const kitty = /\x1b_G[^\x1b]*;OK/.test(buf);
57
+ const da = buf.match(/\x1b\[\?([0-9;]+)c/);
58
+ const cell = buf.match(/\x1b\[6;(\d+);(\d+)t/);
59
+ const areaPx = buf.match(/\x1b\[4;(\d+);(\d+)t/);
60
+ const areaCh = buf.match(/\x1b\[8;(\d+);(\d+)t/);
61
+ const sixel = da ? da[1].split(";").includes("4") : false;
62
+ let cellW = null;
63
+ let cellH = null;
64
+ if (cell) {
65
+ cellH = Number(cell[1]);
66
+ cellW = Number(cell[2]);
67
+ } else if (areaPx && areaCh) {
68
+ const cols = Number(areaCh[2]);
69
+ const rows = Number(areaCh[1]);
70
+ if (cols > 0 && rows > 0) {
71
+ cellW = Number(areaPx[2]) / cols;
72
+ cellH = Number(areaPx[1]) / rows;
73
+ }
74
+ }
75
+ if (!(cellW > 2 && cellW < 64)) cellW = null;
76
+ if (!(cellH > 2 && cellH < 96)) cellH = null;
77
+ resolve({ queried: true, sixel, kitty, cellW, cellH });
78
+ }, timeoutMs);
79
+ });
80
+ }
81
+ export {
82
+ RENDERER_CYCLE,
83
+ detectCapabilities,
84
+ pickInlineBackend,
85
+ probeTerminal
86
+ };
@@ -0,0 +1,42 @@
1
+ import sharp from "sharp";
2
+ const ESC = "\x1B";
3
+ const RESET = `${ESC}[0m`;
4
+ async function renderHalfBlock(buffer, { cols = 80 } = {}) {
5
+ const targetWidth = Math.max(1, Math.min(Math.floor(cols), 400));
6
+ const { data, info } = await sharp(buffer).resize({ width: targetWidth, fit: "inside", withoutEnlargement: false }).flatten({ background: "#ffffff" }).raw().toBuffer({ resolveWithObject: true });
7
+ const { width, height, channels } = info;
8
+ const px = (x, y) => {
9
+ const i = (y * width + x) * channels;
10
+ if (channels === 1) {
11
+ const v = data[i];
12
+ return [v, v, v];
13
+ }
14
+ return [data[i], data[i + 1], data[i + 2]];
15
+ };
16
+ const lines = [];
17
+ for (let y = 0; y < height; y += 2) {
18
+ let line = "";
19
+ let lastFg = null;
20
+ let lastBg = null;
21
+ for (let x = 0; x < width; x++) {
22
+ const [tr, tg, tb] = px(x, y);
23
+ const [br, bg, bb] = y + 1 < height ? px(x, y + 1) : [tr, tg, tb];
24
+ const fg = `${tr};${tg};${tb}`;
25
+ const bgc = `${br};${bg};${bb}`;
26
+ if (fg !== lastFg) {
27
+ line += `${ESC}[38;2;${fg}m`;
28
+ lastFg = fg;
29
+ }
30
+ if (bgc !== lastBg) {
31
+ line += `${ESC}[48;2;${bgc}m`;
32
+ lastBg = bgc;
33
+ }
34
+ line += "\u2580";
35
+ }
36
+ lines.push(line + RESET);
37
+ }
38
+ return { lines, cols: width, rows: lines.length };
39
+ }
40
+ export {
41
+ renderHalfBlock
42
+ };
@@ -0,0 +1,23 @@
1
+ import sharp from "sharp";
2
+ import { renderHalfBlock } from "./halfblock.js";
3
+ import { renderChafaSymbols } from "./chafa.js";
4
+ import { logger } from "../lib/logger.js";
5
+ async function imageSize(buffer) {
6
+ const { width = 1, height = 1 } = await sharp(buffer).metadata();
7
+ return { width, height };
8
+ }
9
+ async function renderInline(buffer, { cols = 80, backend = "halfblock" } = {}) {
10
+ if (backend === "chafa-symbols") {
11
+ try {
12
+ return await renderChafaSymbols(buffer, { cols });
13
+ } catch (err) {
14
+ logger.warn("chafa render failed, falling back to half-block", err);
15
+ return renderHalfBlock(buffer, { cols });
16
+ }
17
+ }
18
+ return renderHalfBlock(buffer, { cols });
19
+ }
20
+ export {
21
+ imageSize,
22
+ renderInline
23
+ };
@@ -0,0 +1,88 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import sharp from "sharp";
4
+ import { withTempImage } from "./chafa.js";
5
+ const execFileAsync = promisify(execFile);
6
+ const DEFAULT_CELL_W = 10;
7
+ const DEFAULT_CELL_H = 20;
8
+ async function encodePixels(buffer, { format = "sixel" } = {}) {
9
+ const { stdout } = await withTempImage(
10
+ buffer,
11
+ (file) => execFileAsync(
12
+ "chafa",
13
+ ["--format", format, "--exact-size", "on", "--font-ratio", "1/1", "--animate", "off", file],
14
+ { maxBuffer: 256 * 1024 * 1024, encoding: "buffer" }
15
+ )
16
+ );
17
+ return stdout;
18
+ }
19
+ async function scalePage(buffer, { cols, cellW }) {
20
+ const viewW = Math.max(1, Math.round(cols * (cellW || DEFAULT_CELL_W)));
21
+ const scaled = await sharp(buffer).resize({ width: viewW }).png().toBuffer();
22
+ const meta = await sharp(scaled).metadata();
23
+ return { buffer: scaled, width: meta.width || viewW, height: meta.height || 0 };
24
+ }
25
+ async function prepareImage(buffer, { mode, cols, rows, scroll = 0, cellW, cellH, scaled = null }) {
26
+ const ch = cellH || DEFAULT_CELL_H;
27
+ const viewW = Math.max(1, Math.round(cols * (cellW || DEFAULT_CELL_W)));
28
+ const viewH = Math.max(1, Math.round(rows * ch));
29
+ if (mode === "fit") {
30
+ const out2 = await sharp(buffer).resize({ width: viewW, height: viewH, fit: "inside", withoutEnlargement: false }).png().toBuffer();
31
+ const meta = await sharp(out2).metadata();
32
+ return { buffer: out2, maxScroll: 0, scroll: 0, imageRows: Math.round((meta.height || viewH) / ch) };
33
+ }
34
+ const page = scaled || await scalePage(buffer, { cols, cellW });
35
+ const scaledH = page.height || viewH;
36
+ const maxScrollPx = Math.max(0, scaledH - viewH);
37
+ const maxScroll = Math.ceil(maxScrollPx / ch);
38
+ const clamped = Math.max(0, Math.min(scroll, maxScroll));
39
+ const top = Math.min(maxScrollPx, clamped * ch);
40
+ const cropH = Math.max(1, Math.min(viewH, scaledH - top));
41
+ const out = await sharp(page.buffer).extract({ left: 0, top: Math.round(top), width: page.width, height: Math.round(cropH) }).png().toBuffer();
42
+ return { buffer: out, maxScroll, scroll: clamped, imageRows: Math.round(cropH / ch) };
43
+ }
44
+ const BAND_PX = 6;
45
+ function parseSixelPage(raw) {
46
+ const dcsStart = raw.indexOf("\x1BP");
47
+ if (dcsStart < 0) throw new Error("no sixel DCS found in encoder output");
48
+ const stEnd = raw.indexOf("\x1B\\", dcsStart);
49
+ const payload = raw.slice(dcsStart, stEnd < 0 ? void 0 : stEnd);
50
+ const qi = payload.indexOf("q");
51
+ const intro = qi >= 0 ? payload.slice(0, qi + 1) : "\x1BPq";
52
+ let rest = payload.slice(intro.length);
53
+ const rasterM = rest.match(/^"(\d+);(\d+);(\d+);(\d+)/);
54
+ const raster = rasterM ? { pan: rasterM[1], pad: rasterM[2], ph: rasterM[3] } : { pan: "1", pad: "1", ph: "0" };
55
+ if (rasterM) rest = rest.slice(rasterM[0].length);
56
+ const palette = rest.match(/^((?:#\d+;2;\d+;\d+;\d+)*)/)?.[1] ?? "";
57
+ const rawBands = rest.slice(palette.length).split("-");
58
+ while (rawBands.length && rawBands[rawBands.length - 1] === "") rawBands.pop();
59
+ let carry = "0";
60
+ const bands = rawBands.map((b) => {
61
+ const fixed = b.startsWith("#") ? b : `#${carry}${b}`;
62
+ const sels = fixed.match(/#(\d+)/g);
63
+ if (sels) carry = sels[sels.length - 1].slice(1);
64
+ return fixed;
65
+ });
66
+ return { intro, raster, palette, bands, height: bands.length * BAND_PX };
67
+ }
68
+ async function encodeSixelPage(buffer) {
69
+ const raw = (await encodePixels(buffer, { format: "sixel" })).toString("latin1");
70
+ return parseSixelPage(raw);
71
+ }
72
+ function sliceSixelPage(page, { startBand, numBands }) {
73
+ const total = page.bands.length;
74
+ const k = Math.max(1, Math.min(numBands, total));
75
+ const start = Math.max(0, Math.min(startBand, total - k));
76
+ const body = page.bands.slice(start, start + k).join("-");
77
+ const r = page.raster;
78
+ const str = `${page.intro}"${r.pan};${r.pad};${r.ph};${k * BAND_PX}${page.palette}${body}\x1B\\`;
79
+ return { sixel: Buffer.from(str, "latin1"), startBand: start, bands: total };
80
+ }
81
+ export {
82
+ encodePixels,
83
+ encodeSixelPage,
84
+ parseSixelPage,
85
+ prepareImage,
86
+ scalePage,
87
+ sliceSixelPage
88
+ };
@@ -0,0 +1,309 @@
1
+ import { getSource } from "./sources/index.js";
2
+ import { setProgress } from "./state/store.js";
3
+ import { chapterLabel } from "./domain/shape.js";
4
+ import { encodePixels, prepareImage, scalePage, encodeSixelPage, sliceSixelPage } from "./render/sixel.js";
5
+ import { logger } from "./lib/logger.js";
6
+ const ESC = "\x1B";
7
+ const SYNC_BEGIN = Buffer.from(`${ESC}[?2026h`, "latin1");
8
+ const SYNC_END = Buffer.from(`${ESC}[?2026l`, "latin1");
9
+ async function runViewer({ sourceId, manga, chapters, chapterIndex, startPage = 0, caps = {} }) {
10
+ const source = getSource(sourceId);
11
+ const { stdin, stdout } = process;
12
+ const format = caps.kitty ? "kitty" : "sixel";
13
+ let ci = chapterIndex;
14
+ let pi = startPage;
15
+ let scroll = 0;
16
+ let fitWidth = true;
17
+ let pages = null;
18
+ let maxScroll = 0;
19
+ const cellH = caps.cellH || 20;
20
+ const gcd = (a, b) => b ? gcd(b, a % b) : a;
21
+ const slotBands = cellH / gcd(6, cellH);
22
+ const slotCells = 6 / gcd(6, cellH);
23
+ const deltaScroll = format === "sixel" && process.env.KOMADO_SCROLL_DELTA === "1";
24
+ const scrollStep = deltaScroll ? slotCells : 2;
25
+ let shownTop = null;
26
+ let shownSig = null;
27
+ let rawKey = null;
28
+ let rawBuf = null;
29
+ let scaledKey = null;
30
+ let scaledBuf = null;
31
+ let sixelKey = null;
32
+ let sixelPg = null;
33
+ let inputSeq = 0;
34
+ let drawing = false;
35
+ let pending = false;
36
+ let pendingFullClear = false;
37
+ const size = () => ({
38
+ cols: Math.max(20, stdout.columns || 80),
39
+ rows: Math.max(6, stdout.rows || 24)
40
+ });
41
+ async function ensurePages() {
42
+ if (pages) return;
43
+ pages = await source.getPages(chapters[ci].id);
44
+ pi = Math.max(0, Math.min(pi, pages.length - 1));
45
+ }
46
+ async function pageBuffer() {
47
+ const key = `${ci}:${pi}`;
48
+ if (rawKey === key && rawBuf) return rawBuf;
49
+ rawBuf = await source.loadPageBuffer(pages[pi]);
50
+ rawKey = key;
51
+ return rawBuf;
52
+ }
53
+ async function scaledPage(cols) {
54
+ const key = `${ci}:${pi}:${cols}:${caps.cellW || ""}`;
55
+ if (scaledKey === key && scaledBuf) return scaledBuf;
56
+ scaledBuf = await scalePage(await pageBuffer(), { cols, cellW: caps.cellW });
57
+ scaledKey = key;
58
+ return scaledBuf;
59
+ }
60
+ async function sixelPageCached(cols) {
61
+ const key = `${ci}:${pi}:${cols}:${caps.cellW || ""}`;
62
+ if (sixelKey === key && sixelPg) return sixelPg;
63
+ const scaled = await scaledPage(cols);
64
+ sixelPg = await encodeSixelPage(scaled.buffer);
65
+ sixelKey = key;
66
+ return sixelPg;
67
+ }
68
+ function statusBar() {
69
+ const { cols } = size();
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 N/P ch \xB7 f fit \xB7 q back";
72
+ const gap = Math.max(1, cols - left.length - right.length - 2);
73
+ return ` ${left}${" ".repeat(gap)}${right} `.slice(0, cols);
74
+ }
75
+ async function draw({ fullClear = false } = {}) {
76
+ const seq = inputSeq;
77
+ const { cols, rows } = size();
78
+ const imgRows = rows - 1;
79
+ try {
80
+ await ensurePages();
81
+ if (!pages.length) throw new Error("This chapter has no hosted pages.");
82
+ let frame = null;
83
+ let imageRows = imgRows;
84
+ let mScroll = 0;
85
+ let sScroll = scroll;
86
+ if (fitWidth && format === "sixel") {
87
+ const page = await sixelPageCached(cols);
88
+ const viewBands = Math.max(1, Math.floor(imgRows * cellH / 6));
89
+ const maxStart = Math.max(0, page.bands.length - viewBands);
90
+ let topBand = Math.round(scroll * cellH / 6);
91
+ if (deltaScroll) topBand = Math.round(topBand / slotBands) * slotBands;
92
+ topBand = Math.max(0, Math.min(topBand, maxStart));
93
+ imageRows = Math.round(viewBands * 6 / cellH);
94
+ mScroll = maxStart * 6 / cellH;
95
+ sScroll = topBand * 6 / cellH;
96
+ const geomSig = `${ci}:${pi}:${cols}:${imgRows}`;
97
+ const reuse = !fullClear && shownTop !== null && shownSig === geomSig;
98
+ const delta = reuse ? topBand - shownTop : 0;
99
+ if (reuse && delta === 0) {
100
+ frame = null;
101
+ } else if (deltaScroll && reuse && delta !== 0 && Math.abs(delta) % slotBands === 0 && Math.abs(delta) < viewBands) {
102
+ frame = buildDeltaFrame(page, { from: shownTop, to: topBand, viewBands, imgRows, rows });
103
+ } else {
104
+ const win = sliceSixelPage(page, { startBand: topBand, numBands: viewBands }).sixel;
105
+ frame = composeFull(win, { fullClear, imageRows, imgRows, rows });
106
+ }
107
+ shownTop = topBand;
108
+ shownSig = geomSig;
109
+ } else {
110
+ shownTop = null;
111
+ let prepared;
112
+ if (fitWidth) {
113
+ const page = await scaledPage(cols);
114
+ prepared = await prepareImage(null, {
115
+ mode: "width",
116
+ cols,
117
+ rows: imgRows,
118
+ scroll,
119
+ cellW: caps.cellW,
120
+ cellH: caps.cellH,
121
+ scaled: page
122
+ });
123
+ } else {
124
+ prepared = await prepareImage(await pageBuffer(), {
125
+ mode: "fit",
126
+ cols,
127
+ rows: imgRows,
128
+ cellW: caps.cellW,
129
+ cellH: caps.cellH
130
+ });
131
+ }
132
+ imageRows = prepared.imageRows;
133
+ mScroll = prepared.maxScroll;
134
+ sScroll = prepared.scroll;
135
+ const sig = `f:${ci}:${pi}:${sScroll}:${cols}:${imgRows}`;
136
+ if (fullClear || sig !== shownSig) {
137
+ const buf = await encodePixels(prepared.buffer, { format });
138
+ frame = composeFull(buf, { fullClear: fullClear || !fitWidth, imageRows, imgRows, rows });
139
+ shownSig = sig;
140
+ }
141
+ }
142
+ if (seq === inputSeq) {
143
+ maxScroll = mScroll;
144
+ scroll = Math.max(0, Math.min(sScroll, mScroll));
145
+ }
146
+ if (frame) stdout.write(Buffer.concat([SYNC_BEGIN, frame, SYNC_END]));
147
+ setProgress(manga.key, {
148
+ source: sourceId,
149
+ mangaId: manga.id,
150
+ mangaTitle: manga.title,
151
+ chapterId: chapters[ci].id,
152
+ chapterNumber: chapters[ci].number,
153
+ page: pi
154
+ });
155
+ if (pi === pages.length - 1 && source.syncChapterRead) {
156
+ source.syncChapterRead(manga.id, chapters[ci].id);
157
+ }
158
+ } catch (err) {
159
+ logger.warn("viewer draw failed", err);
160
+ stdout.write(`${ESC}[2J${ESC}[H${ESC}[0m`);
161
+ stdout.write(`Error: ${err.message}\r
162
+ \r
163
+ N/P chapter \xB7 q back\r
164
+ `);
165
+ }
166
+ }
167
+ function composeFull(sixelBuf, { fullClear, imageRows, imgRows, rows }) {
168
+ const prefix = fullClear ? `${ESC}[2J${ESC}[H` : `${ESC}[H`;
169
+ let suffix = imageRows < imgRows ? `${ESC}[${imageRows + 1};1H${ESC}[0J` : "";
170
+ suffix += `${ESC}[${rows};1H${ESC}[7m${statusBar()}${ESC}[0m`;
171
+ return Buffer.concat([Buffer.from(prefix, "latin1"), sixelBuf, Buffer.from(suffix, "latin1")]);
172
+ }
173
+ function buildDeltaFrame(page, { from, to, viewBands, imgRows, rows }) {
174
+ const delta = to - from;
175
+ const dc = Math.round(Math.abs(delta) * 6 / cellH);
176
+ const region = `${ESC}[1;${imgRows}r`;
177
+ const reset = `${ESC}[r`;
178
+ const status = `${ESC}[${rows};1H${ESC}[7m${statusBar()}${ESC}[0m`;
179
+ let head;
180
+ let strip;
181
+ if (delta > 0) {
182
+ head = `${region}${ESC}[${imgRows};1H${"\n".repeat(dc)}${reset}${ESC}[${imgRows - dc + 1};1H`;
183
+ strip = sliceSixelPage(page, { startBand: from + viewBands, numBands: delta }).sixel;
184
+ } else {
185
+ head = `${region}${ESC}[1;1H${`${ESC}M`.repeat(dc)}${reset}${ESC}[1;1H`;
186
+ strip = sliceSixelPage(page, { startBand: to, numBands: -delta }).sixel;
187
+ }
188
+ return Buffer.concat([Buffer.from(head, "latin1"), strip, Buffer.from(status, "latin1")]);
189
+ }
190
+ function schedule({ fullClear = false } = {}) {
191
+ if (fullClear) pendingFullClear = true;
192
+ if (drawing) {
193
+ pending = true;
194
+ return;
195
+ }
196
+ drawing = true;
197
+ (async () => {
198
+ try {
199
+ do {
200
+ pending = false;
201
+ const fc = pendingFullClear;
202
+ pendingFullClear = false;
203
+ await draw({ fullClear: fc });
204
+ } while (pending);
205
+ } finally {
206
+ drawing = false;
207
+ }
208
+ })();
209
+ }
210
+ function changeChapter(delta) {
211
+ const next = ci + delta;
212
+ if (next < 0 || next >= chapters.length) return false;
213
+ ci = next;
214
+ pi = 0;
215
+ scroll = 0;
216
+ shownTop = null;
217
+ pages = null;
218
+ return true;
219
+ }
220
+ function nextPage() {
221
+ if (pages && pi < pages.length - 1) {
222
+ pi += 1;
223
+ scroll = 0;
224
+ shownTop = null;
225
+ } else changeChapter(1);
226
+ }
227
+ function prevPage() {
228
+ if (pi > 0) {
229
+ pi -= 1;
230
+ scroll = 0;
231
+ shownTop = null;
232
+ } else changeChapter(-1);
233
+ }
234
+ const prevRaw = stdin.isRaw;
235
+ let onKey;
236
+ const keepAlive = setInterval(() => {
237
+ }, 1 << 30);
238
+ const onResize = () => {
239
+ inputSeq += 1;
240
+ schedule({ fullClear: true });
241
+ };
242
+ try {
243
+ await draw({ fullClear: true });
244
+ stdout.on("resize", onResize);
245
+ await new Promise((resolve) => {
246
+ onKey = (data) => {
247
+ const k = data.toString("latin1");
248
+ const pageStep = Math.max(1, size().rows - 2);
249
+ if (k === "q" || k === ESC) {
250
+ resolve();
251
+ return;
252
+ }
253
+ if (k === "n" || k === " ") {
254
+ if (fitWidth && scroll < maxScroll) scroll = Math.min(maxScroll, scroll + pageStep);
255
+ else nextPage();
256
+ } else if (k === "p") {
257
+ if (fitWidth && scroll > 0) scroll = Math.max(0, scroll - pageStep);
258
+ else prevPage();
259
+ } else if (k === "d" || k === `${ESC}[C`) {
260
+ nextPage();
261
+ } else if (k === "a" || k === `${ESC}[D`) {
262
+ prevPage();
263
+ } else if (k === "j" || k === `${ESC}[B`) {
264
+ scroll = Math.min(maxScroll, scroll + scrollStep);
265
+ } else if (k === "k" || k === `${ESC}[A`) {
266
+ scroll = Math.max(0, scroll - scrollStep);
267
+ } else if (k === "N") {
268
+ changeChapter(1);
269
+ } else if (k === "P") {
270
+ changeChapter(-1);
271
+ } else if (k === "f") {
272
+ fitWidth = !fitWidth;
273
+ scroll = 0;
274
+ shownTop = null;
275
+ shownSig = null;
276
+ } else if (k === "g") {
277
+ scroll = 0;
278
+ } else if (k === "G") {
279
+ scroll = maxScroll;
280
+ } else {
281
+ return;
282
+ }
283
+ inputSeq += 1;
284
+ schedule();
285
+ };
286
+ stdin.removeAllListeners("data");
287
+ try {
288
+ stdin.setRawMode(true);
289
+ } catch {
290
+ }
291
+ stdin.resume();
292
+ stdin.ref?.();
293
+ stdin.on("data", onKey);
294
+ });
295
+ } finally {
296
+ clearInterval(keepAlive);
297
+ stdout.removeListener("resize", onResize);
298
+ if (onKey) stdin.removeListener("data", onKey);
299
+ try {
300
+ stdin.setRawMode(prevRaw);
301
+ } catch {
302
+ }
303
+ stdout.write(`${ESC}[2J${ESC}[H${ESC}[0m`);
304
+ }
305
+ return { name: "manga", params: { sourceId, manga } };
306
+ }
307
+ export {
308
+ runViewer
309
+ };
@@ -0,0 +1,17 @@
1
+ import * as mangadex from "./mangadex/index.js";
2
+ import * as local from "./local/index.js";
3
+ const sources = { mangadex, local };
4
+ function getSource(sourceId) {
5
+ const source = sources[sourceId];
6
+ if (!source) throw new Error(`Unknown source: ${sourceId}`);
7
+ return source;
8
+ }
9
+ const SOURCES = sources;
10
+ const REMOTE_SOURCES = Object.values(sources).filter((s) => s.remote);
11
+ const LOCAL_SOURCES = Object.values(sources).filter((s) => !s.remote);
12
+ export {
13
+ LOCAL_SOURCES,
14
+ REMOTE_SOURCES,
15
+ SOURCES,
16
+ getSource
17
+ };
@@ -0,0 +1,68 @@
1
+ import AdmZip from "adm-zip";
2
+ import { readFile } from "node:fs/promises";
3
+ import { naturalSort } from "../../lib/natsort.js";
4
+ const IMAGE_RE = /\.(jpe?g|png|gif|webp|bmp|avif)$/i;
5
+ const isArchive = (name) => /\.(cbz|zip|cbr|rar)$/i.test(name);
6
+ const isRar = (name) => /\.(cbr|rar)$/i.test(name);
7
+ const zipCache = /* @__PURE__ */ new Map();
8
+ function openZip(filePath) {
9
+ let zip = zipCache.get(filePath);
10
+ if (!zip) {
11
+ zip = new AdmZip(filePath);
12
+ zipCache.set(filePath, zip);
13
+ if (zipCache.size > 8) zipCache.delete(zipCache.keys().next().value);
14
+ }
15
+ return zip;
16
+ }
17
+ function listZipImages(filePath) {
18
+ return naturalSort(
19
+ openZip(filePath).getEntries().filter((e) => !e.isDirectory && IMAGE_RE.test(e.entryName)).map((e) => e.entryName)
20
+ );
21
+ }
22
+ function readZipEntry(filePath, entryName) {
23
+ const entry = openZip(filePath).getEntry(entryName);
24
+ if (!entry) throw new Error(`Entry not found in archive: ${entryName}`);
25
+ return entry.getData();
26
+ }
27
+ let unrarPromise = null;
28
+ const getUnrar = () => unrarPromise ??= import("node-unrar-js");
29
+ const rarCache = /* @__PURE__ */ new Map();
30
+ async function openRar(filePath) {
31
+ let extractor = rarCache.get(filePath);
32
+ if (!extractor) {
33
+ const { createExtractorFromData } = await getUnrar();
34
+ const buf = await readFile(filePath);
35
+ extractor = await createExtractorFromData({
36
+ data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
37
+ });
38
+ rarCache.set(filePath, extractor);
39
+ if (rarCache.size > 4) rarCache.delete(rarCache.keys().next().value);
40
+ }
41
+ return extractor;
42
+ }
43
+ async function listRarImages(filePath) {
44
+ const extractor = await openRar(filePath);
45
+ const names = [];
46
+ for (const header of extractor.getFileList().fileHeaders) {
47
+ if (!header.flags.directory && IMAGE_RE.test(header.name)) names.push(header.name);
48
+ }
49
+ return naturalSort(names);
50
+ }
51
+ async function readRarEntry(filePath, entryName) {
52
+ const extractor = await openRar(filePath);
53
+ const [file] = [...extractor.extract({ files: [entryName] }).files];
54
+ if (!file?.extraction) throw new Error(`Failed to extract from RAR: ${entryName}`);
55
+ return Buffer.from(file.extraction);
56
+ }
57
+ async function listArchiveImages(filePath) {
58
+ return isRar(filePath) ? listRarImages(filePath) : listZipImages(filePath);
59
+ }
60
+ async function readArchiveEntry(filePath, entryName) {
61
+ return isRar(filePath) ? readRarEntry(filePath, entryName) : readZipEntry(filePath, entryName);
62
+ }
63
+ export {
64
+ isArchive,
65
+ isRar,
66
+ listArchiveImages,
67
+ readArchiveEntry
68
+ };