mirror of https://github.com/stenzek/duckstation
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
164 lines
6.5 KiB
Python
164 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Safely apply completed JSONL translation batches to a Qt TS catalog."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
if __package__ in (None, ""):
|
|
import sys
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from translation.ts_utils import ( # noqa: E402
|
|
SCHEMA,
|
|
MessageIdentity,
|
|
catalog_fingerprint,
|
|
load_jsonl,
|
|
parse_catalog,
|
|
placeholders_match,
|
|
replace_translation_node,
|
|
scan_raw_messages,
|
|
validate_translation,
|
|
)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("catalog", type=Path, help="Qt Linguist .ts catalog")
|
|
parser.add_argument("batches", nargs="+", type=Path, help="completed JSONL batch files")
|
|
parser.add_argument("--write", action="store_true", help="atomically update the catalog; default is dry-run")
|
|
return parser.parse_args()
|
|
|
|
|
|
def record_identity(record: dict[str, object]) -> MessageIdentity:
|
|
return MessageIdentity(
|
|
context=str(record.get("context", "")),
|
|
source=str(record.get("source", "")),
|
|
comment=str(record.get("comment", "")),
|
|
extracomment=str(record.get("extracomment", "")),
|
|
numerus=bool(record.get("numerus", False)),
|
|
occurrence=int(record.get("occurrence", 0)),
|
|
)
|
|
|
|
|
|
def resolve_target(record: dict[str, object]) -> tuple[str | None, list[str] | None] | None:
|
|
singular = record.get("target_translation")
|
|
plurals = record.get("target_plural_translations")
|
|
accept_current = record.get("accept_current") is True
|
|
if accept_current:
|
|
if singular is not None or plurals is not None:
|
|
raise ValueError(f"{record.get('id')}: accept_current cannot be combined with target fields")
|
|
current_plurals = record.get("current_plural_translations")
|
|
if current_plurals:
|
|
plurals = current_plurals
|
|
else:
|
|
singular = record.get("current_translation")
|
|
|
|
if singular is None and plurals is None:
|
|
return None
|
|
if singular is not None and plurals is not None:
|
|
raise ValueError(f"{record.get('id')}: set either singular or plural target, not both")
|
|
if singular is not None:
|
|
if not isinstance(singular, str) or not singular.strip():
|
|
raise ValueError(f"{record.get('id')}: target_translation must be a nonempty string")
|
|
return singular, None
|
|
if (
|
|
not isinstance(plurals, list)
|
|
or not plurals
|
|
or any(not isinstance(value, str) or not value.strip() for value in plurals)
|
|
):
|
|
raise ValueError(f"{record.get('id')}: target_plural_translations must contain nonempty strings")
|
|
return None, plurals
|
|
|
|
|
|
def atomic_write(path: Path, text: str) -> None:
|
|
mode = path.stat().st_mode
|
|
temporary_name: str | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", newline="", dir=path.parent, delete=False) as stream:
|
|
temporary_name = stream.name
|
|
stream.write(text)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.chmod(temporary_name, mode)
|
|
os.replace(temporary_name, path)
|
|
finally:
|
|
if temporary_name and os.path.exists(temporary_name):
|
|
os.unlink(temporary_name)
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
metadata, records = load_jsonl(args.batches)
|
|
if len(metadata) != len(args.batches):
|
|
raise SystemExit("each batch must contain exactly one metadata record")
|
|
for item in metadata:
|
|
if item.get("schema") != SCHEMA:
|
|
raise SystemExit(f"unsupported batch schema: {item.get('schema')!r}")
|
|
|
|
_, messages = parse_catalog(args.catalog)
|
|
fingerprint = catalog_fingerprint(messages)
|
|
expected_fingerprints = {str(item.get("catalog_fingerprint", "")) for item in metadata}
|
|
if expected_fingerprints != {fingerprint}:
|
|
raise SystemExit("catalog source identity has changed since extraction; export fresh batches")
|
|
|
|
with args.catalog.open("r", encoding="utf-8", newline="") as stream:
|
|
raw_text = stream.read()
|
|
spans = scan_raw_messages(raw_text)
|
|
seen: set[str] = set()
|
|
replacements: list[tuple[int, int, str]] = []
|
|
skipped = 0
|
|
for record in records:
|
|
identifier = str(record.get("id", ""))
|
|
identity = record_identity(record)
|
|
if identifier != identity.identifier:
|
|
raise SystemExit(f"{identifier or '<missing id>'}: record identity does not match its id")
|
|
if identifier in seen:
|
|
raise SystemExit(f"duplicate record id across batches: {identifier}")
|
|
seen.add(identifier)
|
|
target = resolve_target(record)
|
|
if target is None:
|
|
skipped += 1
|
|
continue
|
|
span = spans.get(identifier)
|
|
if span is None:
|
|
raise SystemExit(f"message no longer exists in catalog: {identifier}")
|
|
if span.translation_type != "unfinished":
|
|
raise SystemExit(f"refusing to overwrite {span.translation_type} translation: {identifier}")
|
|
singular, plurals = target
|
|
if span.numerus != (plurals is not None):
|
|
raise SystemExit(f"singular/plural target does not match catalog message: {identifier}")
|
|
values = plurals if plurals is not None else [singular or ""]
|
|
for value in values:
|
|
problems = validate_translation(
|
|
identity.source,
|
|
value,
|
|
allow_missing_placeholders=plurals is not None,
|
|
)
|
|
if problems:
|
|
raise SystemExit(f"{identifier}: {'; '.join(problems)}")
|
|
if plurals is not None and not any(placeholders_match(identity.source, value) for value in plurals):
|
|
raise SystemExit(f"{identifier}: no plural form preserves the complete source placeholder set")
|
|
replacement_block = replace_translation_node(span.block, singular, plurals)
|
|
replacements.append((span.start, span.end, replacement_block))
|
|
|
|
updated_text = raw_text
|
|
for start, end, replacement in sorted(replacements, reverse=True):
|
|
updated_text = updated_text[:start] + replacement + updated_text[end:]
|
|
if args.write and replacements:
|
|
atomic_write(args.catalog, updated_text)
|
|
|
|
action = "Applied" if args.write else "Would apply"
|
|
print(f"{action} {len(replacements)} translation(s); skipped {skipped} record(s) without targets.")
|
|
if not args.write:
|
|
print("Dry-run only; pass --write to update the catalog.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|