""" This module manages language support for Amulet. The language files are in amulet_map_editor/lang with RFC 1766 file names and the .lang extension. Lines starting with a hash (#) are ignored as comments and blank lines are ignored. All other lines should have the format `key=value` where `key` is the language key used in the application and `value` is the localised string. Keys should match the regex [a-z.]+ (lower case a-z and the full stop character) and values can contain any unicode character. Files must be utf-8 encoded. """ import glob from typing import Dict, Optional, List, Tuple import os import locale import logging import re import amulet_map_editor from amulet_map_editor.api import config as CONFIG log = logging.getLogger(__name__) # there might be a proper way to do this but this should be enough for now _lang_dirs: List[str] = [] # the language directories _lang: Dict[str, str] = ( {} ) # a storage for the language strings. unique_identifier: language_string _default_language = "en" _active_language: Optional[str] = None def lang_dirs() -> Tuple[str, ...]: """Tuple of known language directories.""" return tuple(_lang_dirs) def parse_language_id(language_id: str) -> Optional[Tuple[str, str]]: """ Parse an RFC 1766 language id string into a nicer format. :param language_id: The RFC 1766 code to parse :return: A tuple of the language and region codes (both lower case). The region code may be None. """ match = re.fullmatch( r"(?P[a-z]+)([-_](?P[a-z]+))?.*", language_id, flags=re.IGNORECASE, ) if match is None: return None region = match.group("region") region = "" if region is None else region.lower() return match.group("language").lower(), region def set_language(language_id: str): """ Sets and loads the specified language. A restart may be required. :param language_id: The RFC 1766 language code to use """ global _active_language _active_language = language_id cfg = CONFIG.get("amulet_meta", {}) cfg["lang"] = language_id CONFIG.put("amulet_meta", cfg) _load_language() def get_language() -> str: """Get the currently active language string.""" global _active_language if _active_language is None: # find out what language the user is using. try: # try getting the OS language _active_language = locale.getdefaultlocale()[0] except: # if that fails use the default language _active_language = _default_language # if a language is set in the config use that _active_language = CONFIG.get("amulet_meta", {}).get("lang", _active_language) if _active_language is None: _active_language = _default_language return _active_language def get_languages() -> List[str]: """Get a list of all supported language codes.""" langs = set() for d in _lang_dirs: for l in glob.glob(os.path.join(glob.escape(d), "*.lang")): langs.add(os.path.basename(l)[:-5]) return sorted(langs) def _load_lang_file(lang_path: str) -> Dict[str, str]: """Loads a language file and returns the result as a dictionary. Will return an empty dictionary if the path does not exist. :param lang_path: The lang file path to load. :return: A dictionary mapping the string identifier to the string """ lang = {} if not os.path.isfile(lang_path): return lang with open(lang_path, encoding="utf-8") as f: for line in f.readlines(): lstrip_line = line.lstrip() if lstrip_line.startswith("#") or not lstrip_line: continue split_line = lstrip_line.split("=", 1) if len(split_line) == 2: unique_identifier = split_line[0].rstrip() language_string = split_line[1].replace("\\n", "\n").strip() lang[unique_identifier] = language_string return lang def _find_langs(path: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: """Find the default, language specific and region specific lang paths.""" langs = { parse_language_id(os.path.basename(lpath)[:-5]): lpath for lpath in glob.glob(os.path.join(glob.escape(path), "*.lang")) } default_key = parse_language_id(_default_language) active_key = parse_language_id(get_language()) default_path = langs.get(default_key, None) language_path = None region_path = None if active_key is not None: if active_key[0] != default_key[0]: language_path = langs.get((active_key[0], ""), None) if active_key[1]: region_path = langs.get(active_key, None) return default_path, language_path, region_path def _load_language(): """Load the translations for the active language.""" # work out which lang files to load based on the active language # lang files from the default langauge lang_files = sum(zip(*list(map(_find_langs, lang_dirs()))), ()) _lang.clear() for lang_file in lang_files: if lang_file is None: continue _lang.update(_load_lang_file(lang_file)) def register_lang_directory(lang_dir: str): """Register a new language directory. Use this, if you are a third party program, to register new translations for your program. :param lang_dir: The directory containing language files to register. Files must be of the format .lang eg en_US.lang :return: """ if os.path.isdir(lang_dir): if lang_dir in _lang_dirs: log.warning( f"The language directory {lang_dir} has already been registered." ) else: _lang_dirs.append(lang_dir) _load_language() # load the normal language directory register_lang_directory( os.path.join(os.path.dirname(amulet_map_editor.__file__), "lang") ) def get(unique_identifier: str): """ Get the localised string for the translation key :param unique_identifier: The translation key to get. :return: The translated string. """ if unique_identifier not in _lang: # help debugging referenced lang entries that do not exist log.info(f"Could not find lang entry for {unique_identifier}") return _lang.get(unique_identifier, unique_identifier)