#!/usr/bin/env python3 """ Idiomas do openTARS. Todo texto que aparece pro usuário mora em idiomas/.json (um arquivo por idioma, chave -> texto). O código só pede a chave: from tars_i18n import t t("gui.enviar") # "Enviar" / "Send" / "Enviar"... t("msg.carregado", modelo="qwen3") # com valores no lugar de {modelo} Adicionar um idioma = copiar o en.json, traduzir os valores e salvar com o código do idioma (ex: it.json). O menu passa a mostrar o idioma novo sozinho; nada no código muda. Chave que faltar num idioma cai no inglês. A escolha do usuário fica em ~/.config/opentars/config.json. Sem escolha, vale o idioma do sistema (LANG), se houver arquivo pra ele. Também serve o instalador (setup.sh, em bash): python3 tars_i18n.py --shell setup. -> T_setup_xxx='...' (pra eval) """ import json import os import shlex import sys import threading from pathlib import Path import tars_plataforma as plataforma PASTA_IDIOMAS = Path( os.environ.get("TARS_DIR_IDIOMAS") or Path(__file__).resolve().parent / "idiomas" ) # Idioma de reserva: chave que faltar no idioma escolhido vem daqui. IDIOMA_RESERVA = "en" def _pasta_config(): return plataforma.pastas()["config"] ARQUIVO_CONFIG = Path(os.environ.get("TARS_ARQUIVO_CONFIG") or _pasta_config() / "config.json") _trava = threading.Lock() _catalogos = {} # código -> dict já lido do JSON _disponiveis = None # código -> nome nativo (cache) _config = None # conteúdo do config.json (cache) _ouvintes = [] # funções chamadas quando o idioma muda _atual = None # código do idioma em uso _cat_atual = {} # dicionário do idioma em uso (troca atômica) _cat_reserva = {} # ------------------------------------------------------------ # Catálogos # ------------------------------------------------------------ def _carregar(codigo): """Lê idiomas/.json uma vez só. Arquivo quebrado vira {} (as chaves caem no idioma de reserva) em vez de derrubar o programa.""" cat = _catalogos.get(codigo) if cat is None: try: with open(PASTA_IDIOMAS / f"{codigo}.json", encoding="utf-8") as f: cat = json.load(f) if not isinstance(cat, dict): raise ValueError("o arquivo precisa ser um objeto JSON") except FileNotFoundError: cat = {} except Exception as e: # JSON inválido: avisa uma vez e segue print(f"[openTARS] idiomas/{codigo}.json inválido ({e})", file=sys.stderr) cat = {} _catalogos[codigo] = cat return cat def idiomas_disponiveis(): """{código: nome no próprio idioma}, na ordem alfabética dos nomes.""" global _disponiveis if _disponiveis is None: achados = {} try: arquivos = sorted(PASTA_IDIOMAS.glob("*.json")) except OSError: arquivos = [] for arquivo in arquivos: cat = _carregar(arquivo.stem) if cat: achados[arquivo.stem] = str(cat.get("_nome") or arquivo.stem) _disponiveis = dict(sorted(achados.items(), key=lambda kv: kv[1].casefold())) return _disponiveis def nome_idioma(codigo): return idiomas_disponiveis().get(codigo, codigo) def _normalizar_codigo(valor): """'pt-BR.UTF-8@euro' -> 'pt_BR'; 'en' -> 'en'; 'C' -> ''.""" valor = (valor or "").split(".")[0].split("@")[0].replace("-", "_").strip() if valor in ("", "C", "POSIX"): return "" partes = valor.split("_", 1) return partes[0].lower() + ("_" + partes[1].upper() if len(partes) > 1 else "") def melhor_idioma(pedido): """O arquivo que melhor atende um código: exato, depois o idioma sem a região (pt_PT -> pt_BR), depois qualquer região do mesmo idioma.""" codigo = _normalizar_codigo(pedido) if not codigo: return None disponiveis = idiomas_disponiveis() if codigo in disponiveis: return codigo base = codigo.split("_")[0] if base in disponiveis: return base return next((c for c in disponiveis if c.split("_")[0] == base), None) def locales_do_sistema(): """Os códigos do sistema como estão (pt_PT, de_AT...), sem mapear pros arquivos de idioma: servem pra achar Name[pt_PT]= nos atalhos de apps.""" codigos = [] for var in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"): for parte in (os.environ.get(var) or "").split(":"): codigo = _normalizar_codigo(parte) if codigo and codigo not in codigos: codigos.append(codigo) return codigos def idiomas_do_sistema(): """Códigos pedidos pelo sistema, em ordem de preferência (LANGUAGE pode ter vários, separados por ':').""" pedidos = [] for var in ("LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"): for parte in (os.environ.get(var) or "").split(":"): codigo = melhor_idioma(parte) if codigo and codigo not in pedidos: pedidos.append(codigo) return pedidos # ------------------------------------------------------------ # Configuração do usuário # ------------------------------------------------------------ def ler_config(): global _config if _config is None: try: dados = json.loads(ARQUIVO_CONFIG.read_text(encoding="utf-8")) _config = dados if isinstance(dados, dict) else {} except Exception: _config = {} return _config def salvar_config(**mudancas): """Grava de forma atômica (temporário + rename). Devolve True se deu.""" with _trava: dados = dict(ler_config()) dados.update(mudancas) try: ARQUIVO_CONFIG.parent.mkdir(parents=True, exist_ok=True) temporario = ARQUIVO_CONFIG.with_suffix(".tmp") temporario.write_text(json.dumps(dados, ensure_ascii=False, indent=1), encoding="utf-8") os.replace(temporario, ARQUIVO_CONFIG) except Exception: return False global _config _config = dados return True # ------------------------------------------------------------ # Idioma em uso # ------------------------------------------------------------ def _escolher_inicial(): for pedido in (os.environ.get("TARS_IDIOMA"), ler_config().get("idioma")): codigo = melhor_idioma(pedido) if codigo: return codigo sistema = idiomas_do_sistema() if sistema: return sistema[0] return IDIOMA_RESERVA if IDIOMA_RESERVA in idiomas_disponiveis() else next(iter(idiomas_disponiveis()), IDIOMA_RESERVA) def _aplicar(codigo): global _atual, _cat_atual, _cat_reserva _cat_reserva = _carregar(IDIOMA_RESERVA) _cat_atual = _carregar(codigo) _atual = codigo def idioma_atual(): if _atual is None: _aplicar(_escolher_inicial()) return _atual def definir_idioma(codigo, salvar=True): """Troca o idioma (e grava a escolha). Devolve o código aplicado, ou None se não houver arquivo pra esse idioma.""" escolhido = melhor_idioma(codigo) if not escolhido: return None anterior = idioma_atual() _aplicar(escolhido) if salvar: salvar_config(idioma=escolhido) if escolhido != anterior: for funcao in list(_ouvintes): try: funcao(escolhido) except Exception: pass return escolhido def ao_mudar_idioma(funcao): """Registra uma função(codigo) chamada a cada troca de idioma.""" _ouvintes.append(funcao) return funcao def idiomas_de_entrada(): """Idiomas em que o usuário provavelmente ESCREVE: o escolhido, os do sistema e o inglês. Usado nas palavras-chave (detectar tarefa, achar app pelo nome traduzido) — não todos os idiomas, pra "fecha" (fechar, em português) não disparar num pedido em espanhol ("fecha" = data).""" vistos = [] for codigo in [idioma_atual(), *idiomas_do_sistema(), IDIOMA_RESERVA]: if codigo and codigo not in vistos and codigo in idiomas_disponiveis(): vistos.append(codigo) return vistos # ------------------------------------------------------------ # Textos # ------------------------------------------------------------ def t(chave, **valores): """Texto da chave no idioma em uso (ou no de reserva). Com valores, preenche os {campos}; um campo que falte não quebra: volta o texto cru.""" if _atual is None: idioma_atual() texto = _cat_atual.get(chave) if not isinstance(texto, str): texto = _cat_reserva.get(chave) if not isinstance(texto, str): return chave if valores: try: return texto.format(**valores) except (KeyError, IndexError, ValueError): return texto return texto def lista(chave): """Lista da chave (exemplos, palavras-chave...) no idioma em uso.""" if _atual is None: idioma_atual() valor = _cat_atual.get(chave) if not isinstance(valor, list): valor = _cat_reserva.get(chave) return list(valor) if isinstance(valor, list) else [] def juntar(chave, idiomas=None): """União (sem repetir, na ordem) das listas de uma chave em vários idiomas — por padrão, os idiomas de entrada.""" saida, vistos = [], set() for codigo in idiomas or idiomas_de_entrada(): valor = _carregar(codigo).get(chave) for item in valor if isinstance(valor, list) else (): marca = json.dumps(item, ensure_ascii=False) if not isinstance(item, str) else item if marca not in vistos: vistos.add(marca) saida.append(item) return saida def em_todos(chave): """União da chave em TODOS os idiomas (comandos como /limpar, /clear, /limpiar: explícitos, não há risco de engano).""" return juntar(chave, list(idiomas_disponiveis())) def chaves(prefixo=""): """Chaves conhecidas (do idioma de reserva e do atual) com o prefixo.""" idioma_atual() return sorted(k for k in {*_cat_reserva, *_cat_atual} if k.startswith(prefixo)) def _para_shell(prefixo): linhas = [] for chave in chaves(prefixo): texto = t(chave) if isinstance(texto, str): nome = "T_" + "".join(ch if ch.isalnum() else "_" for ch in chave) linhas.append(f"{nome}={shlex.quote(texto)}") return "\n".join(linhas) if __name__ == "__main__": argumentos = sys.argv[1:] if len(argumentos) == 2 and argumentos[0] == "--shell": print(_para_shell(argumentos[1])) elif argumentos == ["--lista"]: for codigo, nome in idiomas_disponiveis().items(): print(f"{codigo}\t{nome}") else: print("uso: tars_i18n.py --shell | --lista", file=sys.stderr) sys.exit(2)