Files
pgames-manager/frontend/src/player/i18n.ts
T
mrleo1nid c8d83b3fe7 feat: add mod editing, revision merges and secure game storage
Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
2026-10-25 04:42:48 +03:00

99 lines
3.4 KiB
TypeScript

// Game localisation: per-language line tables (by tid) and a strings table (by source text).
import type { GameData, Manifest } from "./types";
export interface LocaleInfo {
name: string;
renpy: string;
modules: Record<string, string>;
strings: string;
font?: string | null;
lines: number;
progress: number;
}
interface Entry {
t: string;
state?: string;
}
const PREF_KEY = "pgvn:lang";
export class Translator {
constructor(
public readonly lang: string, // BCP-47; equals sourceLang when untranslated
public readonly renpyName: string | null,
private lines: Map<string, string> = new Map(),
private strings: Map<string, string> = new Map(),
public readonly font: string | null = null, // logical path of the locale's font
) {}
static source(game: GameData) {
return new Translator(game.sourceLang ?? "und", null);
}
/** Dialogue line by translation id, falling back to the source text. */
line(tid: string | undefined, source: string): string {
return (tid && this.lines.get(tid)) || source;
}
/** Names, menu choices, `_()` strings: looked up by their source text. */
str(source: string): string {
return this.strings.get(source) ?? source;
}
}
export function preferredLanguage(game: GameData): string {
const available = [game.sourceLang ?? "und", ...Object.keys(game.locales ?? {})];
let saved: string | null = null;
try {
saved = localStorage.getItem(PREF_KEY);
} catch {
/* storage unavailable */
}
if (saved && available.includes(saved)) return saved;
// browser languages, matched exactly or by primary subtag (ru-RU -> ru)
for (const nav of navigator.languages ?? [navigator.language]) {
const hit = available.find((a) => a === nav) ?? available.find((a) => a.split("-")[0] === nav.split("-")[0]);
if (hit) return hit;
}
return game.sourceLang ?? "und";
}
export function rememberLanguage(lang: string) {
try {
localStorage.setItem(PREF_KEY, lang);
} catch {
/* storage unavailable */
}
}
export async function loadTranslator(game: GameData, manifest: Manifest, lang: string): Promise<Translator> {
const info = game.locales?.[lang];
if (!info) return Translator.source(game);
const blob = (path: string) => {
const f = manifest.files[path];
if (!f) throw new Error(`в версии нет файла ${path}`);
return fetch(`/api/versions/${manifest.versionId}/files/${f.hash}`).then((r) => {
if (!r.ok) throw new Error(`${r.status} ${path}`);
return r.json() as Promise<Record<string, Entry>>;
});
};
const [strings, ...modules] = await Promise.all([
info.strings ? blob(info.strings) : Promise.resolve({} as Record<string, Entry>),
...Object.values(info.modules).map(blob)]);
const lines = new Map<string, string>();
for (const mod of modules) for (const [tid, e] of Object.entries(mod)) lines.set(tid, e.t);
return new Translator(lang, info.renpy, lines,
new Map(Object.entries(strings).map(([k, e]) => [k, e.t])), info.font ?? null);
}
export function languageName(game: GameData, lang: string): string {
if (lang === game.sourceLang) {
const known: Record<string, string> = { en: "English", ru: "Русский", fr: "Français", de: "Deutsch",
es: "Español", ja: "日本語" };
return `${known[lang] ?? lang} (оригинал)`;
}
return game.locales?.[lang]?.name ?? lang;
}