Files
cheats-updater/lib/borealis/scripts/i18n-linter.py
2026-03-05 20:18:29 +01:00

250 lines
8.2 KiB
Python

"""
Borealis, a Nintendo Switch UI Library
Copyright (C) 2020 natinusala
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
# Run with Python 3
import argparse
import json
import re
from jsonpointer import resolve_pointer, JsonPointerException
from pathlib import Path
# All locales supported by HOS
_SUPPORTED_LOCALES = [
"ja",
"en-US",
"en-GB",
"fr",
"fr-CA",
"de",
"it",
"es",
"zh-CN",
"zh-Hans",
"zh-Hant",
"zh-TW",
"ko",
"nl",
"pt",
"pt-BR",
"ru",
"es-419",
]
# The default locale used by brls
_DEFAULT_LOCALE = "en-US"
# All illegal chars in the string keys
# Mostly jsonpointer reserved chars
_ILLEGAL_KEYS_CHARS = [
"/",
"~",
" ",
"#",
"$",
]
# All locales and their strings, filled by _check_locales
# {locale -> {key -> string}}
_LOCALES_CACHE = {}
def _folder_exists(path: Path, errors: list, warnings: list) -> tuple:
"""Checks that the i18n folder exists and is a folder"""
if not path.exists():
errors.append((1, f"Cannot continue with the checks: folder \"{path}\" doesn't exist"))
elif not path.is_dir():
errors.append((2, f"Cannot continue with the checks: file \"{path}\" is not a folder"))
def _ensure_default_locale(path: Path, errors: list, warnings: list) -> tuple:
"""Ensures that the default locale exists"""
defaultlocalefile = path / f"{_DEFAULT_LOCALE}"
if not defaultlocalefile.exists() or not defaultlocalefile.is_dir():
warnings.append((6, f"Default locale {_DEFAULT_LOCALE} is missing from the i18n folder"))
def _check_locales(path: Path, errors: list, warnings: list) -> tuple:
"""
Checks that the i18n only contains known locales
and loads them for subsequent checks
"""
for f in path.iterdir():
# Load locale
if f.is_dir():
# Known locale
if f.name not in _SUPPORTED_LOCALES:
warnings.append((3, f"Unknown locale for folder \"{f.name}\""))
continue
#Load all JSON files inside
for ff in f.iterdir():
# Directory
if ff.is_dir():
warnings.append((1, f"{f.name} folder contains stray folder \"{ff.name}\""))
# Known format
elif not ff.name.endswith(".json"):
warnings.append((2, f"{f.name} folder contains stray file \"{ff.name}\""))
# Load it
else:
with open(ff, "r") as jsonf:
_LOCALES_CACHE.setdefault(f.name, {})
try:
_LOCALES_CACHE[f.name][ff.name[:-5]] = json.loads(jsonf.read())
except json.JSONDecodeError as e:
errors.append((5, f"Cannot parse JSON file \"{f.name}/{ff.name}\": {e}"))
return # don't bother continuing
# File
else:
warnings.append((2, f"i18n folder contains stray file \"{f.name}\""))
if _DEFAULT_LOCALE not in _LOCALES_CACHE:
_LOCALES_CACHE[_DEFAULT_LOCALE] = {}
def _check_types(path: Path, errors: list, warnings: list) -> tuple:
"""Checks that locales only contain valid data"""
def _check_node(breadcrumb: str, key: str, value: dict, locale: str):
# Illegal chars in key
for char in _ILLEGAL_KEYS_CHARS:
if char in key:
errors.append((6, f"String \"{breadcrumb}\" of {locale} locale contains illegal character \"{char}\" in its name"))
# Dict
if isinstance(value, dict):
for nested_key in value:
new_breadcrumb = f"{breadcrumb}/{nested_key}" if breadcrumb else nested_key
_check_node(new_breadcrumb, nested_key, value[nested_key], locale)
# Not strings
elif not isinstance(value, str):
errors.append((7, f"String \"{breadcrumb}\" of {locale} locale contains data \"{str(value)}\" of invalid type \"{type(value).__name__}\""))
for locale in _LOCALES_CACHE:
_check_node("", "", _LOCALES_CACHE[locale], locale)
def _check_untranslated_strings(path: Path, errors: list, warnings: list) -> tuple:
"""
Ensure there are no untranslated strings
"""
def _check_node(breadcrumb: str, value: dict):
for nested_key in value:
if breadcrumb:
base = f"{breadcrumb}/{nested_key}"
else:
base = nested_key
# Dict
if isinstance(value[nested_key], dict):
_check_node(base, value[nested_key])
# Str
else:
for locale in _LOCALES_CACHE:
if locale == _DEFAULT_LOCALE:
continue
pointer = f"/{base}"
try:
resolve_pointer(_LOCALES_CACHE[locale], pointer)
except JsonPointerException:
warnings.append((4, f"Locale {locale} is missing string \"{base}\" (untranslated from {_DEFAULT_LOCALE})"))
_check_node("", _LOCALES_CACHE[_DEFAULT_LOCALE])
def _check_unknown_translations(path: Path, errors: list, warnings: list) -> tuple:
"""Ensures all strings from all translations are in default locale"""
default_locale = _LOCALES_CACHE[_DEFAULT_LOCALE]
def _check_node(locale: str, breadcrumb: str, value: dict):
# Dict
if isinstance(value, dict):
for nested_key in value:
if breadcrumb:
base = f"{breadcrumb}/{nested_key}"
else:
base = nested_key
_check_node(locale, base, value[nested_key])
# Str
elif isinstance(value, str):
pointer = f"/{breadcrumb}"
try:
resolve_pointer(default_locale, pointer)
except JsonPointerException:
warnings.append((5, f"String \"{breadcrumb}\" is translated in locale {locale} but is missing from default locale {_DEFAULT_LOCALE} (translation of unknown string)"))
for locale in _LOCALES_CACHE:
if locale == _DEFAULT_LOCALE:
continue
_check_node(locale, "", _LOCALES_CACHE[locale])
if __name__ == "__main__":
# Arguments parsing
parser = argparse.ArgumentParser(description="Check integrity of i18n strings")
parser.add_argument(
dest="path",
action="store",
help="The path to the i18n folder to check",
)
args = parser.parse_args()
path = Path(args.path)
print(f"Checking i18n folder {path}...\n")
# Validation
checks = [
_folder_exists,
_ensure_default_locale,
_check_locales,
_check_types,
_check_untranslated_strings,
_check_unknown_translations,
]
errors = []
warnings = []
for check in checks:
check(path, errors, warnings)
if errors:
break # errors are fatal
if warnings:
print(f"{len(warnings)} warning(s):")
for code, warning in warnings:
print(f" - W{code:02}: {warning}")
print("\nWarnings are not fatal but should be fixed to avoid missing / broken translations in the app.")
if errors:
print(f"{len(errors)} error(s):")
for code, error in errors:
print(f" - E{code:02}: {error}")
print("\nPlease fix them and run the script again.")
if not errors and not warnings:
print("No errors or warnings detected, your i18n folder is good to go!")