/
/
/
1"""
2Generate the English source file that Lokalise syncs against.
3
4Concatenates every ``strings.json`` authoring file (music_assistant/strings.json + each
5per-provider/per-controller strings.json) into one flat, fully-qualified ``key -> English``
6JSON at ``music_assistant/translations/en.json``. ``lokalise-upload.yml`` pushes that file to
7Lokalise; ``lokalise-download.yml`` pulls the translated languages back into translations/.
8
9A string value may reference another (typically shared ``common.``) string with the Home
10Assistant style token ``[%key:owner::path::to::key%]`` (``::`` separates the dotted key
11segments). Such a reference declares that an owner reuses a shared string: the target is
12validated to exist and the referencing key is omitted from the generated source, so Lokalise
13translates the shared string once while the server resolves the owner's key at runtime via its
14owner -> common fallback.
15
16A duplicated key inside an authoring file would silently lose strings (JSON parsers keep only
17the last value), so the build fails loudly on duplicate keys at any nesting depth.
18
19Standalone (no ``music_assistant`` imports) so it runs under any music-assistant-models version
20and without the full server import chain.
21
22Usage:
23 uv run -m scripts.build_translations # (re)generate the source file
24 uv run -m scripts.build_translations --check # verify it is up to date (CI/pre-commit)
25"""
26
27from __future__ import annotations
28
29import json
30import os
31import re
32import sys
33from pathlib import Path
34from typing import Any
35
36import orjson
37
38# ruff: noqa: T201
39
40# repo paths (this file lives at <repo>/scripts/build_translations.py)
41_REPO_ROOT = str(Path(__file__).resolve().parents[1])
42PACKAGE_ROOT = os.path.join(_REPO_ROOT, "music_assistant")
43PROVIDERS_PATH = os.path.join(PACKAGE_ROOT, "providers")
44CONTROLLERS_PATH = os.path.join(PACKAGE_ROOT, "controllers")
45TRANSLATIONS_PATH = os.path.join(PACKAGE_ROOT, "translations")
46# the shared/common source strings file (subkeys: settings, media, ...) at the package root
47ROOT_STRINGS_FILE = os.path.join(PACKAGE_ROOT, "strings.json")
48
49SOURCE_LANGUAGE = "en"
50SOURCE_FILE = os.path.join(TRANSLATIONS_PATH, f"{SOURCE_LANGUAGE}.json")
51COMMON_PREFIX = "common."
52# Home Assistant style reference token: [%key:owner::path::to::key%] -> owner.path.to.key
53REFERENCE_PATTERN = re.compile(r"^\[%key:(.+)%\]$")
54
55
56def build_translations_source() -> dict[str, str]:
57 """Assemble the flat English source from all authoring strings.json files."""
58 raw: dict[str, str] = {}
59 duplicates: list[str] = []
60 for prefix, path in _collect_source_files():
61 with open(path, "rb") as file:
62 content = file.read()
63 # orjson below silently keeps the last value when an object repeats a key, which
64 # would drop strings from the generated source; detect duplicates loudly instead.
65 rel_path = os.path.relpath(path, _REPO_ROOT)
66 try:
67 duplicates.extend(
68 f"{rel_path}: {key_path}" for key_path in _find_duplicate_keys(content)
69 )
70 data = orjson.loads(content)
71 except (json.JSONDecodeError, UnicodeDecodeError) as err:
72 raise ValueError(f"{rel_path}: {err}") from err
73 _flatten_into(data, prefix, raw)
74 if duplicates:
75 raise ValueError("Duplicate strings.json key(s):\n " + "\n ".join(sorted(duplicates)))
76 return _resolve_references(raw)
77
78
79def _collect_source_files() -> list[tuple[str, str]]:
80 """Discover all English source strings.json files as (key prefix, path) pairs."""
81 source_files: list[tuple[str, str]] = []
82 # shared/common strings at the package root
83 if Path(ROOT_STRINGS_FILE).is_file():
84 source_files.append((COMMON_PREFIX, ROOT_STRINGS_FILE))
85 # per-provider strings (sibling of manifest.json); skip template/test providers (their
86 # strings must not reach Lokalise as translator noise)
87 for entry in _iter_subdirs(PROVIDERS_PATH):
88 if entry.startswith("_") or entry == "test":
89 continue
90 path = os.path.join(PROVIDERS_PATH, entry, "strings.json")
91 if Path(path).is_file():
92 source_files.append((f"provider.{entry}.", path))
93 # per-package-controller strings
94 for entry in _iter_subdirs(CONTROLLERS_PATH):
95 path = os.path.join(CONTROLLERS_PATH, entry, "strings.json")
96 if Path(path).is_file():
97 source_files.append((f"core.{entry}.", path))
98 return source_files
99
100
101def _iter_subdirs(path: str) -> list[str]:
102 """Return non-hidden subdirectory names of a path (empty if it does not exist)."""
103 if not Path(path).is_dir():
104 return []
105 return [
106 entry
107 for entry in os.listdir(path) # noqa: PTH208
108 if not entry.startswith(".") and Path(os.path.join(path, entry)).is_dir()
109 ]
110
111
112class _RawJsonObject(list[tuple[str, Any]]):
113 """A JSON object kept as its raw key/value pairs, so duplicate keys stay observable."""
114
115
116def _find_duplicate_keys(content: bytes) -> list[str]:
117 """
118 Return the dotted key path of every duplicated object key in a JSON document.
119
120 :param content: The raw JSON document to inspect.
121 """
122 duplicates: list[str] = []
123
124 def _walk(node: Any, path: str) -> None:
125 if isinstance(node, _RawJsonObject):
126 seen: set[str] = set()
127 for key, value in node:
128 key_path = f"{path}.{key}" if path else key
129 if key in seen:
130 duplicates.append(key_path)
131 seen.add(key)
132 _walk(value, key_path)
133 elif isinstance(node, list):
134 for index, value in enumerate(node):
135 _walk(value, f"{path}[{index}]")
136
137 _walk(json.loads(content, object_pairs_hook=_RawJsonObject), "")
138 return duplicates
139
140
141def _flatten_into(data: dict[str, Any], prefix: str, out: dict[str, str]) -> None:
142 """Flatten a nested strings dict into dotted, prefixed keys with string leaves."""
143 for key, value in data.items():
144 full_key = f"{prefix}{key}"
145 if isinstance(value, dict):
146 _flatten_into(value, f"{full_key}.", out)
147 elif isinstance(value, str):
148 out[full_key] = value
149
150
151def _resolve_references(raw: dict[str, str]) -> dict[str, str]:
152 """
153 Drop reference-valued keys after validating each points at an existing concrete string.
154
155 A reference value ``[%key:owner::path::to::key%]`` declares that this key reuses a shared
156 string defined elsewhere; the referenced key stays the single translatable source, so the
157 referencing key is left out of the generated catalog (the server resolves it at runtime via
158 its owner -> common fallback).
159
160 :param raw: The flattened catalog, still containing any reference-valued keys.
161 :raises ValueError: When a reference points at a key that is not a concrete string.
162 """
163 concrete = {key: value for key, value in raw.items() if not REFERENCE_PATTERN.match(value)}
164 unresolved: list[str] = []
165 for key, value in raw.items():
166 if not (match := REFERENCE_PATTERN.match(value)):
167 continue
168 target = match.group(1).replace("::", ".")
169 if target not in concrete:
170 unresolved.append(f"{key} -> {target}")
171 if unresolved:
172 raise ValueError(
173 "Unresolved translation reference target(s): " + ", ".join(sorted(unresolved))
174 )
175 return concrete
176
177
178def _render(catalog: dict[str, str]) -> bytes:
179 """Render the catalog as deterministic, sorted, indented JSON."""
180 return orjson.dumps(
181 dict(sorted(catalog.items())),
182 option=orjson.OPT_INDENT_2 | orjson.OPT_APPEND_NEWLINE,
183 )
184
185
186def main() -> int:
187 """Generate (or, with --check, validate) the Lokalise source file."""
188 try:
189 source = build_translations_source()
190 except ValueError as err:
191 print(str(err), file=sys.stderr)
192 return 1
193 rendered = _render(source)
194 if "--check" in sys.argv[1:]:
195 existing = b""
196 if Path(SOURCE_FILE).is_file():
197 with open(SOURCE_FILE, "rb") as file:
198 existing = file.read()
199 if existing != rendered:
200 print(
201 f"{SOURCE_FILE} is out of date. "
202 "Run `uv run -m scripts.build_translations` and commit the result.",
203 file=sys.stderr,
204 )
205 return 1
206 return 0
207 Path(TRANSLATIONS_PATH).mkdir(parents=True, exist_ok=True)
208 with open(SOURCE_FILE, "wb") as file:
209 file.write(rendered)
210 print(f"Wrote {len(source)} source strings to {SOURCE_FILE}")
211 return 0
212
213
214if __name__ == "__main__":
215 raise SystemExit(main())
216