feat(i18n): translate labels, and keep brand names out of it
name and title were translated at render time but never machine-filled, on the grounds that no backend tells the menu label "Pictures" from the brand "Mastodon". That left the visible half of a card in English. They are filled now, and the two key sets collapse into one. The brands need somewhere to be named instead. app/i18n/keep.txt lists them, one per line, and every entry is stored as itself in every target language: no request, and never over an entry written by hand. --keep adds one-off strings, --keep-file points elsewhere. The shipped list holds the 43 product names that appear as name: or title: in config.sample.yaml. Generic labels — Pictures, Imprint, Settings, Certificates — are deliberately absent, and so are Cybermaster, Polymath and Yachtmaster, which read as brand or as job title depending on who is asking. Two of these behaviours first shipped unguarded. A test that protected a string and asserted the hand-written value survived passed either way, because a run where nothing is missing reports "complete" and never writes; and nothing exercised main(), so the keep file could stop being read without a failure. Both are covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ sys.path.insert(0, str(REPO_ROOT))
|
||||
from app.utils import i18n # noqa: E402
|
||||
|
||||
DEFAULT_CONFIG_PATH = REPO_ROOT / "app" / "config.yaml"
|
||||
DEFAULT_KEEP_PATH = i18n.I18N_DIR / "keep.txt"
|
||||
REQUEST_TIMEOUT = 30
|
||||
|
||||
|
||||
@@ -38,11 +39,27 @@ def collect_sources(node, key=None, found=None):
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
collect_sources(item, key, found)
|
||||
elif isinstance(node, str) and key in i18n.AUTOFILL_KEYS and node.strip():
|
||||
elif isinstance(node, str) and key in i18n.TRANSLATABLE_KEYS and node.strip():
|
||||
found.add(node)
|
||||
return found
|
||||
|
||||
|
||||
def read_keep(path):
|
||||
"""Return the strings listed in ``path``, ignoring blanks and comments.
|
||||
|
||||
Args:
|
||||
path: a text file with one string per line, or a path that does not exist.
|
||||
"""
|
||||
if not path.is_file():
|
||||
return []
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
return [
|
||||
stripped
|
||||
for stripped in (line.strip() for line in lines)
|
||||
if stripped and not stripped.startswith("#")
|
||||
]
|
||||
|
||||
|
||||
class BackendError(Exception):
|
||||
"""The translation backend answered in a way the run cannot continue from."""
|
||||
|
||||
@@ -144,7 +161,7 @@ def translate(session, url, api_key, text, target):
|
||||
return translated
|
||||
|
||||
|
||||
def sync(url, api_key, sources, targets, directory):
|
||||
def sync(url, api_key, sources, targets, directory, keep=()):
|
||||
"""Fill and write the catalogue of every language in ``targets``.
|
||||
|
||||
Args:
|
||||
@@ -153,6 +170,7 @@ def sync(url, api_key, sources, targets, directory):
|
||||
sources: English strings to translate.
|
||||
targets: language codes to fill.
|
||||
directory: catalogue directory to write into.
|
||||
keep: strings to store as themselves instead of translating.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
session = requests.Session()
|
||||
@@ -168,6 +186,9 @@ def sync(url, api_key, sources, targets, directory):
|
||||
if catalog is None:
|
||||
continue
|
||||
|
||||
protected = {word: word for word in keep if word not in catalog}
|
||||
catalog.update(protected)
|
||||
|
||||
shipped = (
|
||||
{}
|
||||
if directory == i18n.UI_DIR
|
||||
@@ -190,7 +211,7 @@ def sync(url, api_key, sources, targets, directory):
|
||||
catalog[source] = translated
|
||||
added += 1
|
||||
|
||||
if not added:
|
||||
if not added and not protected:
|
||||
print(f" ! {target}: nothing translated, leaving the file untouched")
|
||||
continue
|
||||
|
||||
@@ -227,6 +248,24 @@ def main(argv=None):
|
||||
"ui: the interface strings that ship with the project."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep",
|
||||
nargs="+",
|
||||
default=[],
|
||||
metavar="STRING",
|
||||
help=(
|
||||
"Store these as themselves instead of translating, "
|
||||
"in addition to --keep-file."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-file",
|
||||
type=Path,
|
||||
default=DEFAULT_KEEP_PATH,
|
||||
help=(
|
||||
f"One string per line, stored untranslated (default: {DEFAULT_KEEP_PATH})."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--languages",
|
||||
nargs="+",
|
||||
@@ -245,8 +284,18 @@ def main(argv=None):
|
||||
sources = collect_sources(config) | set(i18n.UI_STRINGS)
|
||||
print(f"{len(sources)} string(s) in {args.config} and the interface")
|
||||
|
||||
keep = read_keep(args.keep_file) + args.keep
|
||||
print(f"{len(keep)} string(s) kept untranslated")
|
||||
|
||||
try:
|
||||
sync(args.url.rstrip("/"), args.api_key, sources, args.languages, directory)
|
||||
sync(
|
||||
args.url.rstrip("/"),
|
||||
args.api_key,
|
||||
sources,
|
||||
args.languages,
|
||||
directory,
|
||||
keep,
|
||||
)
|
||||
except BackendError as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
Reference in New Issue
Block a user