goober/modules/volta/main.py

259 lines
9.2 KiB
Python
Raw Permalink Normal View History

# 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
2025-07-07 00:33:36 +02:00
# Also, Note to self: Add more comments it needs more love
import os
2025-07-16 11:02:25 +02:00
import locale
import json
import pathlib
import threading
2025-07-18 16:09:14 +02:00
import platform
import sys
import time
from dotenv import load_dotenv
2025-07-18 14:08:36 +02:00
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"
2025-07-09 22:57:59 +02:00
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
2025-07-07 12:38:32 +02:00
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
2025-07-07 16:54:19 +02:00
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
2025-07-23 15:47:29 +02:00
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"]
2025-07-07 16:54:19 +02:00
else:
2025-07-23 15:47:29 +02:00
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
2025-07-07 16:54:19 +02:00
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 = {}
2025-07-16 11:02:25 +02:00
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:
2025-07-07 00:16:36 +02:00
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:
2025-07-07 00:16:36 +02:00
print(f"[VOLTA] {RED}Translation file changed: {file_path}, reloading...{RESET}")
2025-07-18 14:20:24 +02:00
_lookup_translation.cache_clear()
load_translations()
break
except FileNotFoundError:
2025-07-07 00:16:36 +02:00
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
2025-07-16 11:02:25 +02:00
if not LOCALE:
LOCALE = get_system_locale()
elif lang in translations:
LOCALE = lang
else:
2025-07-07 00:16:36 +02:00
print(f"[VOLTA] {RED}Language '{lang}' not found, defaulting to 'en'{RESET}")
2025-07-07 12:38:32 +02:00
if FALLBACK_LOCALE in translations:
LOCALE = FALLBACK_LOCALE
else:
2025-07-07 12:38:32 +02:00
print(f"[VOLTA] {RED}The fallback translations cannot be found! No fallback available.{RESET}")
ENGLISH_MISSING = True
2025-07-18 14:08:36 +02:00
_lookup_translation.cache_clear()
2025-07-18 14:20:24 +02:00
def check_missing_translations(LOCALE=LOCALE):
global ENGLISH_MISSING
load_translations()
2025-07-07 12:38:32 +02:00
if FALLBACK_LOCALE not in translations:
2025-07-07 16:54:19 +02:00
print(f"[VOLTA] {RED}Fallback translations ({FALLBACK_LOCALE}.json) missing from assets/locales.{RESET}")
ENGLISH_MISSING = True
return
if LOCALE == "en":
2025-07-07 20:39:39 +02:00
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:
2025-07-07 16:54:19 +02:00
print(f"[VOLTA] {YELLOW}Warning: All keys are missing in locale '{LOCALE}'! Defaulting back to {FALLBACK_LOCALE}{RESET}")
2025-07-07 12:38:32 +02:00
set_language(FALLBACK_LOCALE)
elif percent_missing > 0:
2025-07-07 20:39:39 +02:00
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:
2025-07-07 20:39:39 +02:00
print(f"[VOLTA] All translation keys present for locale: {LOCALE}")
2025-07-16 11:02:25 +02:00
printedsystemfallback = False
2025-07-18 14:08:36 +02:00
@lru_cache(maxsize=600)
def _lookup_translation(lang: str, key: str):
return translations.get(lang, {}).get(key)
def get_translation(lang: str, key: str):
2025-07-16 11:02:25 +02:00
global printedsystemfallback
if ENGLISH_MISSING:
return f"[VOLTA] {RED}No fallback available!{RESET}"
2025-07-18 14:08:36 +02:00
val = _lookup_translation(lang, key)
if val:
return val
2025-07-16 11:02:25 +02:00
sys_lang = get_system_locale().split("_")[0] if get_system_locale() else None
2025-07-18 14:08:36 +02:00
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:
2025-07-16 11:02:25 +02:00
print(f"[VOLTA] {YELLOW}Missing key: '{key}' in '{lang}', falling back to fallback locale '{FALLBACK_LOCALE}'{RESET}")
2025-07-18 14:08:36 +02:00
return fallback_val
2025-07-18 14:08:36 +02:00
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()
2025-07-07 12:38:32 +02:00
if __name__ == '__main__':
2025-07-18 14:20:24 +02:00
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}")