Translate Chinese comments and strings to English across multiple files; update user management views for consistency in language.

This commit is contained in:
dienakdz
2026-04-07 17:31:18 +07:00
parent aa09e85199
commit 2dc29a215e
37 changed files with 5004 additions and 1912 deletions
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""
Translate Chinese comments/docstrings in Python files and Chinese prose in
selected Markdown files into English.
This intentionally skips:
- generated frontend assets
- dedicated localized docs such as *_CN.md / *_CH.md / *_TW.md
- regular code string literals that are not docstrings, to avoid changing
application behavior
"""
from __future__ import annotations
import argparse
import io
import json
import pathlib
import re
import sys
import time
import tokenize
import urllib.parse
import urllib.request
from dataclasses import dataclass
ROOT = pathlib.Path(__file__).resolve().parents[1]
HAN_RE = re.compile(r"[\u3400-\u9FFF]")
TRANSLATE_URL = (
"https://translate.googleapis.com/translate_a/single"
"?client=gtx&sl=auto&tl=en&dt=t&q={query}"
)
PYTHON_GLOBS = [
"backend_api_python/*.py",
"backend_api_python/app/**/*.py",
"scripts/*.py",
]
MARKDOWN_TARGETS = [
"backend_api_python/README.md",
"docs/FRONTEND_FAST_ANALYSIS.md",
"docs/CHANGELOG.md",
"docs/THIRD_PARTY_INTEGRATIONS.md",
]
SKIP_PATH_PATTERNS = [
"/frontend/dist/",
"/docs/README_CN.md",
"_CN.md",
"_CH.md",
"_TW.md",
"_JA.md",
"_KO.md",
]
@dataclass(order=True)
class Replacement:
start: int
end: int
text: str
def contains_han(text: str) -> bool:
return bool(HAN_RE.search(text))
_CACHE: dict[str, str] = {}
def translate_text(text: str) -> str:
text = text.strip("\n")
if not text or not contains_han(text):
return text
if text in _CACHE:
return _CACHE[text]
url = TRANSLATE_URL.format(query=urllib.parse.quote(text))
last_error: Exception | None = None
for attempt in range(3):
try:
with urllib.request.urlopen(url, timeout=20) as resp:
payload = json.loads(resp.read().decode("utf-8"))
translated = "".join(part[0] for part in payload[0] if part and part[0])
translated = normalize_translation(translated)
_CACHE[text] = translated
return translated
except Exception as exc: # pragma: no cover - best effort script
last_error = exc
time.sleep(0.5 * (attempt + 1))
raise RuntimeError(f"Translation failed for text: {text!r}") from last_error
def normalize_translation(text: str) -> str:
text = text.replace("K line", "K-line")
text = text.replace("K Line", "K-line")
text = text.replace("candlestick line", "candlestick")
text = text.replace("real-time market", "realtime market")
text = text.replace("real-time quote", "realtime quote")
return text
def line_start_offsets(text: str) -> list[int]:
starts = [0]
for match in re.finditer(r"\n", text):
starts.append(match.end())
return starts
def to_offset(line_starts: list[int], lineno: int, col: int) -> int:
return line_starts[lineno - 1] + col
def translate_preserving_indent(text: str) -> str:
lines = text.splitlines(keepends=True)
out: list[str] = []
for line in lines:
body = line.rstrip("\r\n")
newline = line[len(body):]
if not contains_han(body):
out.append(line)
continue
leading = re.match(r"\s*", body).group(0)
translated = translate_text(body[len(leading):])
out.append(f"{leading}{translated}{newline}")
return "".join(out)
DOCSTRING_ONE_LINE_RE = re.compile(
r'^(?P<indent>\s*)(?P<prefix>[rubfRUBF]*)(?P<quote>"""|\'\'\')(?P<body>.*?)(?P=quote)(?P<trailing>\s*(?:#.*)?)$'
)
DOCSTRING_START_RE = re.compile(
r'^(?P<indent>\s*)(?P<prefix>[rubfRUBF]*)(?P<quote>"""|\'\'\')(?P<body>.*)$'
)
def translate_python_file(path: pathlib.Path) -> bool:
src = path.read_text(encoding="utf-8")
docstring_translated = translate_python_docstrings(src)
line_starts = line_start_offsets(docstring_translated)
replacements: list[Replacement] = []
for token in tokenize.generate_tokens(io.StringIO(docstring_translated).readline):
if token.type != tokenize.COMMENT or not contains_han(token.string):
continue
leading = re.match(r"#\s*", token.string).group(0)
translated = translate_text(token.string[len(leading):])
replacement = f"{leading}{translated}"
replacements.append(
Replacement(
start=to_offset(line_starts, token.start[0], token.start[1]),
end=to_offset(line_starts, token.end[0], token.end[1]),
text=replacement,
)
)
if not replacements and docstring_translated == src:
return False
new_src = apply_replacements(docstring_translated, replacements)
if new_src != src:
path.write_text(new_src, encoding="utf-8")
return True
return False
def translate_python_docstrings(src: str) -> str:
lines = src.splitlines(keepends=True)
out: list[str] = []
in_docstring = False
active_quote = ""
for line in lines:
stripped = line.lstrip()
if not in_docstring:
one_line = DOCSTRING_ONE_LINE_RE.match(line.rstrip("\n"))
if one_line and stripped.startswith(one_line.group("prefix") + one_line.group("quote")):
body = one_line.group("body")
if contains_han(body):
body = translate_text(body)
newline = "\n" if line.endswith("\n") else ""
out.append(
f"{one_line.group('indent')}{one_line.group('prefix')}{one_line.group('quote')}"
f"{body}{one_line.group('quote')}{one_line.group('trailing')}{newline}"
)
continue
start = DOCSTRING_START_RE.match(line.rstrip("\n"))
if start and stripped.startswith(start.group("prefix") + start.group("quote")):
in_docstring = True
active_quote = start.group("quote")
body = start.group("body")
if contains_han(body):
body = translate_text(body)
newline = "\n" if line.endswith("\n") else ""
out.append(
f"{start.group('indent')}{start.group('prefix')}{start.group('quote')}{body}{newline}"
)
continue
out.append(line)
continue
line_no_newline = line.rstrip("\n")
if active_quote in line_no_newline:
before, after = line_no_newline.split(active_quote, 1)
if contains_han(before):
before = translate_preserving_indent(before).rstrip("\n")
out.append(f"{before}{active_quote}{after}{'\n' if line.endswith('\n') else ''}")
in_docstring = False
active_quote = ""
else:
out.append(translate_preserving_indent(line))
return "".join(out)
def translate_markdown_file(path: pathlib.Path) -> bool:
src = path.read_text(encoding="utf-8")
lines = src.splitlines(keepends=True)
out: list[str] = []
in_fence = False
changed = False
for line in lines:
stripped = line.strip()
if stripped.startswith("```"):
in_fence = not in_fence
out.append(line)
continue
if in_fence or not contains_han(line):
out.append(line)
continue
translated = translate_preserving_indent(line)
out.append(translated)
changed = changed or translated != line
if changed:
path.write_text("".join(out), encoding="utf-8")
return changed
def apply_replacements(text: str, replacements: list[Replacement]) -> str:
dedup: list[Replacement] = []
seen: set[tuple[int, int]] = set()
for item in sorted(replacements, reverse=True):
key = (item.start, item.end)
if key in seen:
continue
seen.add(key)
dedup.append(item)
for item in dedup:
text = text[: item.start] + item.text + text[item.end :]
return text
def should_skip(path: pathlib.Path) -> bool:
posix = path.as_posix()
return any(pattern in posix for pattern in SKIP_PATH_PATTERNS)
def iter_python_targets() -> list[pathlib.Path]:
paths: set[pathlib.Path] = set()
for pattern in PYTHON_GLOBS:
paths.update(ROOT.glob(pattern))
return sorted(path for path in paths if path.is_file() and not should_skip(path))
def iter_markdown_targets() -> list[pathlib.Path]:
return [ROOT / target for target in MARKDOWN_TARGETS if (ROOT / target).exists()]
def resolve_targets(args: argparse.Namespace) -> tuple[list[pathlib.Path], list[pathlib.Path]]:
if args.paths:
python_files: list[pathlib.Path] = []
markdown_files: list[pathlib.Path] = []
for raw in args.paths:
path = (ROOT / raw).resolve() if not pathlib.Path(raw).is_absolute() else pathlib.Path(raw)
if not path.exists():
continue
if path.suffix == ".py":
python_files.append(path)
elif path.suffix == ".md":
markdown_files.append(path)
return python_files, markdown_files
return iter_python_targets(), iter_markdown_targets()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("paths", nargs="*")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
python_targets, markdown_targets = resolve_targets(args)
changed_files: list[str] = []
for path in python_targets:
if args.verbose:
print(f"[py] {path.relative_to(ROOT).as_posix()}", file=sys.stderr)
if translate_python_file(path):
changed_files.append(path.relative_to(ROOT).as_posix())
for path in markdown_targets:
if args.verbose:
print(f"[md] {path.relative_to(ROOT).as_posix()}", file=sys.stderr)
if translate_markdown_file(path):
changed_files.append(path.relative_to(ROOT).as_posix())
for relpath in changed_files:
print(relpath)
return 0
if __name__ == "__main__":
raise SystemExit(main())