diff --git a/TODO.md b/TODO.md index 047013cc..6428192f 100644 --- a/TODO.md +++ b/TODO.md @@ -104,6 +104,19 @@ Per-item detail lives in the dated work log below. **Since 2026-07 versions are --- +### ★ Website translated into all 13 game languages (2026-08-10, branch feat/wix-i18n-locale-tools) +The full website (all pages, menu, forms, image alt texts) now has complete translations in every +game language: en/it plus newly created es, fr, nl, pl, pt, tr, ru, uk, ja, ko, zh — the 11 new Wix +locales were created **HIDDEN** and stay invisible until reviewed and flipped visible. Translations +were produced locally (one Claude subagent per language, game locale files as terminology glossary, +EN as meaning reference, link-/markup-preserving checks + dry-run before every import); Wix machine +translation and its word credits were not used. Tooling added: `create_locales.py` (create HIDDEN +secondary locales), `curate_todo.py` (filter template leftovers out of audit todos), +`import_translations.py` now copies the required `parentEntityId` from the primary-language record +on create. Remaining editor-only tasks: per-language SEO meta/slugs, and the EN school-club page +additions (dataquery links are numbered per language, so link-bearing sections can't be appended via +API into an existing translation). + ### ★ Website i18n tooling: Wix translation audit + import (2026-08-10, branch feat/wix-i18n-audit) `tools/wix-i18n/` — manage the website's translations via the Wix Multilingual REST APIs, bypassing Wix's machine translation. `audit_translations.py` (read-only) dumps schemas + per-locale contents and diff --git a/tools/wix-i18n/create_locales.py b/tools/wix-i18n/create_locales.py new file mode 100644 index 00000000..93be53fa --- /dev/null +++ b/tools/wix-i18n/create_locales.py @@ -0,0 +1,37 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["requests"] +# /// +"""Create secondary site locales (HIDDEN) for the given language codes. + +Skips locales that already exist. Usage: + uv run create_locales.py es fr nl pl pt tr ru uk ja ko zh +""" +import sys + +from audit_translations import API, fetch_locales, load_env, make_session + +sys.stdout.reconfigure(encoding="utf-8") + + +def main() -> None: + codes = sys.argv[1:] + if not codes: + sys.exit("usage: create_locales.py [...]") + s = make_session(load_env()) + existing = {l["languageCode"] for l in fetch_locales(s)} + for code in codes: + if code in existing: + print(f"{code}: exists, skipped") + continue + r = s.post(f"{API}/locales/v2/locale", + json={"locale": {"languageCode": code, "visibility": "HIDDEN"}}) + if r.status_code == 200: + loc = r.json()["locale"] + print(f"{code}: created HIDDEN (id={loc['id']}, display={loc.get('effectiveDisplayName')})") + else: + print(f"{code}: FAILED {r.status_code} {r.text[:200]}") + + +if __name__ == "__main__": + main() diff --git a/tools/wix-i18n/curate_todo.py b/tools/wix-i18n/curate_todo.py new file mode 100644 index 00000000..eadb8503 --- /dev/null +++ b/tools/wix-i18n/curate_todo.py @@ -0,0 +1,44 @@ +# /// script +# requires-python = ">=3.11" +# /// +"""Filter a todo-.json down to real, visitor-facing content. + +Drops known template leftovers: items whose schema no longer exists on the +site (deleted template collections), the template job-application form, the +Translation-Manager pseudo page title and "Image Title" placeholder values. + +Usage: uv run curate_todo.py es fr ... (writes out/todo--curated.json) +""" +import json +import sys +from pathlib import Path + +sys.stdout.reconfigure(encoding="utf-8") +OUT = Path(__file__).resolve().parent / "out" + +TEMPLATE_ENTITIES = { + "fde85b74-ac43-4dc1-aa38-c47fee4407fb", # Bewerbungsformular (template job form) + "masterPage", +} +PLACEHOLDER_TEXTS = {"Image Title"} + + +def curate(locale: str) -> None: + todo = json.loads((OUT / f"todo-{locale}.json").read_text(encoding="utf-8")) + curated = [] + for item in todo: + if item["schema"] == "unknown schema" or item["entityId"] in TEMPLATE_ENTITIES: + continue + fields = {k: f for k, f in item["fields"].items() + if f["text"].strip() not in PLACEHOLDER_TEXTS} + if fields: + curated.append({**item, "fields": fields}) + n_fields = sum(len(i["fields"]) for i in curated) + chars = sum(len(f["text"]) for i in curated for f in i["fields"].values()) + (OUT / f"todo-{locale}-curated.json").write_text( + json.dumps(curated, indent=1, ensure_ascii=False), encoding="utf-8") + print(f"{locale}: {len(curated)} items, {n_fields} fields, {chars} chars") + + +for loc in sys.argv[1:]: + curate(loc) diff --git a/tools/wix-i18n/import_translations.py b/tools/wix-i18n/import_translations.py index 4157035a..5a83839b 100644 --- a/tools/wix-i18n/import_translations.py +++ b/tools/wix-i18n/import_translations.py @@ -31,8 +31,8 @@ from audit_translations import API, load_env, make_session, text_preview -def fetch_existing_keys(s: requests.Session, locale: str) -> set[tuple[str, str]]: - keys: set[tuple[str, str]] = set() +def fetch_contents_map(s: requests.Session, locale: str) -> dict[tuple[str, str], dict]: + contents: dict[tuple[str, str], dict] = {} cursor = None while True: paging: dict = {"limit": 100} @@ -44,11 +44,11 @@ def fetch_existing_keys(s: requests.Session, locale: str) -> set[tuple[str, str] r = s.post(f"{API}/translation-content/v1/contents/search", json=body) r.raise_for_status() d = r.json() - keys |= {(c["schemaId"], c["entityId"]) for c in d.get("contents", [])} + contents.update({(c["schemaId"], c["entityId"]): c for c in d.get("contents", [])}) pm = d.get("pagingMetadata", {}) cursor = pm.get("cursors", {}).get("next") if not pm.get("hasNext"): - return keys + return contents def field_text(f: dict, base_dir: Path) -> str: @@ -58,14 +58,18 @@ def field_text(f: dict, base_dir: Path) -> str: return f["text"] -def content_payload(item: dict, locale: str, base_dir: Path) -> dict: - return { +def content_payload(item: dict, locale: str, base_dir: Path, + parent_entity_id: str | None = None) -> dict: + payload = { "schemaId": item["schemaId"], "entityId": item["entityId"], "locale": locale, "fields": {key: {"textValue": field_text(f, base_dir), "published": True} for key, f in item["fields"].items()}, } + if parent_entity_id: + payload["parentEntityId"] = parent_entity_id + return payload def main() -> None: @@ -85,7 +89,10 @@ def main() -> None: env = load_env() s = make_session(env) - existing = fetch_existing_keys(s, locale) + existing = set(fetch_contents_map(s, locale)) + # Some schemas (editor components) require the parent entity (page) on create; + # copy it from the primary-language record. + primary = fetch_contents_map(s, "de") creates = [i for i in items if (i["schemaId"], i["entityId"]) not in existing] updates = [i for i in items if (i["schemaId"], i["entityId"]) in existing] @@ -103,8 +110,10 @@ def main() -> None: failures = 0 for item in creates: + de_record = primary.get((item["schemaId"], item["entityId"]), {}) r = s.post(f"{API}/translation-content/v1/contents", - json={"content": content_payload(item, locale, base_dir)}) + json={"content": content_payload(item, locale, base_dir, + de_record.get("parentEntityId"))}) if r.status_code != 200: failures += 1 print(f"FAILED create {item['schema']} {item['entityId']}: "