komado 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +208 -0
- package/dist/app.js +59 -0
- package/dist/cli.js +196 -0
- package/dist/components/List.js +41 -0
- package/dist/components/screens/ContinueScreen.js +66 -0
- package/dist/components/screens/HomeScreen.js +59 -0
- package/dist/components/screens/LibraryScreen.js +69 -0
- package/dist/components/screens/LoginScreen.js +85 -0
- package/dist/components/screens/MangaScreen.js +107 -0
- package/dist/components/screens/ReaderScreen.js +195 -0
- package/dist/components/screens/SearchScreen.js +111 -0
- package/dist/components/screens/SettingsScreen.js +144 -0
- package/dist/components/ui.js +33 -0
- package/dist/config.js +51 -0
- package/dist/domain/shape.js +44 -0
- package/dist/hooks/useStdoutDimensions.js +17 -0
- package/dist/lib/AppError.js +37 -0
- package/dist/lib/cache.js +54 -0
- package/dist/lib/catchAsync.js +12 -0
- package/dist/lib/envelope.js +15 -0
- package/dist/lib/fetchWithBackoff.js +65 -0
- package/dist/lib/logger.js +41 -0
- package/dist/lib/natsort.js +7 -0
- package/dist/lib/text.js +20 -0
- package/dist/render/chafa.js +56 -0
- package/dist/render/detect.js +86 -0
- package/dist/render/halfblock.js +42 -0
- package/dist/render/image.js +23 -0
- package/dist/render/sixel.js +88 -0
- package/dist/sixel-reader.js +309 -0
- package/dist/sources/index.js +17 -0
- package/dist/sources/local/archive.js +68 -0
- package/dist/sources/local/index.js +147 -0
- package/dist/sources/mangadex/auth.js +102 -0
- package/dist/sources/mangadex/client.js +76 -0
- package/dist/sources/mangadex/index.js +156 -0
- package/dist/sources/mangadex/normalize.js +54 -0
- package/dist/state/store.js +91 -0
- package/dist/ui-context.js +11 -0
- package/package.json +50 -0
- package/src/app.js +73 -0
- package/src/cli.js +218 -0
- package/src/components/List.js +60 -0
- package/src/components/screens/ContinueScreen.js +73 -0
- package/src/components/screens/HomeScreen.js +54 -0
- package/src/components/screens/LibraryScreen.js +79 -0
- package/src/components/screens/LoginScreen.js +92 -0
- package/src/components/screens/MangaScreen.js +125 -0
- package/src/components/screens/ReaderScreen.js +230 -0
- package/src/components/screens/SearchScreen.js +123 -0
- package/src/components/screens/SettingsScreen.js +146 -0
- package/src/components/ui.js +42 -0
- package/src/config.js +49 -0
- package/src/domain/shape.js +47 -0
- package/src/hooks/useStdoutDimensions.js +19 -0
- package/src/lib/AppError.js +26 -0
- package/src/lib/cache.js +57 -0
- package/src/lib/catchAsync.js +12 -0
- package/src/lib/envelope.js +14 -0
- package/src/lib/fetchWithBackoff.js +74 -0
- package/src/lib/logger.js +41 -0
- package/src/lib/natsort.js +7 -0
- package/src/lib/text.js +18 -0
- package/src/render/chafa.js +64 -0
- package/src/render/detect.js +112 -0
- package/src/render/halfblock.js +46 -0
- package/src/render/image.js +24 -0
- package/src/render/sixel.js +141 -0
- package/src/sixel-reader.js +359 -0
- package/src/sources/index.js +17 -0
- package/src/sources/local/archive.js +74 -0
- package/src/sources/local/index.js +155 -0
- package/src/sources/mangadex/auth.js +125 -0
- package/src/sources/mangadex/client.js +83 -0
- package/src/sources/mangadex/index.js +166 -0
- package/src/sources/mangadex/normalize.js +70 -0
- package/src/state/store.js +90 -0
- package/src/ui-context.js +12 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
// Self-contained runtime home. Override with KOMADO_HOME (handy for tests).
|
|
6
|
+
const usingDefaultHome = !process.env.KOMADO_HOME;
|
|
7
|
+
const HOME = usingDefaultHome
|
|
8
|
+
? path.join(os.homedir(), '.komado')
|
|
9
|
+
: path.resolve(process.env.KOMADO_HOME);
|
|
10
|
+
// The pre-rename home (the project used to be "manga-tui") — migrated once on
|
|
11
|
+
// first run so existing config / reading progress / MangaDex login carry over.
|
|
12
|
+
const LEGACY_HOME = path.join(os.homedir(), '.manga-tui');
|
|
13
|
+
|
|
14
|
+
export const paths = {
|
|
15
|
+
home: HOME,
|
|
16
|
+
configFile: path.join(HOME, 'config.json'),
|
|
17
|
+
progressFile: path.join(HOME, 'progress.json'),
|
|
18
|
+
credentialsFile: path.join(HOME, 'credentials.json'),
|
|
19
|
+
cacheDir: path.join(HOME, 'cache'),
|
|
20
|
+
logFile: path.join(HOME, 'komado.log'),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function ensureDirs() {
|
|
24
|
+
// One-time migration from the old ~/.manga-tui home so saved state isn't
|
|
25
|
+
// orphaned. Best-effort, default-home only (same filesystem → rename suffices).
|
|
26
|
+
if (usingDefaultHome && !fs.existsSync(HOME) && fs.existsSync(LEGACY_HOME)) {
|
|
27
|
+
try { fs.renameSync(LEGACY_HOME, HOME); } catch { /* leave the legacy dir as-is */ }
|
|
28
|
+
}
|
|
29
|
+
fs.mkdirSync(paths.home, { recursive: true });
|
|
30
|
+
fs.mkdirSync(paths.cacheDir, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_CONFIG = {
|
|
34
|
+
localLibraryPaths: [], // directories scanned by the local source
|
|
35
|
+
language: 'en', // preferred MangaDex translatedLanguage
|
|
36
|
+
contentRating: ['safe', 'suggestive'],
|
|
37
|
+
dataSaver: true, // smaller MangaDex page images — ideal for a terminal
|
|
38
|
+
renderer: 'auto', // auto | halfblock | chafa
|
|
39
|
+
theme: 'default',
|
|
40
|
+
syncProgress: true, // push read-markers to MangaDex while logged in
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const MANGADEX = {
|
|
44
|
+
api: 'https://api.mangadex.org',
|
|
45
|
+
auth: 'https://auth.mangadex.org/realms/mangadex/protocol/openid-connect/token',
|
|
46
|
+
uploads: 'https://uploads.mangadex.org',
|
|
47
|
+
userAgent: 'komado/0.1 (+https://github.com/RyuPrad/komado)',
|
|
48
|
+
pageLimit: 20,
|
|
49
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The unified data-shape contract. Rows from MangaDex and from the local
|
|
2
|
+
// filesystem are normalized into ONE Manga/Chapter shape, so every downstream
|
|
3
|
+
// consumer (search, reader, progress, UI) treats them identically. The only
|
|
4
|
+
// source-specific seam is `source.loadPageBuffer()`, which resolves raw bytes.
|
|
5
|
+
|
|
6
|
+
export const globalKey = (source, id) => `${source}:${id}`;
|
|
7
|
+
|
|
8
|
+
export function makeManga(p) {
|
|
9
|
+
return {
|
|
10
|
+
source: p.source,
|
|
11
|
+
id: String(p.id),
|
|
12
|
+
key: globalKey(p.source, p.id),
|
|
13
|
+
title: p.title || 'Untitled',
|
|
14
|
+
altTitles: p.altTitles || [],
|
|
15
|
+
description: p.description || '',
|
|
16
|
+
authors: p.authors || [],
|
|
17
|
+
status: p.status || 'unknown',
|
|
18
|
+
tags: p.tags || [],
|
|
19
|
+
coverUrl: p.coverUrl || null,
|
|
20
|
+
language: p.language || 'en',
|
|
21
|
+
chaptersCount: p.chaptersCount ?? null,
|
|
22
|
+
raw: p.raw,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function makeChapter(p) {
|
|
27
|
+
return {
|
|
28
|
+
source: p.source,
|
|
29
|
+
id: String(p.id),
|
|
30
|
+
key: globalKey(p.source, p.id),
|
|
31
|
+
mangaKey: p.mangaKey,
|
|
32
|
+
number: p.number ?? null, // string like "12.5", or null for oneshots
|
|
33
|
+
volume: p.volume ?? null,
|
|
34
|
+
title: p.title || '',
|
|
35
|
+
language: p.language || 'en',
|
|
36
|
+
pages: p.pages ?? null,
|
|
37
|
+
publishedAt: p.publishedAt || null,
|
|
38
|
+
raw: p.raw,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function chapterLabel(ch) {
|
|
43
|
+
const head = ch.number != null && ch.number !== ''
|
|
44
|
+
? `Ch. ${ch.number}${ch.volume != null && ch.volume !== '' ? ` (Vol. ${ch.volume})` : ''}`
|
|
45
|
+
: 'Oneshot';
|
|
46
|
+
return ch.title ? `${head} — ${ch.title}` : head;
|
|
47
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
// Terminal size + live updates on resize. The reader recomputes its viewport
|
|
4
|
+
// and re-renders the page at the new width from this.
|
|
5
|
+
export function useStdoutDimensions() {
|
|
6
|
+
const read = () => ({
|
|
7
|
+
cols: process.stdout.columns || 80,
|
|
8
|
+
rows: process.stdout.rows || 24,
|
|
9
|
+
});
|
|
10
|
+
const [size, setSize] = useState(read);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const onResize = () => setSize(read());
|
|
14
|
+
process.stdout.on('resize', onResize);
|
|
15
|
+
return () => process.stdout.off('resize', onResize);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
return size;
|
|
19
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Typed, operational errors — same idea as your server AppError, minus HTTP plumbing.
|
|
2
|
+
// statusCode is kept for parity/log triage; the TUI maps these to friendly messages.
|
|
3
|
+
export class AppError extends Error {
|
|
4
|
+
constructor(message, statusCode = 500, opts = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = this.constructor.name;
|
|
7
|
+
this.statusCode = statusCode;
|
|
8
|
+
this.isOperational = true;
|
|
9
|
+
if (opts.cause) this.cause = opts.cause;
|
|
10
|
+
if (opts.meta) Object.assign(this, opts.meta);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Critical-vs-decorative branching at the call site, instead of string-matching.
|
|
15
|
+
export class NotFoundError extends AppError {
|
|
16
|
+
constructor(message = 'Not found', opts) { super(message, 404, opts); }
|
|
17
|
+
}
|
|
18
|
+
export class SourceError extends AppError {
|
|
19
|
+
constructor(message = 'Source unavailable', opts) { super(message, 502, opts); }
|
|
20
|
+
}
|
|
21
|
+
export class UnsupportedError extends AppError {
|
|
22
|
+
constructor(message = 'Unsupported', opts) { super(message, 422, opts); }
|
|
23
|
+
}
|
|
24
|
+
export class AuthError extends AppError {
|
|
25
|
+
constructor(message = 'Authentication required', opts) { super(message, 401, opts); }
|
|
26
|
+
}
|
package/src/lib/cache.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// In-memory cache with TTL, negative caching, and stampede protection —
|
|
2
|
+
// a port of your createCache. `wrap` shares a single in-flight promise per key
|
|
3
|
+
// so concurrent callers (e.g. two screens requesting the same chapter) collapse
|
|
4
|
+
// into one upstream request.
|
|
5
|
+
export function createCache({ ttlMs = 60_000, negativeTtlMs = 5_000, max = 500 } = {}) {
|
|
6
|
+
const store = new Map(); // key -> { value, expires }
|
|
7
|
+
const inflight = new Map(); // key -> Promise
|
|
8
|
+
|
|
9
|
+
function get(key) {
|
|
10
|
+
const entry = store.get(key);
|
|
11
|
+
if (!entry) return undefined;
|
|
12
|
+
if (entry.expires <= Date.now()) {
|
|
13
|
+
store.delete(key);
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
return entry.value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function set(key, value, ttl) {
|
|
20
|
+
const isEmpty = value === null || value === undefined;
|
|
21
|
+
const life = ttl ?? (isEmpty ? negativeTtlMs : ttlMs);
|
|
22
|
+
store.set(key, { value, expires: Date.now() + life });
|
|
23
|
+
// Cheap bound: evict the oldest insertion when over capacity.
|
|
24
|
+
if (store.size > max) {
|
|
25
|
+
const oldest = store.keys().next().value;
|
|
26
|
+
store.delete(oldest);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function wrap(key, fn, ttl) {
|
|
31
|
+
const cached = get(key);
|
|
32
|
+
if (cached !== undefined) return cached; // note: a cached `null` is a hit (negative cache)
|
|
33
|
+
if (inflight.has(key)) return inflight.get(key);
|
|
34
|
+
|
|
35
|
+
const promise = (async () => {
|
|
36
|
+
try {
|
|
37
|
+
const value = await fn();
|
|
38
|
+
set(key, value, ttl);
|
|
39
|
+
return value;
|
|
40
|
+
} finally {
|
|
41
|
+
inflight.delete(key);
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
|
|
45
|
+
inflight.set(key, promise);
|
|
46
|
+
return promise;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
get,
|
|
51
|
+
set,
|
|
52
|
+
wrap,
|
|
53
|
+
delete: (key) => store.delete(key),
|
|
54
|
+
clear: () => { store.clear(); inflight.clear(); },
|
|
55
|
+
get size() { return store.size; },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// TUI flavour of your server catchAsync. Instead of forwarding to next(err),
|
|
2
|
+
// it resolves a { data, error } result so hooks can drop straight into state
|
|
3
|
+
// without a try/catch at every call site (and never crash the render loop).
|
|
4
|
+
export function catchAsync(fn) {
|
|
5
|
+
return async (...args) => {
|
|
6
|
+
try {
|
|
7
|
+
return { data: await fn(...args), error: null };
|
|
8
|
+
} catch (error) {
|
|
9
|
+
return { data: null, error };
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Consistent { data, pagination, meta } shape across every source, exactly like
|
|
2
|
+
// your API envelope. Sources return this so hooks/UI are source-agnostic.
|
|
3
|
+
export function envelope(data, { pagination = null, meta = {} } = {}) {
|
|
4
|
+
return { data, pagination, meta };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function paginate({ offset = 0, limit = 0, total = 0 } = {}) {
|
|
8
|
+
return {
|
|
9
|
+
offset,
|
|
10
|
+
limit,
|
|
11
|
+
total,
|
|
12
|
+
hasMore: total > 0 ? offset + limit < total : false,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { SourceError } from './AppError.js';
|
|
2
|
+
|
|
3
|
+
function sleep(ms, signal) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
if (signal?.aborted) return reject(signal.reason ?? new Error('aborted'));
|
|
6
|
+
const timer = setTimeout(resolve, ms);
|
|
7
|
+
signal?.addEventListener('abort', () => {
|
|
8
|
+
clearTimeout(timer);
|
|
9
|
+
reject(signal.reason ?? new Error('aborted'));
|
|
10
|
+
}, { once: true });
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Global fetch + retries on 429/5xx with exponential backoff, honouring
|
|
15
|
+
// Retry-After, plus a hard per-attempt timeout. Caller-supplied AbortSignal
|
|
16
|
+
// short-circuits retries (used by the hooks' cancelled-flag guard).
|
|
17
|
+
export async function fetchWithBackoff(url, options = {}) {
|
|
18
|
+
const {
|
|
19
|
+
retries = 4,
|
|
20
|
+
baseDelayMs = 500,
|
|
21
|
+
maxDelayMs = 8_000,
|
|
22
|
+
timeoutMs = 20_000,
|
|
23
|
+
signal: extSignal,
|
|
24
|
+
...fetchOpts
|
|
25
|
+
} = options;
|
|
26
|
+
|
|
27
|
+
let attempt = 0;
|
|
28
|
+
for (;;) {
|
|
29
|
+
const ctrl = new AbortController();
|
|
30
|
+
const onExtAbort = () => ctrl.abort(extSignal.reason);
|
|
31
|
+
if (extSignal) {
|
|
32
|
+
if (extSignal.aborted) ctrl.abort(extSignal.reason);
|
|
33
|
+
else extSignal.addEventListener('abort', onExtAbort, { once: true });
|
|
34
|
+
}
|
|
35
|
+
const timer = setTimeout(() => ctrl.abort(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(url, { ...fetchOpts, signal: ctrl.signal });
|
|
39
|
+
|
|
40
|
+
if ((res.status === 429 || res.status >= 500) && attempt < retries) {
|
|
41
|
+
const retryAfter = Number(res.headers.get('retry-after'));
|
|
42
|
+
const delay = Number.isFinite(retryAfter) && retryAfter > 0
|
|
43
|
+
? retryAfter * 1000
|
|
44
|
+
: Math.min(maxDelayMs, baseDelayMs * 2 ** attempt) + Math.random() * 200;
|
|
45
|
+
attempt += 1;
|
|
46
|
+
await sleep(delay, extSignal);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
return res;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// Caller cancelled — propagate without retrying.
|
|
52
|
+
if (extSignal?.aborted) throw err;
|
|
53
|
+
if (attempt < retries) {
|
|
54
|
+
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt) + Math.random() * 200;
|
|
55
|
+
attempt += 1;
|
|
56
|
+
await sleep(delay, extSignal);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
throw new SourceError(`Request failed: ${url}`, { cause: err });
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
extSignal?.removeEventListener('abort', onExtAbort);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Convenience JSON wrapper that throws a typed error on non-2xx.
|
|
68
|
+
export async function fetchJson(url, options = {}) {
|
|
69
|
+
const res = await fetchWithBackoff(url, options);
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
throw new SourceError(`HTTP ${res.status} for ${url}`, { meta: { statusCode: res.status } });
|
|
72
|
+
}
|
|
73
|
+
return res.json();
|
|
74
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { paths } from '../config.js';
|
|
3
|
+
|
|
4
|
+
// A TUI owns stdout — console.log would corrupt the Ink render. So debug output
|
|
5
|
+
// goes to a file, only when KOMADO_DEBUG is set. Tail it with:
|
|
6
|
+
// tail -f ~/.komado/komado.log
|
|
7
|
+
const enabled = !!process.env.KOMADO_DEBUG;
|
|
8
|
+
let stream = null;
|
|
9
|
+
|
|
10
|
+
function out() {
|
|
11
|
+
if (!enabled) return null;
|
|
12
|
+
if (!stream) {
|
|
13
|
+
try {
|
|
14
|
+
fs.mkdirSync(paths.home, { recursive: true });
|
|
15
|
+
stream = fs.createWriteStream(paths.logFile, { flags: 'a' });
|
|
16
|
+
} catch {
|
|
17
|
+
stream = null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return stream;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fmt(arg) {
|
|
24
|
+
if (typeof arg === 'string') return arg;
|
|
25
|
+
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
|
|
26
|
+
try { return JSON.stringify(arg); } catch { return String(arg); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function write(level, args) {
|
|
30
|
+
const s = out();
|
|
31
|
+
if (!s) return;
|
|
32
|
+
s.write(`[${new Date().toISOString()}] ${level} ${args.map(fmt).join(' ')}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const logger = {
|
|
36
|
+
enabled,
|
|
37
|
+
debug: (...a) => write('DEBUG', a),
|
|
38
|
+
info: (...a) => write('INFO', a),
|
|
39
|
+
warn: (...a) => write('WARN', a),
|
|
40
|
+
error: (...a) => write('ERROR', a),
|
|
41
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Natural ("human") ordering so page2 < page10 and "Chapter 1.5" sorts sanely.
|
|
2
|
+
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
|
3
|
+
|
|
4
|
+
export const naturalCompare = (a, b) => collator.compare(String(a), String(b));
|
|
5
|
+
|
|
6
|
+
export const naturalSort = (arr, key = (x) => x) =>
|
|
7
|
+
[...arr].sort((a, b) => naturalCompare(key(a), key(b)));
|
package/src/lib/text.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export 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)) + '…';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Compact relative time ("3h ago") in LOCAL time, anchored to now.
|
|
8
|
+
export function relativeTime(ts) {
|
|
9
|
+
if (!ts) return '';
|
|
10
|
+
const diff = Date.now() - ts;
|
|
11
|
+
const mins = Math.round(diff / 60_000);
|
|
12
|
+
if (mins < 1) return 'just now';
|
|
13
|
+
if (mins < 60) return `${mins}m ago`;
|
|
14
|
+
const hrs = Math.round(mins / 60);
|
|
15
|
+
if (hrs < 24) return `${hrs}h ago`;
|
|
16
|
+
const days = Math.round(hrs / 24);
|
|
17
|
+
return `${days}d ago`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
let dirReady = false;
|
|
12
|
+
async function ensureTempDir() {
|
|
13
|
+
if (dirReady) return;
|
|
14
|
+
await mkdir(paths.cacheDir, { recursive: true });
|
|
15
|
+
dirReady = true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function withTempImage(buffer, fn) {
|
|
19
|
+
await ensureTempDir();
|
|
20
|
+
const file = path.join(
|
|
21
|
+
paths.cacheDir,
|
|
22
|
+
`chafa-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.png`,
|
|
23
|
+
);
|
|
24
|
+
await writeFile(file, buffer);
|
|
25
|
+
try {
|
|
26
|
+
return await fn(file);
|
|
27
|
+
} finally {
|
|
28
|
+
unlink(file).catch(() => {});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Cell-based symbol output (truecolor ANSI). Compatible with Ink's <Text>
|
|
33
|
+
// layout, so the reader can scroll it like the half-block output. We size the
|
|
34
|
+
// box to the image's natural aspect to avoid chafa padding the result.
|
|
35
|
+
export async function renderChafaSymbols(buffer, { cols = 80 } = {}) {
|
|
36
|
+
const meta = await sharp(buffer).metadata();
|
|
37
|
+
const aspect = (meta.height || 1) / (meta.width || 1);
|
|
38
|
+
const rows = Math.max(1, Math.round((aspect * cols) / 2)); // cells are ~2x tall
|
|
39
|
+
|
|
40
|
+
const stdout = await withTempImage(buffer, (file) =>
|
|
41
|
+
execFileAsync(
|
|
42
|
+
'chafa',
|
|
43
|
+
['--format', 'symbols', '--colors', 'full', '--size', `${cols}x${rows}`, file],
|
|
44
|
+
{ maxBuffer: 96 * 1024 * 1024 },
|
|
45
|
+
).then((r) => r.stdout),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const lines = stdout.replace(/\n+$/, '').split('\n');
|
|
49
|
+
return { lines, cols, rows: lines.length };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fullscreen one-shot: print straight to the inherited TTY so chafa can probe
|
|
53
|
+
// the real terminal and auto-select kitty > sixel > symbols. Used by the
|
|
54
|
+
// reader's "high-fidelity" toggle. Returns false if chafa is unavailable.
|
|
55
|
+
export function spawnChafaToTerminal(file, { cols, rows } = {}) {
|
|
56
|
+
if (!detectCapabilities().chafa) return false;
|
|
57
|
+
const args = ['--colors', 'full'];
|
|
58
|
+
if (cols && rows) args.push('--size', `${cols}x${rows}`);
|
|
59
|
+
args.push(file);
|
|
60
|
+
const res = spawnSync('chafa', args, { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
61
|
+
return res.status === 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { withTempImage };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
let cached = null;
|
|
4
|
+
|
|
5
|
+
// Detect terminal/image capabilities once. Conservative: we never assume a
|
|
6
|
+
// pixel protocol unless there's a strong signal, because the universal
|
|
7
|
+
// half-block path always works.
|
|
8
|
+
export function detectCapabilities() {
|
|
9
|
+
if (cached) return cached;
|
|
10
|
+
const env = process.env;
|
|
11
|
+
const term = env.TERM || '';
|
|
12
|
+
const termProgram = env.TERM_PROGRAM || '';
|
|
13
|
+
|
|
14
|
+
const kitty =
|
|
15
|
+
!!env.KITTY_WINDOW_ID ||
|
|
16
|
+
term.includes('kitty') ||
|
|
17
|
+
termProgram === 'ghostty' ||
|
|
18
|
+
termProgram === 'WezTerm';
|
|
19
|
+
|
|
20
|
+
// Sixel is hard to probe without a terminal round-trip; trust an explicit hint
|
|
21
|
+
// or a couple of known sixel-first terminals.
|
|
22
|
+
const sixel =
|
|
23
|
+
/sixel/i.test(env.KOMADO_CAPS || '') ||
|
|
24
|
+
term === 'foot' || term.includes('foot') || term.includes('mlterm');
|
|
25
|
+
|
|
26
|
+
// We always emit 24-bit colour; non-truecolor terminals degrade gracefully.
|
|
27
|
+
const truecolor = env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit';
|
|
28
|
+
|
|
29
|
+
let chafa = false;
|
|
30
|
+
let chafaVersion = null;
|
|
31
|
+
try {
|
|
32
|
+
const out = execFileSync('chafa', ['--version'], {
|
|
33
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
34
|
+
}).toString();
|
|
35
|
+
chafa = true;
|
|
36
|
+
chafaVersion = (out.match(/version\s+([\d.]+)/i) || [])[1] || 'unknown';
|
|
37
|
+
} catch {
|
|
38
|
+
/* chafa not on PATH — half-block fallback */
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
cached = { term, termProgram, kitty, sixel, truecolor, chafa, chafaVersion };
|
|
42
|
+
return cached;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Inline (scrollable) reader backend. Both options produce an array of terminal
|
|
46
|
+
// lines, so the reader can slice a vertical window for panning.
|
|
47
|
+
export function pickInlineBackend(config, caps = detectCapabilities()) {
|
|
48
|
+
const pref = config?.renderer || 'auto';
|
|
49
|
+
if (pref === 'halfblock') return 'halfblock';
|
|
50
|
+
if (pref === 'chafa') return caps.chafa ? 'chafa-symbols' : 'halfblock';
|
|
51
|
+
// auto: chafa's symbol output is sharper than raw half-blocks when available.
|
|
52
|
+
return caps.chafa ? 'chafa-symbols' : 'halfblock';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Cycle order for the in-reader "switch renderer" key.
|
|
56
|
+
export const RENDERER_CYCLE = ['auto', 'halfblock', 'chafa'];
|
|
57
|
+
|
|
58
|
+
// Actively ask the terminal what it supports by emitting a kitty-graphics
|
|
59
|
+
// support query (APC G) + primary Device Attributes (DA1), then reading the
|
|
60
|
+
// replies. DA1 returns ";4" when sixel is supported; a kitty ";OK" reply means
|
|
61
|
+
// the kitty graphics protocol is available. Needs a real TTY on both ends.
|
|
62
|
+
export async function probeTerminal({ timeoutMs = 350 } = {}) {
|
|
63
|
+
const { stdin, stdout } = process;
|
|
64
|
+
if (!stdout.isTTY || !stdin.isTTY) {
|
|
65
|
+
return { queried: false, sixel: false, kitty: false, cellW: null, cellH: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
let buf = '';
|
|
70
|
+
const prevRaw = stdin.isRaw;
|
|
71
|
+
const onData = (d) => { buf += d.toString('latin1'); };
|
|
72
|
+
|
|
73
|
+
try { stdin.setRawMode(true); } catch { /* ignore */ }
|
|
74
|
+
stdin.resume();
|
|
75
|
+
stdin.on('data', onData);
|
|
76
|
+
// kitty support · cell size (16t) · text-area px (14t) · text-area chars
|
|
77
|
+
// (18t) · primary DA. Replies are collected together over the timeout.
|
|
78
|
+
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');
|
|
79
|
+
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
stdin.removeListener('data', onData);
|
|
82
|
+
try { stdin.setRawMode(prevRaw); } catch { /* ignore */ }
|
|
83
|
+
stdin.pause();
|
|
84
|
+
/* eslint-disable no-control-regex */ // matching raw terminal escape replies
|
|
85
|
+
const kitty = /\x1b_G[^\x1b]*;OK/.test(buf);
|
|
86
|
+
const da = buf.match(/\x1b\[\?([0-9;]+)c/);
|
|
87
|
+
const cell = buf.match(/\x1b\[6;(\d+);(\d+)t/); // CSI 6 ; cellH ; cellW t
|
|
88
|
+
const areaPx = buf.match(/\x1b\[4;(\d+);(\d+)t/); // CSI 4 ; areaH ; areaW t (px)
|
|
89
|
+
const areaCh = buf.match(/\x1b\[8;(\d+);(\d+)t/); // CSI 8 ; rows ; cols t
|
|
90
|
+
/* eslint-enable no-control-regex */
|
|
91
|
+
|
|
92
|
+
const sixel = da ? da[1].split(';').includes('4') : false;
|
|
93
|
+
let cellW = null;
|
|
94
|
+
let cellH = null;
|
|
95
|
+
if (cell) {
|
|
96
|
+
cellH = Number(cell[1]);
|
|
97
|
+
cellW = Number(cell[2]);
|
|
98
|
+
} else if (areaPx && areaCh) {
|
|
99
|
+
const cols = Number(areaCh[2]);
|
|
100
|
+
const rows = Number(areaCh[1]);
|
|
101
|
+
if (cols > 0 && rows > 0) {
|
|
102
|
+
cellW = Number(areaPx[2]) / cols;
|
|
103
|
+
cellH = Number(areaPx[1]) / rows;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!(cellW > 2 && cellW < 64)) cellW = null; // ignore absurd values
|
|
107
|
+
if (!(cellH > 2 && cellH < 96)) cellH = null;
|
|
108
|
+
|
|
109
|
+
resolve({ queried: true, sixel, kitty, cellW, cellH });
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
|
|
3
|
+
const ESC = '\x1b';
|
|
4
|
+
const RESET = `${ESC}[0m`;
|
|
5
|
+
|
|
6
|
+
// Render an image buffer to terminal lines using the upper-half-block glyph (▀):
|
|
7
|
+
// the glyph's foreground paints the TOP half of the cell, the background the
|
|
8
|
+
// BOTTOM half — so each character encodes two vertical pixels at 24-bit colour.
|
|
9
|
+
// One column == one pixel wide, one row == two pixels tall.
|
|
10
|
+
export async function renderHalfBlock(buffer, { cols = 80 } = {}) {
|
|
11
|
+
const targetWidth = Math.max(1, Math.min(Math.floor(cols), 400));
|
|
12
|
+
|
|
13
|
+
const { data, info } = await sharp(buffer)
|
|
14
|
+
.resize({ width: targetWidth, fit: 'inside', withoutEnlargement: false })
|
|
15
|
+
.flatten({ background: '#ffffff' }) // composite any transparency onto white
|
|
16
|
+
.raw()
|
|
17
|
+
.toBuffer({ resolveWithObject: true });
|
|
18
|
+
|
|
19
|
+
const { width, height, channels } = info;
|
|
20
|
+
|
|
21
|
+
const px = (x, y) => {
|
|
22
|
+
const i = (y * width + x) * channels;
|
|
23
|
+
if (channels === 1) { const v = data[i]; return [v, v, v]; }
|
|
24
|
+
return [data[i], data[i + 1], data[i + 2]];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const lines = [];
|
|
28
|
+
for (let y = 0; y < height; y += 2) {
|
|
29
|
+
let line = '';
|
|
30
|
+
let lastFg = null;
|
|
31
|
+
let lastBg = null;
|
|
32
|
+
for (let x = 0; x < width; x++) {
|
|
33
|
+
const [tr, tg, tb] = px(x, y);
|
|
34
|
+
const [br, bg, bb] = y + 1 < height ? px(x, y + 1) : [tr, tg, tb];
|
|
35
|
+
const fg = `${tr};${tg};${tb}`;
|
|
36
|
+
const bgc = `${br};${bg};${bb}`;
|
|
37
|
+
// Emit colour codes only when they change — keeps lines compact.
|
|
38
|
+
if (fg !== lastFg) { line += `${ESC}[38;2;${fg}m`; lastFg = fg; }
|
|
39
|
+
if (bgc !== lastBg) { line += `${ESC}[48;2;${bgc}m`; lastBg = bgc; }
|
|
40
|
+
line += '▀';
|
|
41
|
+
}
|
|
42
|
+
lines.push(line + RESET);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { lines, cols: width, rows: lines.length };
|
|
46
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
|
|
6
|
+
export async function imageSize(buffer) {
|
|
7
|
+
const { width = 1, height = 1 } = await sharp(buffer).metadata();
|
|
8
|
+
return { width, height };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Dispatch a buffer to the chosen inline backend, producing { lines, cols, rows }.
|
|
12
|
+
// Always falls back to half-block so a chafa hiccup never blanks the reader —
|
|
13
|
+
// the same "graceful degradation" idea as your SSR→static fallback.
|
|
14
|
+
export async function renderInline(buffer, { cols = 80, backend = 'halfblock' } = {}) {
|
|
15
|
+
if (backend === 'chafa-symbols') {
|
|
16
|
+
try {
|
|
17
|
+
return await renderChafaSymbols(buffer, { cols });
|
|
18
|
+
} catch (err) {
|
|
19
|
+
logger.warn('chafa render failed, falling back to half-block', err);
|
|
20
|
+
return renderHalfBlock(buffer, { cols });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return renderHalfBlock(buffer, { cols });
|
|
24
|
+
}
|