chore: add CI, lint config, login rate limit and split large modules

- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After)
- Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build)
- Apply ruff autofixes (import order, pyupgrade)
- Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and
  translation-types.ts from oversized modules
- Add dependency-free React UI tests
- Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 11:23:22 +03:00
1 parent 57a4edcbfd
commit bbbcc6a5d8
42 files changed
+678 -391

No files matched your search

+6 -161
View File
@@ -1,84 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { api, uploadRaw, type Version } from "../api";
import { EmptyState, ErrorNote, PageHeader, Spinner } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts";
import {
FILTERS, PAGE, STATE_BADGE, STATE_LABEL, USAGE,
type GlossaryTerm, type Item, type LangStats, type LineState, type Overview, type Tab,
} from "./translation-types";
import "./translate.css";
type LineState = "untranslated" | "outdated" | "translated" | "fuzzy" | "machine" | "approved";
interface LangStats {
code: string;
name: string;
font: string | null;
lines: number;
translated: number;
progress: number;
states: Record<LineState, number>;
strings: number;
stringsTranslated: number;
}
interface Overview {
sourceLang: string | null;
editable: boolean;
canApprove: boolean;
lines: number;
strings: number;
modules: { path: string; lines: number }[];
languages: LangStats[];
knownLanguages: Record<string, string>;
}
interface Item {
key: string;
source: string;
translation: string;
state: LineState;
// lines
nodeId?: string;
module?: string;
label?: string;
who?: string;
prev?: string | null;
next?: string | null;
scene?: string | null;
// strings
usage?: string;
}
interface GlossaryTerm {
src: string;
t: string;
note: string;
}
type Tab = "lines" | "strings" | "glossary";
const STATE_LABEL: Record<LineState, string> = {
untranslated: "Не переведено",
outdated: "Устарело",
fuzzy: "Неточно",
machine: "Машинный",
translated: "Переведено",
approved: "Одобрено",
};
const STATE_BADGE: Record<LineState, string> = {
untranslated: "badge-plain",
outdated: "badge-error",
fuzzy: "badge-warn",
machine: "badge-warn",
translated: "badge-ok",
approved: "badge-ok",
};
const FILTERS: [string, string][] = [
["todo", "К работе"], ["all", "Все"], ["untranslated", "Не переведено"], ["outdated", "Устарело"],
["fuzzy", "Неточно"], ["machine", "Машинный"], ["translated", "Переведено"], ["approved", "Одобрено"],
];
const PAGE = 40;
const USAGE: Record<string, string> = { name: "имя персонажа", menu: "пункт меню", text: "текст на экране" };
export function TranslationEditor() {
const { versionId } = useParams();
const [params, setParams] = useSearchParams();
@@ -275,29 +207,6 @@ export function TranslationEditor() {
);
}
function ProgressBar({ stats }: { stats: LangStats }) {
const total = stats.lines || 1;
const seg = (state: LineState, cls: string) =>
stats.states[state] ? <div className={cls} style={{ width: `${(stats.states[state] / total) * 100}%` }}
title={`${STATE_LABEL[state]}: ${stats.states[state]}`} /> : null;
return (
<div className="tr-progress">
<div className="tr-bar">
{seg("approved", "seg-approved")}
{seg("translated", "seg-ok")}
{seg("fuzzy", "seg-warn")}
{seg("machine", "seg-warn")}
{seg("outdated", "seg-error")}
</div>
<span className="muted small">
{stats.translated} из {stats.lines} реплик ({Math.round(stats.progress * 100)}%)
{stats.states.outdated ? ` · устарело ${stats.states.outdated}` : ""}
{" · "}строк {stats.stringsTranslated}/{stats.strings}
</span>
</div>
);
}
function EntriesView({ versionId, lang, tab, ov, onStats }: {
versionId: string; lang: string; tab: Tab; ov: Overview; onStats: (s: Partial<LangStats>) => void;
}) {
@@ -524,67 +433,3 @@ function Row({ item, versionId, lang, editable, canApprove, terms, onChange, onB
</div>
);
}
function highlight(text: string, terms: GlossaryTerm[]): ReactNode {
if (!terms.length) return text;
const escaped = terms.map((t) => t.src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).sort((a, b) => b.length - a.length);
const re = new RegExp(`(${escaped.join("|")})`, "gi");
return text.split(re).map((part, i) => {
const term = i % 2 ? terms.find((t) => t.src.toLowerCase() === part.toLowerCase()) : undefined;
return term ? <mark key={i} title={`${term.t}${term.note ? ` — ${term.note}` : ""}`}>{part}</mark> : part;
});
}
function GlossaryEditor({ versionId, lang, editable, terms, onChange }: {
versionId: string; lang: string; editable: boolean; terms: GlossaryTerm[]; onChange: (t: GlossaryTerm[]) => void;
}) {
const ui = useUi();
const [rows, setRows] = useState<GlossaryTerm[]>(terms.length ? terms : [{ src: "", t: "", note: "" }]);
const save = async () => {
try {
await api.put(`/api/versions/${versionId}/i18n/${lang}`, { glossary: rows });
onChange(rows.filter((r) => r.src.trim()));
ui.toast("Глоссарий сохранён", "ok");
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const set = (i: number, key: keyof GlossaryTerm, value: string) =>
setRows((rs) => rs.map((r, n) => n === i ? { ...r, [key]: value } : r));
return (
<div className="card">
<p className="muted small" style={{ marginTop: 0 }}>
Имена и термины, которые нужно переводить единообразно. В репликах они подсвечиваются,
при наведении виден принятый перевод.
</p>
<div className="table-wrap">
<table className="table tr-glossary">
<thead><tr><th>Оригинал</th><th>Перевод</th><th>Заметка</th><th /></tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td><input value={r.src} readOnly={!editable} onChange={(e) => set(i, "src", e.target.value)} /></td>
<td><input value={r.t} readOnly={!editable} onChange={(e) => set(i, "t", e.target.value)} lang={lang} /></td>
<td><input value={r.note} readOnly={!editable} onChange={(e) => set(i, "note", e.target.value)} /></td>
<td>
{editable && (
<button className="btn btn-sm btn-ghost btn-icon btn-danger" aria-label="Удалить термин"
onClick={() => setRows((rs) => rs.filter((_, n) => n !== i))}><Icon name="x" size={14} /></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{editable && (
<div className="actions" style={{ marginTop: "var(--space-3)" }}>
<button className="btn btn-sm" onClick={() => setRows((rs) => [...rs, { src: "", t: "", note: "" }])}>
<Icon name="plus" size={16} />Термин
</button>
<button className="btn btn-sm btn-primary" onClick={() => void save()}><Icon name="check" size={16} />Сохранить</button>
</div>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { useState, type ReactNode } from "react";
import { api } from "../api";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
import { STATE_LABEL, type GlossaryTerm, type LangStats, type LineState } from "./translation-types";
export function ProgressBar({ stats }: { stats: LangStats }) {
const total = stats.lines || 1;
const seg = (state: LineState, cls: string) =>
stats.states[state] ? <div className={cls} style={{ width: `${(stats.states[state] / total) * 100}%` }}
title={`${STATE_LABEL[state]}: ${stats.states[state]}`} /> : null;
return (
<div className="tr-progress">
<div className="tr-bar">
{seg("approved", "seg-approved")}
{seg("translated", "seg-ok")}
{seg("fuzzy", "seg-warn")}
{seg("machine", "seg-warn")}
{seg("outdated", "seg-error")}
</div>
<span className="muted small">
{stats.translated} из {stats.lines} реплик ({Math.round(stats.progress * 100)}%)
{stats.states.outdated ? ` · устарело ${stats.states.outdated}` : ""}
{" · "}строк {stats.stringsTranslated}/{stats.strings}
</span>
</div>
);
}
export function highlight(text: string, terms: GlossaryTerm[]): ReactNode {
if (!terms.length) return text;
const escaped = terms.map((t) => t.src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).sort((a, b) => b.length - a.length);
const re = new RegExp(`(${escaped.join("|")})`, "gi");
return text.split(re).map((part, i) => {
const term = i % 2 ? terms.find((t) => t.src.toLowerCase() === part.toLowerCase()) : undefined;
return term ? <mark key={i} title={`${term.t}${term.note ? ` — ${term.note}` : ""}`}>{part}</mark> : part;
});
}
export function GlossaryEditor({ versionId, lang, editable, terms, onChange }: {
versionId: string; lang: string; editable: boolean; terms: GlossaryTerm[]; onChange: (t: GlossaryTerm[]) => void;
}) {
const ui = useUi();
const [rows, setRows] = useState<GlossaryTerm[]>(terms.length ? terms : [{ src: "", t: "", note: "" }]);
const save = async () => {
try {
await api.put(`/api/versions/${versionId}/i18n/${lang}`, { glossary: rows });
onChange(rows.filter((r) => r.src.trim()));
ui.toast("Глоссарий сохранён", "ok");
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const set = (i: number, key: keyof GlossaryTerm, value: string) =>
setRows((rs) => rs.map((r, n) => n === i ? { ...r, [key]: value } : r));
return (
<div className="card">
<p className="muted small" style={{ marginTop: 0 }}>
Имена и термины, которые нужно переводить единообразно. В репликах они подсвечиваются,
при наведении виден принятый перевод.
</p>
<div className="table-wrap">
<table className="table tr-glossary">
<thead><tr><th>Оригинал</th><th>Перевод</th><th>Заметка</th><th /></tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td><input value={r.src} readOnly={!editable} onChange={(e) => set(i, "src", e.target.value)} /></td>
<td><input value={r.t} readOnly={!editable} onChange={(e) => set(i, "t", e.target.value)} lang={lang} /></td>
<td><input value={r.note} readOnly={!editable} onChange={(e) => set(i, "note", e.target.value)} /></td>
<td>
{editable && (
<button className="btn btn-sm btn-ghost btn-icon btn-danger" aria-label="Удалить термин"
onClick={() => setRows((rs) => rs.filter((_, n) => n !== i))}><Icon name="x" size={14} /></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{editable && (
<div className="actions" style={{ marginTop: "var(--space-3)" }}>
<button className="btn btn-sm" onClick={() => setRows((rs) => [...rs, { src: "", t: "", note: "" }])}>
<Icon name="plus" size={16} />Термин
</button>
<button className="btn btn-sm btn-primary" onClick={() => void save()}><Icon name="check" size={16} />Сохранить</button>
</div>
)}
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
// Shared types and labels of the translation editor.
export type LineState = "untranslated" | "outdated" | "translated" | "fuzzy" | "machine" | "approved";
export interface LangStats {
code: string;
name: string;
font: string | null;
lines: number;
translated: number;
progress: number;
states: Record<LineState, number>;
strings: number;
stringsTranslated: number;
}
export interface Overview {
sourceLang: string | null;
editable: boolean;
canApprove: boolean;
lines: number;
strings: number;
modules: { path: string; lines: number }[];
languages: LangStats[];
knownLanguages: Record<string, string>;
}
export interface Item {
key: string;
source: string;
translation: string;
state: LineState;
// lines
nodeId?: string;
module?: string;
label?: string;
who?: string;
prev?: string | null;
next?: string | null;
scene?: string | null;
// strings
usage?: string;
}
export interface GlossaryTerm {
src: string;
t: string;
note: string;
}
export type Tab = "lines" | "strings" | "glossary";
export const STATE_LABEL: Record<LineState, string> = {
untranslated: "Не переведено",
outdated: "Устарело",
fuzzy: "Неточно",
machine: "Машинный",
translated: "Переведено",
approved: "Одобрено",
};
export const STATE_BADGE: Record<LineState, string> = {
untranslated: "badge-plain",
outdated: "badge-error",
fuzzy: "badge-warn",
machine: "badge-warn",
translated: "badge-ok",
approved: "badge-ok",
};
export const FILTERS: [string, string][] = [
["todo", "К работе"], ["all", "Все"], ["untranslated", "Не переведено"], ["outdated", "Устарело"],
["fuzzy", "Неточно"], ["machine", "Машинный"], ["translated", "Переведено"], ["approved", "Одобрено"],
];
export const PAGE = 40;
export const USAGE: Record<string, string> = { name: "имя персонажа", menu: "пункт меню", text: "текст на экране" };
+51
View File
@@ -0,0 +1,51 @@
// Small shared pieces of the engine: save API types, settings and DOM/storage helpers.
import type { Volumes } from "./audio";
import type { SaveData } from "./types";
export interface SaveMeta {
slot: string;
title: string;
updated_at: string;
}
export interface SaveApi<T = SaveData> {
list(): Promise<SaveMeta[]>;
get(slot: string): Promise<T | null>;
put(slot: string, title: string, data: T): Promise<void>;
remove(slot: string): Promise<void>;
}
export interface Settings extends Volumes {
cps: number; // characters per second, 0 = instant
autoDelay: number; // seconds per 20 characters in auto mode
}
export const DEFAULT_SETTINGS: Settings = { cps: 45, autoDelay: 1.2, music: 0.7, sound: 0.8, voice: 1 };
export const SLOTS = 12;
export class Restart extends Error {}
export function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls = "", text = ""): HTMLElementTagNameMap[K] {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text) e.textContent = text;
return e;
}
export function loadJSON<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? { ...fallback, ...JSON.parse(raw) } : fallback;
} catch {
return fallback;
}
}
export function saveJSON(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage unavailable */
}
}
+3 -47
View File
@@ -1,35 +1,17 @@
// PGVN interpreter + in-game UI.
import { AudioSystem, type Volumes } from "./audio";
import { AudioSystem } from "./audio";
import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, type Scope } from "./python";
import { Renderer, type ResolvedImage } from "./renderer";
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n";
import { naniText } from "./naninovel";
import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util";
import type {
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
} from "./types";
export interface SaveMeta {
slot: string;
title: string;
updated_at: string;
}
export interface SaveApi<T = SaveData> {
list(): Promise<SaveMeta[]>;
get(slot: string): Promise<T | null>;
put(slot: string, title: string, data: T): Promise<void>;
remove(slot: string): Promise<void>;
}
interface Settings extends Volumes {
cps: number; // characters per second, 0 = instant
autoDelay: number; // seconds per 20 characters in auto mode
}
const DEFAULT_SETTINGS: Settings = { cps: 45, autoDelay: 1.2, music: 0.7, sound: 0.8, voice: 1 };
const SLOTS = 12;
export type { SaveApi, SaveMeta } from "./engine-util";
interface LabelRef {
label: Label;
@@ -37,32 +19,6 @@ interface LabelRef {
index: number;
}
class Restart extends Error {}
function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls = "", text = ""): HTMLElementTagNameMap[K] {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text) e.textContent = text;
return e;
}
function loadJSON<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? { ...fallback, ...JSON.parse(raw) } : fallback;
} catch {
return fallback;
}
}
function saveJSON(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* storage unavailable */
}
}
export class Engine {
private labels = new Map<string, LabelRef>();
private anchors = new Map<string, { label: string; path: number[]; params?: [string, Expr | null][] | null }>();
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
// Bundle React together with the code under test so that both share one React instance.
async function load(entry) {
const result = await build({
stdin: { contents: entry, resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
bundle: true, write: false, format: "esm", platform: "node", jsx: "automatic", logLevel: "silent",
banner: { js: "import { createRequire } from \"node:module\"; const require = createRequire(process.cwd() + \"/package.json\");" },
});
const code = Buffer.from(result.outputFiles[0].text).toString("base64");
return import(`data:text/javascript;base64,${code}`);
}
const { renderToStaticMarkup, h, ProgressBar, highlight } = await load(`
export { renderToStaticMarkup } from "react-dom/server";
export { createElement as h } from "react";
export { ProgressBar, highlight } from "./pages/TranslationParts";
`);
const stats = {
lines: 10, translated: 5, progress: 0.5, strings: 4, stringsTranslated: 2,
states: { untranslated: 5, outdated: 1, translated: 3, fuzzy: 0, machine: 0, approved: 1 },
};
test("ProgressBar renders counts and only non-empty segments", () => {
const html = renderToStaticMarkup(h(ProgressBar, { stats }));
assert.match(html, /5 из 10 реплик \(50%\)/);
assert.match(html, /устарело 1/);
assert.match(html, /seg-approved/);
assert.doesNotMatch(html, /seg-warn/);
});
test("highlight wraps glossary terms case-insensitively and escapes regex characters", () => {
const terms = [{ src: "Eileen", t: "Эйлин", note: "" }, { src: "a+b", t: "x", note: "" }];
const html = renderToStaticMarkup(h("p", null, highlight("eileen met a+b", terms)));
assert.match(html, /<mark title="Эйлин">eileen<\/mark>/);
assert.match(html, /<mark title="x">a\+b<\/mark>/);
assert.equal(highlight("plain", []), "plain");
});