# If you're seeing this after cloning the Goober repo, note that this is a standalone module for translations. # While it's used by Goober Core, it lives in its own repository and should not be modified here. # For updates or contributions, visit: https://github.com/gooberinc/volta # Also, Note to self: Add more comments it needs more love import os import locale import json import pathlib import threading import platform import sys import time from dotenv import load_dotenv from functools import lru_cache ANSI = "\033[" RED = f"{ANSI}31m" GREEN = f"{ANSI}32m" YELLOW = f"{ANSI}33m" DEBUG = f"{ANSI}1;30m" RESET = f"{ANSI}0m" LOCALE = os.getenv("LOCALE") module_dir = pathlib.Path(__file__).parent.parent working_dir = pathlib.Path.cwd() EXCLUDE_DIRS = {'.git', '__pycache__'} locales_dirs = [] ENGLISH_MISSING = False FALLBACK_LOCALE = "en" if os.getenv("fallback_locale"): FALLBACK_LOCALE = os.getenv("fallback_locale") def find_locales_dirs(base_path): found = [] for root, dirs, files in os.walk(base_path): dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] if 'locales' in dirs: locales_path = pathlib.Path(root) / 'locales' found.append(locales_path) dirs.remove('locales') return found def find_dotenv(start_path: pathlib.Path) -> pathlib.Path | None: current = start_path.resolve() while current != current.parent: candidate = current / ".env" if candidate.exists(): return candidate current = current.parent return None def load_settings_json() -> dict | None: start_path = working_dir.resolve() current = start_path.resolve() while current != current.parent: candidate = current / "settings.json" if candidate.exists(): try: with open(candidate, "r", encoding="utf-8") as f: data = json.load(f) print(f"[VOLTA] {GREEN}Loaded settings.json locale '{data.get('locale')}' from {candidate}{RESET}") return data except Exception as e: print(f"[VOLTA] {RED}Failed to load settings.json at {candidate}: {e}{RESET}") return None current = current.parent for root, dirs, files in os.walk(start_path): if "settings.json" in files: candidate = pathlib.Path(root) / "settings.json" try: with open(candidate, "r", encoding="utf-8") as f: data = json.load(f) print(f"[VOLTA] {GREEN}Loaded settings.json locale '{data.get('locale')}' from {candidate}{RESET}") return data except Exception as e: print(f"[VOLTA] {RED}Failed to load settings.json at {candidate}: {e}{RESET}") return None print(f"[VOLTA] {YELLOW}No settings.json found scanning up or down from {start_path}{RESET}") return None settings = load_settings_json() if settings and "locale" in settings: LOCALE = settings["locale"] else: env_path = find_dotenv(pathlib.Path(__file__).parent) if env_path: load_dotenv(dotenv_path=env_path) print(f"[VOLTA] {GREEN}Loaded .env from {env_path}{RESET}") else: print(f"[VOLTA] {YELLOW}No .env file found from {__file__} upwards.{RESET}") LOCALE = os.getenv("LOCALE") or None locales_dirs.extend(find_locales_dirs(module_dir)) if working_dir != module_dir: locales_dirs.extend(find_locales_dirs(working_dir)) translations = {} _file_mod_times = {} def get_system_locale(): system = platform.system() # fallback incase locale isnt set if system == "Windows": lang, _ = locale.getdefaultlocale() return lang or os.getenv("LANG") elif system == "Darwin": try: import subprocess result = subprocess.run( ["defaults", "read", "-g", "AppleLocale"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True ) return result.stdout.strip() or locale.getdefaultlocale()[0] except Exception: return locale.getdefaultlocale()[0] elif system == "Linux": return ( os.getenv("LC_ALL") or os.getenv("LANG") or locale.getdefaultlocale()[0] ) return locale.getdefaultlocale()[0] def load_translations(): global translations, _file_mod_times translations.clear() _file_mod_times.clear() for locales_dir in locales_dirs: for filename in os.listdir(locales_dir): if filename.endswith(".json"): lang_code = filename[:-5] file_path = locales_dir / filename try: with open(file_path, "r", encoding="utf-8") as f: data = json.load(f) if lang_code not in translations: translations[lang_code] = {} translations[lang_code].update(data) _file_mod_times[(lang_code, file_path)] = file_path.stat().st_mtime except Exception as e: print(f"[VOLTA] {RED}Failed loading {file_path}: {e}{RESET}") def reload_if_changed(): while True: for (lang_code, file_path), last_mtime in list(_file_mod_times.items()): try: current_mtime = file_path.stat().st_mtime if current_mtime != last_mtime: print(f"[VOLTA] {RED}Translation file changed: {file_path}, reloading...{RESET}") _lookup_translation.cache_clear() load_translations() break except FileNotFoundError: print(f"[VOLTA] {RED}Translation file removed: {file_path}{RESET}") _file_mod_times.pop((lang_code, file_path), None) if lang_code in translations: translations.pop(lang_code, None) def set_language(lang: str): global LOCALE, ENGLISH_MISSING if not LOCALE: LOCALE = get_system_locale() elif lang in translations: LOCALE = lang else: print(f"[VOLTA] {RED}Language '{lang}' not found, defaulting to 'en'{RESET}") if FALLBACK_LOCALE in translations: LOCALE = FALLBACK_LOCALE else: print(f"[VOLTA] {RED}The fallback translations cannot be found! No fallback available.{RESET}") ENGLISH_MISSING = True _lookup_translation.cache_clear() def check_missing_translations(LOCALE=LOCALE): global ENGLISH_MISSING load_translations() if FALLBACK_LOCALE not in translations: print(f"[VOLTA] {RED}Fallback translations ({FALLBACK_LOCALE}.json) missing from assets/locales.{RESET}") ENGLISH_MISSING = True return if LOCALE == "en": print("[VOLTA] Locale is English, skipping missing key check.") return en_keys = set(translations.get("en", {}).keys()) locale_keys = set(translations.get(LOCALE, {}).keys()) missing_keys = en_keys - locale_keys total_keys = len(en_keys) missing_count = len(missing_keys) if missing_count > 0: percent_missing = (missing_count / total_keys) * 100 if percent_missing == 100: print(f"[VOLTA] {YELLOW}Warning: All keys are missing in locale '{LOCALE}'! Defaulting back to {FALLBACK_LOCALE}{RESET}") set_language(FALLBACK_LOCALE) elif percent_missing > 0: print(f"[VOLTA] {YELLOW}Warning: {missing_count}/{total_keys} keys missing in locale '{LOCALE}' ({percent_missing:.1f}%)!{RESET}") for key in sorted(missing_keys): print(f" - {key}") time.sleep(2) else: print(f"[VOLTA] All translation keys present for locale: {LOCALE}") printedsystemfallback = False @lru_cache(maxsize=600) def _lookup_translation(lang: str, key: str): return translations.get(lang, {}).get(key) def get_translation(lang: str, key: str): global printedsystemfallback if ENGLISH_MISSING: return f"[VOLTA] {RED}No fallback available!{RESET}" val = _lookup_translation(lang, key) if val: return val sys_lang = get_system_locale().split("_")[0] if get_system_locale() else None if sys_lang and sys_lang != lang: sys_val = _lookup_translation(sys_lang, key) if sys_val: if not printedsystemfallback: print(f"[VOLTA] {YELLOW}Falling back to system language {sys_lang}!{RESET}") printedsystemfallback = True return sys_val fallback_val = _lookup_translation(FALLBACK_LOCALE, key) if fallback_val: print(f"[VOLTA] {YELLOW}Missing key: '{key}' in '{lang}', falling back to fallback locale '{FALLBACK_LOCALE}'{RESET}") return fallback_val return f"[VOLTA] {YELLOW}Missing key: '{key}' in all locales!{RESET}" def _(key: str) -> str: return get_translation(LOCALE, key) load_translations() watchdog_thread = threading.Thread(target=reload_if_changed, daemon=True) watchdog_thread.start() if __name__ == '__main__': import argparse parser = argparse.ArgumentParser() parser.add_argument("LOCALE", help="Locale to validate") args = parser.parse_args() print("[VOLTA] Validating all locales....") check_missing_translations(LOCALE=f"{args.LOCALE}")