250 lines
8.2 KiB
Python
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!")
|