From 07d04a351a743e0b87017c41ae9e1c2fe1c7d46b Mon Sep 17 00:00:00 2001 From: Maped Date: Fri, 4 Sep 2026 16:58:44 +0800 Subject: [PATCH] feat(tui): add localized library browser Replace the rejected local Web UI with an interactive workflow that keeps all browsing in the terminal and opens external Fab pages only on user action. --- README.md | 17 +- src/cli.ts | 28 +++ src/download.ts | 32 +-- src/locales/en.json | 33 +++ src/locales/zh-CN.json | 33 +++ src/tui.ts | 445 +++++++++++++++++++++++++++++++++++++++++ src/tuiI18n.ts | 60 ++++++ 7 files changed, 634 insertions(+), 14 deletions(-) create mode 100644 src/locales/en.json create mode 100644 src/locales/zh-CN.json create mode 100644 src/tui.ts create mode 100644 src/tuiI18n.ts diff --git a/README.md b/README.md index 5b1050e..cedfca6 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,24 @@ Tokens land at `~/.config/epic-fab/auth.json` (mode `600`, never in URLs, never | `epic-fab list` | JSON dump of every owned Fab asset (id, title, type, ownedAt) | | `epic-fab download --into ` | Download a single asset to a target directory | | `epic-fab sync --project ` | Bulk-download library into a UE project's `Content/Fab/` tree | +| `epic-fab tui` | Browse and filter interactively; `Enter` downloads and `Ctrl+O` opens Fab | | `epic-fab logout` | Delete persisted auth tokens | -Every command exits with a meaningful code (`0` OK, `1` user error, `2` not authenticated, `3` network error) and emits JSON on stdout for piping. +Every command exits with a meaningful code (`0` OK, `1` user error, `2` not authenticated, `3` network error). The non-interactive result commands emit JSON on stdout for piping; `tui` is interactive. + +### Interactive TUI + +```bash +epic-fab tui --lang zh-CN +``` + +The TUI filters as you type. Use `↑`/`↓` to select, `Enter` to download, `Ctrl+O` +to open the original Fab listing, `Ctrl+I` to open the selected asset's thumbnail, +and `Esc` to quit. +All TUI text lives in [`src/locales/en.json`](./src/locales/en.json) and +[`src/locales/zh-CN.json`](./src/locales/zh-CN.json). Add any +`src/locales/.json` file (for example, `fr-FR.json`) and use +`epic-fab tui --lang `; a missing or invalid file falls back to English. --- diff --git a/src/cli.ts b/src/cli.ts index 8619c33..df66819 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,8 @@ import { } from "./api.ts"; import { downloadAsset } from "./download.ts"; import { createInterface } from "node:readline/promises"; +import { runTui, safeTerminalText, TuiUserError } from "./tui.ts"; +import { loadTuiTranslator } from "./tuiI18n.ts"; const USAGE = `epic-fab — Epic Games / Fab.com asset library on Linux @@ -30,6 +32,8 @@ Commands: list [--json] List owned Fab assets download [...] Download asset(s) to disk sync --project Bulk-download library into a UE project's Content/ + tui [--lang ] + Browse, filter, and download assets interactively whoami Show current authenticated Epic account logout Delete persisted auth tokens @@ -41,6 +45,7 @@ Options: --project sync: UE project root --concurrency CDN chunk fetch concurrency (default 8) --no-skip Redownload even when on-disk SHA1 matches + --lang tui: locale-file name (default: $LANG, fallback en) `; const VERSION = "0.1.0"; @@ -350,6 +355,27 @@ async function cmdSync(argv: ReadonlyArray, engineVersion: string): Prom } } +async function cmdTui(argv: ReadonlyArray, engineVersion: string): Promise { + const translate = await loadTuiTranslator(findFlagValue(argv, "--lang") ?? process.env["LANG"]); + const tokens = await loadAndRefresh(); + if (!tokens) { + console.error(translate("not_authenticated")); + return EXIT_NOT_AUTHENTICATED; + } + + try { + const result = await runTui(tokens, { + engineVersion, + locale: findFlagValue(argv, "--lang"), + }); + return result.downloadFailed ? EXIT_NETWORK_ERROR : EXIT_OK; + } catch (err) { + const error = safeTerminalText(err instanceof Error ? err.message : String(err)); + console.error(translate("tui_failed", { error })); + return err instanceof TuiUserError ? EXIT_USER_ERROR : EXIT_NETWORK_ERROR; + } +} + async function cmdWhoami(): Promise { const tokens = await loadTokens(); if (!tokens) { @@ -396,6 +422,8 @@ async function main(argv: string[]): Promise { return cmdDownload(rest, engineVersion); case "sync": return cmdSync(rest, engineVersion); + case "tui": + return cmdTui(rest, engineVersion); case "whoami": return cmdWhoami(); case "logout": diff --git a/src/download.ts b/src/download.ts index d3c6bf5..1f9ed19 100644 --- a/src/download.ts +++ b/src/download.ts @@ -12,6 +12,8 @@ import { chunkPath, decodeChunkPayload, type ChunkInfo, type ChunkPart } from ". export interface DownloadOptions { targetDir: string; preserveStructure: boolean; + /** Suppress the built-in stderr progress lines for an interactive caller. */ + quiet?: boolean; /** Max concurrent CDN chunk fetches. Default 8; Epic CDNs often rate-limit around 16+. */ concurrency?: number; /** Skip files whose on-disk SHA1 already matches the manifest. Default true. */ @@ -368,7 +370,7 @@ export async function downloadAsset( }; const toDownload: PlannedFile[] = []; - if (skipExisting) { + if (skipExisting && !opts.quiet) { process.stderr.write(`Checking ${fileTotal} on-disk file(s) for SHA1 match…\n`); } @@ -383,7 +385,7 @@ export async function downloadAsset( bytesTotal += file.size; filesDone += 1; skipped += 1; - process.stderr.write(`[${filesDone}/${fileTotal}] skip ${relativePath}\n`); + if (!opts.quiet) process.stderr.write(`[${filesDone}/${fileTotal}] skip ${relativePath}\n`); continue; } @@ -391,7 +393,7 @@ export async function downloadAsset( } if (toDownload.length === 0) { - process.stderr.write(`Nothing to fetch — all ${skipped} file(s) already match manifest SHA1\n`); + if (!opts.quiet) process.stderr.write(`Nothing to fetch — all ${skipped} file(s) already match manifest SHA1\n`); return { files: writtenFiles, bytesTotal, skipped }; } @@ -410,18 +412,20 @@ export async function downloadAsset( remainingUses, ); - process.stderr.write( - `Downloading ${toDownload.length}/${fileTotal} file(s), ${uniqueNeeded} unique chunk(s)` + - ` (concurrency ${concurrency}, prefetch window ${prefetchWindow}` + - (bases.length > 1 ? `, ${bases.length} CDN bases` : "") + - ")…\n", - ); + if (!opts.quiet) { + process.stderr.write( + `Downloading ${toDownload.length}/${fileTotal} file(s), ${uniqueNeeded} unique chunk(s)` + + ` (concurrency ${concurrency}, prefetch window ${prefetchWindow}` + + (bases.length > 1 ? `, ${bases.length} CDN bases` : "") + + ")…\n", + ); + } // Kick the first window so the pool is busy before assembly starts. primeWindow(cache, order, 0, prefetchWindow); for (const { file, relativePath, targetPath } of toDownload) { - process.stderr.write(`[${filesDone + 1}/${fileTotal}] downloading ${relativePath}…\n`); + if (!opts.quiet) process.stderr.write(`[${filesDone + 1}/${fileTotal}] downloading ${relativePath}…\n`); const bytes = await assembleFile( file, cache, @@ -433,9 +437,11 @@ export async function downloadAsset( writtenFiles.push(targetPath); bytesTotal += bytes; filesDone += 1; - process.stderr.write( - `[${filesDone}/${fileTotal}] wrote ${relativePath} (${bytes} bytes, cache ${cache.cachedCount()} chunks)\n`, - ); + if (!opts.quiet) { + process.stderr.write( + `[${filesDone}/${fileTotal}] wrote ${relativePath} (${bytes} bytes, cache ${cache.cachedCount()} chunks)\n`, + ); + } } return { files: writtenFiles, bytesTotal, skipped }; diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 0000000..588ec17 --- /dev/null +++ b/src/locales/en.json @@ -0,0 +1,33 @@ +{ + "interactive_terminal_required": "tui requires an interactive terminal", + "not_authenticated": "Not authenticated. Run: epic-fab auth", + "tui_failed": "tui failed: {error}", + "unknown": "unknown", + "fetching_library": "Fetching library…", + "search_status": "epic-fab tui {matches}/{total} assets Search: {query}", + "help": "Type to search · ↑/↓ select · Enter download · Ctrl+O open Fab · Ctrl+I open thumbnail · Esc quit", + "detail_type": "Type: {type}", + "detail_id": "ID: {id}", + "detail_fab": "Fab: {url}", + "thumbnail_link": "Thumbnail: {url}", + "detail_download": "Download: {path}", + "opening_fab": "Opening the Fab listing…", + "opened_fab": "Opened the Fab listing in your default browser.", + "open_fab_failed": "Could not open the Fab listing; copy the URL from the details pane.", + "thumbnail_unavailable": "Thumbnail unavailable for this asset.", + "opening_thumbnail": "Opening the thumbnail…", + "opened_thumbnail": "Opened the thumbnail in your default browser.", + "open_thumbnail_failed": "Could not open the thumbnail.", + "engine_fallback": "Engine {requested} is not available for \"{title}\". Using {selected} (highest compatible version).", + "engine_higher": "Engine {requested} is not available for \"{title}\".", + "engine_available": "Available versions: {versions}", + "engine_prompt": "Enter a version from the list above (or 'q' to abort): ", + "engine_invalid": "\"{value}\" is not in the available versions.", + "engine_none": "No engine versions found for \"{title}\".", + "download_cancelled": "Download cancelled.", + "download_in_progress": "A download is in progress. Wait for it to finish before quitting.", + "download_already_in_progress": "A download is already in progress.", + "downloading": "Downloading {title}…", + "downloaded": "Downloaded to {path}", + "download_failed": "Download failed: {error}" +} diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json new file mode 100644 index 0000000..e89f2eb --- /dev/null +++ b/src/locales/zh-CN.json @@ -0,0 +1,33 @@ +{ + "interactive_terminal_required": "tui 需要在交互式终端中运行", + "not_authenticated": "尚未认证,请先运行:epic-fab auth", + "tui_failed": "tui 运行失败:{error}", + "unknown": "未知", + "fetching_library": "正在获取资源库…", + "search_status": "epic-fab tui {matches}/{total} 个资源 搜索:{query}", + "help": "直接输入搜索 · ↑/↓ 选择 · Enter 下载 · Ctrl+O 打开 Fab · Ctrl+I 打开缩略图 · Esc 退出", + "detail_type": "类型:{type}", + "detail_id": "ID:{id}", + "detail_fab": "Fab:{url}", + "thumbnail_link": "缩略图:{url}", + "detail_download": "下载位置:{path}", + "opening_fab": "正在打开 Fab 页面…", + "opened_fab": "已在默认浏览器中打开 Fab 页面。", + "open_fab_failed": "无法打开 Fab 页面;请从详情栏复制链接。", + "thumbnail_unavailable": "此资源没有可用缩略图。", + "opening_thumbnail": "正在打开缩略图…", + "opened_thumbnail": "已在默认浏览器中打开缩略图。", + "open_thumbnail_failed": "无法打开缩略图。", + "engine_fallback": "\"{title}\" 不支持引擎 {requested},将使用最高兼容版本 {selected}。", + "engine_higher": "\"{title}\" 不支持引擎 {requested}。", + "engine_available": "可用版本:{versions}", + "engine_prompt": "请输入以上列表中的版本(或输入 q 取消):", + "engine_invalid": "\"{value}\" 不在可用版本中。", + "engine_none": "\"{title}\" 没有可用的引擎版本。", + "download_cancelled": "已取消下载。", + "download_in_progress": "下载正在进行,请等待完成后再退出。", + "download_already_in_progress": "已有下载任务正在进行。", + "downloading": "正在下载 {title}…", + "downloaded": "已下载到 {path}", + "download_failed": "下载失败:{error}" +} diff --git a/src/tui.ts b/src/tui.ts new file mode 100644 index 0000000..ebc0404 --- /dev/null +++ b/src/tui.ts @@ -0,0 +1,445 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { emitKeypressEvents, type Key } from "node:readline"; +import { createInterface } from "node:readline/promises"; + +import type { AuthTokens } from "./auth.ts"; +import { fetchAssetDetail, listLibrary, resolveAsset, type FabAssetDetail, type FabAssetSummary } from "./api.ts"; +import { downloadAsset } from "./download.ts"; +import { loadTuiTranslator, type TuiTranslate } from "./tuiI18n.ts"; + +export interface TuiOptions { + engineVersion: string; + locale?: string; +} + +export interface TuiResult { + downloadFailed: boolean; +} + +export class TuiUserError extends Error {} + +export function filterAssets( + assets: ReadonlyArray, + query: string, +): FabAssetSummary[] { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) return [...assets]; + return assets.filter((asset) => + [asset.id, asset.title, asset.type].some((value) => value.toLowerCase().includes(needle)), + ); +} + +export function visibleAssetWindow( + assets: ReadonlyArray, + selected: number, + rows: number, +): { start: number; assets: FabAssetSummary[] } { + const start = Math.max(0, Math.min(selected - Math.floor(rows / 2), Math.max(0, assets.length - rows))); + return { start, assets: assets.slice(start, start + rows) }; +} + +export function fabUrlFor(item: FabAssetSummary): string { + for (const key of ["listingUrl", "fabUrl", "productUrl", "url"] as const) { + const value = item.raw[key]; + if (typeof value !== "string") continue; + try { + const url = new URL(value); + if (url.protocol === "https:" && (url.hostname === "www.fab.com" || url.hostname === "fab.com")) { + return url.toString(); + } + } catch { + // Use the canonical fallback below when a raw field is not a valid URL. + } + } + const dashed = /^[0-9a-f]{32}$/i.test(item.id) + ? `${item.id.slice(0, 8)}-${item.id.slice(8, 12)}-${item.id.slice(12, 16)}-${item.id.slice(16, 20)}-${item.id.slice(20)}` + : item.id; + return `https://www.fab.com/listings/${encodeURIComponent(dashed)}`; +} + +export function thumbnailUrlFor(raw: Record): string | null { + const direct = raw["thumbnailUrl"] ?? raw["thumbnail_url"] ?? raw["thumbnail"] ?? raw["thumbUrl"]; + const candidates: unknown[] = [direct]; + for (const key of ["images", "thumbnails"] as const) { + const list = raw[key]; + if (Array.isArray(list) && list.length > 0 && typeof list[0] === "object" && list[0] !== null) { + candidates.push((list[0] as Record)["url"]); + } + } + for (const candidate of candidates) { + if (typeof candidate !== "string") continue; + try { + const url = new URL(candidate); + if (url.protocol === "https:") return url.toString(); + } catch { + // Continue to another possible thumbnail field. + } + } + return null; +} + +function assetSlug(asset: FabAssetSummary): string { + const candidate = asset.title.length > 0 ? asset.title : asset.id; + const slug = candidate + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); + return slug.length > 0 ? slug : "asset"; +} + +export function downloadTargetDir(asset: FabAssetSummary): string { + const idSuffix = asset.id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + return join(homedir(), "Downloads", `${assetSlug(asset)}-${idSuffix || "asset"}`); +} + +export type TuiAction = "quit" | "up" | "down" | "backspace" | "download" | "open" | "thumbnail" | "search" | "none"; + +export function tuiKeyAction(key: Pick): TuiAction { + if ((key.ctrl && key.name === "c") || key.name === "escape") return "quit"; + if (key.name === "up") return "up"; + if (key.name === "down") return "down"; + if (key.name === "backspace") return "backspace"; + if (key.name === "return" || key.name === "enter") return "download"; + if (key.ctrl && key.name === "o") return "open"; + if ((key.ctrl && key.name === "i") || key.name === "tab") return "thumbnail"; + if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) return "search"; + return "none"; +} + +function characterDisplayWidth(character: string): number { + const codePoint = character.codePointAt(0); + if (codePoint === undefined || codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) { + return 0; + } + if ( + /^\p{Mark}$/u.test(character) || + codePoint === 0x200d || + (codePoint >= 0xfe00 && codePoint <= 0xfe0f) + ) { + return 0; + } + if ( + (codePoint >= 0x1100 && codePoint <= 0x115f) || + codePoint === 0x2329 || codePoint === 0x232a || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x1f300 && codePoint <= 0x1faff) || + (codePoint >= 0x20000 && codePoint <= 0x3fffd) + ) return 2; + return 1; +} + +export function displayWidth(value: string): number { + let width = 0; + for (const character of value) width += characterDisplayWidth(character); + return width; +} + +export function truncateToDisplayWidth(value: string, width: number): string { + if (width <= 0) return ""; + if (displayWidth(value) <= width) return value; + if (width === 1) return "…"; + + let truncated = ""; + let used = 0; + for (const character of value) { + const characterWidth = characterDisplayWidth(character); + if (used + characterWidth > width - 1) break; + truncated += character; + used += characterWidth; + } + return `${truncated}…`; +} + +export function padToDisplayWidth(value: string, width: number): string { + return `${value}${" ".repeat(Math.max(0, width - displayWidth(value)))}`; +} + +export function wrapToDisplayWidth(value: string, width: number): string[] { + if (width <= 0) return [""]; + const lines: string[] = []; + let line = ""; + let lineWidth = 0; + for (const character of value) { + const characterWidth = characterDisplayWidth(character); + if (lineWidth > 0 && lineWidth + characterWidth > width) { + lines.push(line); + line = ""; + lineWidth = 0; + } + line += character; + lineWidth += characterWidth; + } + if (line.length > 0 || lines.length === 0) lines.push(line); + return lines; +} + +export function safeTerminalText(value: string): string { + return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ""); +} + +export function relatedDetailLines(translate: TuiTranslate, asset: FabAssetSummary): string[] { + const lines = [translate("detail_fab", { url: safeTerminalText(fabUrlFor(asset)) })]; + const thumbnailUrl = thumbnailUrlFor(asset.raw); + if (thumbnailUrl) lines.push(translate("thumbnail_link", { url: safeTerminalText(thumbnailUrl) })); + lines.push(translate("detail_download", { path: downloadTargetDir(asset) })); + return lines; +} + +export function detailLinesFor(translate: TuiTranslate, asset: FabAssetSummary, width: number): string[] { + const summary = [ + safeTerminalText(asset.title || asset.id), + translate("detail_type", { type: safeTerminalText(asset.type || translate("unknown")) }), + translate("detail_id", { id: safeTerminalText(asset.id) }), + ].flatMap((line) => wrapToDisplayWidth(line, width)); + const related = relatedDetailLines(translate, asset); + const downloadLine = related.pop(); + return [ + ...summary, + ...related.map((line) => truncateToDisplayWidth(line, width)), + ...(downloadLine ? wrapToDisplayWidth(downloadLine, width) : []), + ]; +} + +function clearScreen(): void { + process.stdout.write("\x1b[2J\x1b[H"); +} + +export async function runTui(tokens: AuthTokens, opts: TuiOptions): Promise { + const t = await loadTuiTranslator(opts.locale ?? process.env["LANG"]); + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new TuiUserError(t("interactive_terminal_required")); + } + + process.stderr.write(`${t("fetching_library")}\n`); + const assets = await listLibrary(tokens); + let query = ""; + let selected = 0; + let message = t("help"); + let closed = false; + let downloading = false; + let downloadFailed = false; + let terminalActive = false; + + const resolveDownloadAsset = async (asset: FabAssetSummary): Promise => { + let resolved = await resolveAsset(tokens, asset.id, opts.engineVersion); + const title = safeTerminalText(resolved.summary.title || resolved.summary.id); + switch (resolved.resolution.matchType) { + case "exact": + break; + case "fallback": + process.stderr.write(`${t("engine_fallback", { + requested: safeTerminalText(resolved.resolution.requested), + title, + selected: safeTerminalText(resolved.resolution.selected), + })}\n`); + break; + case "higher-only": { + process.stderr.write(`${t("engine_higher", { requested: safeTerminalText(resolved.resolution.requested), title })}\n`); + process.stderr.write(`${t("engine_available", { + versions: resolved.resolution.available.map(safeTerminalText).join(", "), + })}\n`); + const prompt = createInterface({ input: process.stdin, output: process.stdout }); + try { + const picked = (await prompt.question(t("engine_prompt"))).trim(); + if (picked.length === 0 || picked === "q") return null; + if (!resolved.resolution.available.includes(picked)) { + process.stderr.write(`${t("engine_invalid", { value: safeTerminalText(picked) })}\n`); + return null; + } + resolved = await resolveAsset(tokens, asset.id, picked); + } finally { + prompt.close(); + } + break; + } + case "none": + process.stderr.write(`${t("engine_none", { title })}\n`); + return null; + } + return fetchAssetDetail(tokens, resolved); + }; + + const render = (): void => { + if (!terminalActive) return; + const matches = filterAssets(assets, query); + selected = Math.max(0, Math.min(selected, Math.max(0, matches.length - 1))); + const width = Math.max(80, process.stdout.columns ?? 100); + const listWidth = Math.floor(width * 0.52); + const detailWidth = width - listWidth - 1; + const current = matches[selected]; + const detailLines = current ? detailLinesFor(t, current, detailWidth) : []; + const rows = Math.max(Math.max(6, (process.stdout.rows ?? 24) - 8), detailLines.length); + const window = visibleAssetWindow(matches, selected, rows); + clearScreen(); + process.stdout.write(`${t("search_status", { matches: matches.length, total: assets.length, query: safeTerminalText(query) })}\n`); + process.stdout.write(`${"─".repeat(listWidth)}┬${"─".repeat(detailWidth)}\n`); + for (let index = 0; index < rows; index += 1) { + const asset = window.assets[index]; + const marker = window.start + index === selected ? "›" : " "; + const list = asset + ? truncateToDisplayWidth( + `${marker} ${safeTerminalText(asset.title || asset.id)} [${safeTerminalText(asset.type || t("unknown"))}]`, + listWidth, + ) + : ""; + const detail = detailLines[index] ?? ""; + process.stdout.write(`${padToDisplayWidth(list, listWidth)}│${detail}\n`); + } + process.stdout.write(`${"─".repeat(width)}\n`); + process.stdout.write(`${truncateToDisplayWidth(message, width)}\n`); + }; + + const leaveTerminal = (): void => { + if (!terminalActive) return; + process.stdin.setRawMode(false); + process.stdin.pause(); + process.stdout.write("\x1b[?25h\x1b[?1049l"); + terminalActive = false; + }; + + const enterTerminal = (): void => { + if (closed || terminalActive) return; + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdout.write("\x1b[?1049h\x1b[?25l"); + terminalActive = true; + }; + + const restore = (): void => { + if (closed) return; + closed = true; + leaveTerminal(); + }; + + let onKeypress: ((_input: string, key: Key) => void) | undefined; + let resolveRun: (() => void) | undefined; + let finished = false; + let terminationSignal: NodeJS.Signals | undefined; + const finish = (): void => { + if (finished) return; + finished = true; + resolveRun?.(); + }; + const onSigint = (): void => { + terminationSignal = "SIGINT"; + finish(); + }; + const onSigterm = (): void => { + terminationSignal = "SIGTERM"; + finish(); + }; + const onSighup = (): void => { + terminationSignal = "SIGHUP"; + finish(); + }; + + try { + await new Promise((resolve) => { + resolveRun = resolve; + onKeypress = (_input: string, key: Key): void => { + const matches = filterAssets(assets, query); + const action = tuiKeyAction(key); + if (action === "quit") { + if (downloading) { + message = t("download_in_progress"); + return render(); + } + return finish(); + } + if (action === "up") selected = Math.max(0, selected - 1); + else if (action === "down") selected = Math.min(Math.max(0, matches.length - 1), selected + 1); + else if (action === "backspace") { + query = query.slice(0, -1); + selected = 0; + } else if (action === "open" && matches[selected]) { + const url = fabUrlFor(matches[selected]); + message = t("opening_fab"); + void (async () => { + try { + const child = Bun.spawn(["xdg-open", url], { stdio: ["ignore", "ignore", "ignore"] }); + if ((await child.exited) === 0) message = t("opened_fab"); + else message = t("open_fab_failed"); + } catch { + message = t("open_fab_failed"); + } + render(); + })(); + } else if (action === "thumbnail" && matches[selected]) { + const thumbnailUrl = thumbnailUrlFor(matches[selected].raw); + if (!thumbnailUrl) { + message = t("thumbnail_unavailable"); + return render(); + } + message = t("opening_thumbnail"); + void (async () => { + try { + const child = Bun.spawn(["xdg-open", thumbnailUrl], { stdio: ["ignore", "ignore", "ignore"] }); + if ((await child.exited) === 0) message = t("opened_thumbnail"); + else message = t("open_thumbnail_failed"); + } catch { + message = t("open_thumbnail_failed"); + } + render(); + })(); + } else if (action === "download" && matches[selected]) { + if (downloading) { + message = t("download_already_in_progress"); + return render(); + } + const asset = matches[selected]; + downloading = true; + message = t("downloading", { title: safeTerminalText(asset.title || asset.id) }); + leaveTerminal(); + process.stderr.write(`${message}\n`); + void (async () => { + try { + const detail = await resolveDownloadAsset(asset); + if (!detail) { + message = t("download_cancelled"); + return; + } + const targetDir = downloadTargetDir(detail); + await downloadAsset(detail, { targetDir, preserveStructure: true, quiet: true }); + message = t("downloaded", { path: targetDir }); + } catch (err) { + downloadFailed = true; + message = t("download_failed", { error: safeTerminalText(err instanceof Error ? err.message : String(err)) }); + } finally { + downloading = false; + enterTerminal(); + render(); + } + })(); + } else if (action === "search" && key.sequence) { + query += key.sequence; + selected = 0; + } + render(); + }; + + emitKeypressEvents(process.stdin); + process.stdin.on("keypress", onKeypress); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + process.once("SIGHUP", onSighup); + enterTerminal(); + render(); + }); + } finally { + if (onKeypress) process.stdin.off("keypress", onKeypress); + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + process.off("SIGHUP", onSighup); + restore(); + } + + if (terminationSignal) process.kill(process.pid, terminationSignal); + return { downloadFailed }; +} diff --git a/src/tuiI18n.ts b/src/tuiI18n.ts new file mode 100644 index 0000000..0a0f8f6 --- /dev/null +++ b/src/tuiI18n.ts @@ -0,0 +1,60 @@ +export type TuiTranslate = (key: string, values?: Record) => string; +const LOCALE_FILE_NAME = /^[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*$/; + +function format(template: string, values: Record | undefined): string { + if (!values) return template; + return template.replace(/\{([a-z_]+)\}/g, (whole, key: string) => + Object.hasOwn(values, key) ? String(values[key]) : whole, + ); +} + +export function localeCandidates(value: string | undefined): string[] { + const normalized = value?.trim().replace(/\..*$/, "").replace(/_/g, "-"); + if (!normalized || !LOCALE_FILE_NAME.test(normalized)) return []; + const language = normalized.split("-")[0]!; + return language === normalized ? [normalized] : [normalized, language]; +} + +export function validateMessages(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Locale file must contain an object"); + } + for (const [key, message] of Object.entries(value)) { + if (typeof message !== "string") { + throw new Error(`Locale file must map every key to a string (invalid key: ${key})`); + } + if (/[\u0000-\u001f\u007f-\u009f]/.test(message)) { + throw new Error(`Locale file must not contain terminal control characters (invalid key: ${key})`); + } + } + return value as Record; +} + +async function readMessages(locale: string): Promise> { + if (!LOCALE_FILE_NAME.test(locale)) throw new Error("Invalid locale file name"); + const file = Bun.file(new URL(`./locales/${locale}.json`, import.meta.url)); + return validateMessages(await file.json()); +} + +export async function loadTuiTranslator(localeValue: string | undefined): Promise { + let fallback: Record = {}; + try { + fallback = await readMessages("en"); + } catch { + // Keep the TUI usable even if a user edits the fallback file into invalid JSON. + } + let messages = fallback; + for (const locale of localeCandidates(localeValue)) { + if (locale === "en") continue; + try { + messages = { ...fallback, ...(await readMessages(locale)) }; + break; + } catch { + // Try a less-specific locale, then retain English if no valid file is found. + } + } + return (key, values) => { + const template = messages[key] ?? fallback[key] ?? key; + return format(template, values); + }; +}