Localization stages 2-3: drafts, publishing, translation editor

- games.main_version_id, version visibility (draft/published) and kind
  (original/translation/mod), version_history; migration 0002
- permissions translations.edit and versions.publish
- "Translate" creates a translation draft; publishing merges the changed
  languages into the main version in place (players keep their saves), the
  previous manifest goes to the history and can be restored
- translation API: overview with per-state stats, entries with filters,
  search and scene context, save, glossary, locale font, PO/CSV export/import
- translation editor page: languages and progress, lines and strings,
  filters, autosave, approve/outdated/fuzzy actions, glossary highlighting,
  "play from here" with the scene restored
- player: ?lang= and ?at= from the editor, per-locale font

(models.py and permissions.py for this landed earlier in 9ee3e1f)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:56:19 +03:00
1 parent 4e047f3aa0
commit ac164d1782
20 files changed
+2039 -298

No files matched your search

+57 -3
View File
@@ -407,6 +407,7 @@ export class Engine {
try {
this.tr = await this.loadLocale(lang);
rememberLanguage(lang);
this.applyLocaleFont();
this.notify(languageName(this.game, lang));
} catch (err) {
this.notify(`Не удалось загрузить язык: ${(err as Error).message}`);
@@ -995,7 +996,53 @@ export class Engine {
this.showMainMenu();
}
private newGame() {
/** Preview from a given line (translation editor): game variables keep their defaults. */
startAt(nodeId: string) {
const at = this.findNode(nodeId);
if (!at) {
this.start();
this.notify("Строка не найдена в этой версии");
return;
}
this.newGame(at);
// rebuild the picture: replay the visual statements of the label that come before the line
const body = this.labels.get(at.label)!.label.body;
for (const n of body.slice(0, at.path[0])) {
if (n.op === "scene") {
this.renderer.scene();
if (n.img) this.showNode(n);
} else if (n.op === "show") this.showNode(n);
else if (n.op === "hide") this.renderer.hide(n.tag);
}
this.renderer.commit();
this.notify("Предпросмотр с выбранной строки — переменные игры по умолчанию");
}
private findNode(id: string): Frame | null {
const visit = (nodes: Node[], label: string, prefix: number[]): Frame | null => {
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const path = [...prefix, i];
if (n.id === id || (n.op === "menu" && n.say?.id === id)) return { label, path };
const children: [Node[], number][] =
n.op === "if" ? n.branches.map((b: any, k: number) => [b.body, k])
: n.op === "menu" ? n.items.map((it: any, k: number) => [it.body, k])
: n.op === "while" ? [[n.body, 0]] : [];
for (const [body, k] of children) {
const hit = visit(body, label, [...path, k]);
if (hit) return hit;
}
}
return null;
};
for (const { label } of this.labels.values()) {
const hit = visit(label.body, label.name, []);
if (hit) return hit;
}
return null;
}
private newGame(at?: Frame) {
this.restart(() => {
this.audio.stopAll();
this.renderer.scene();
@@ -1006,8 +1053,8 @@ export class Engine {
this.history = [];
this.lastSay = null;
this.textbox.classList.add("pg-hidden");
this.cur = { label: this.game.entry, path: [0] };
this.markSeen(this.game.entry);
this.cur = at ?? { label: this.game.entry, path: [0] };
this.markSeen(this.cur.label);
this.settle();
});
}
@@ -1095,6 +1142,7 @@ export class Engine {
if (ui.text_color) stage.style.setProperty("--pg-text", ui.text_color);
this.loadFont("pg-game-font", ui.text_font);
this.loadFont("pg-game-name-font", ui.name_text_font ?? ui.text_font);
this.applyLocaleFont();
this.textbox = el("div", "pg-textbox pg-hidden");
const boxH = px(ui.textbox_height, h, Math.round(h * 0.257));
@@ -1171,6 +1219,12 @@ export class Engine {
}, { passive: true });
}
/** A translation may bring its own font when the game's fonts lack its glyphs. */
private applyLocaleFont() {
this.renderer.stage.classList.remove("pg-locale-font");
if (this.tr.font) this.loadFont("pg-locale-font", this.tr.font);
}
private loadFont(family: string, file?: string) {
const url = file ? this.fileUrl(file) : null;
if (!url) return;
+6 -2
View File
@@ -7,6 +7,7 @@ export interface LocaleInfo {
renpy: string;
modules: Record<string, string>;
strings: string;
font?: string | null;
lines: number;
progress: number;
}
@@ -24,6 +25,7 @@ export class Translator {
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) {
@@ -77,11 +79,13 @@ export async function loadTranslator(game: GameData, manifest: Manifest, lang: s
return r.json() as Promise<Record<string, Entry>>;
});
};
const [strings, ...modules] = await Promise.all([blob(info.strings), ...Object.values(info.modules).map(blob)]);
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])));
new Map(Object.entries(strings).map(([k, e]) => [k, e.t])), info.font ?? null);
}
export function languageName(game: GameData, lang: string): string {
+7 -2
View File
@@ -45,7 +45,10 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
const game = await getJSON<GameData>(blob(manifest, "game.json"));
onProgress(`Загрузка скриптов (${game.scripts.length})`);
const lang = preferredLanguage(game);
// ?lang= and ?at= come from the translation editor ("play from here")
const url = new URLSearchParams(location.search);
const asked = url.get("lang");
const lang = asked && (asked === game.sourceLang || game.locales?.[asked]) ? asked : preferredLanguage(game);
const [modules, tr] = await Promise.all([
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
loadTranslator(game, manifest, lang),
@@ -53,6 +56,8 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
`pgvn:persistent:${versionId}`, onExit, tr,
(code) => loadTranslator(game, manifest, code));
engine.start();
const at = url.get("at");
if (at) engine.startAt(at);
else engine.start();
return engine;
}
+1
View File
@@ -23,6 +23,7 @@
}
.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-locale-font, .pg-stage.pg-locale-font .pg-namebox { font-family: "pg-locale-font", "Segoe UI", system-ui, sans-serif; }
.pg-layer { position: absolute; inset: 0; }
.pg-transition { pointer-events: none; }