Initial Commit
This commit is contained in:
249
lib/borealis/scripts/i18n-linter.py
Normal file
249
lib/borealis/scripts/i18n-linter.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
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!")
|
||||
Reference in New Issue
Block a user