From 737a7a8adebafdd2369eb7416aa5744b07e0b5ba Mon Sep 17 00:00:00 2001 From: Cristian Date: Thu, 5 Nov 2026 23:42:31 -0500 Subject: [PATCH 1/6] feat(scripts): traducir mensajes de chat de KubeJS, logros y funciones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runCommand/runCommandSilent: traduce el texto de tellraw, title, say, msg, tell y w. - Mensajes armados por partes (variable que acaba en tell), sus miembros name y sus tablas de nombres, incluso si la tabla está en otro archivo. - Logros y funciones .mcfunction de kubejs/data, con copia de seguridad y en el kit para servidores. --- CHANGELOG.md | 11 ++ README.md | 2 +- src/traductor_es/formatos/comandos.py | 47 +++++++ src/traductor_es/formatos/flujo_js.py | 86 +++++++++++++ src/traductor_es/modulos/datos.py | 145 +++++++++++++++++++++ src/traductor_es/modulos/scripts.py | 126 ++++++++++++++++-- src/traductor_es/proceso.py | 4 +- src/traductor_es/salida/exportar.py | 5 +- tests/test_comandos_y_datos.py | 176 ++++++++++++++++++++++++++ 9 files changed, 583 insertions(+), 19 deletions(-) create mode 100644 src/traductor_es/formatos/comandos.py create mode 100644 src/traductor_es/formatos/flujo_js.py create mode 100644 src/traductor_es/modulos/datos.py create mode 100644 tests/test_comandos_y_datos.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 710ce27..4d56ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ Todos los cambios importantes de ModpackES quedan en este archivo. El formato si ## [Sin publicar] +### Agregado + +- Mensajes de chat de los scripts de KubeJS: el texto de `tellraw`, `title` y `say` (y `msg`, `tell`, `w`) + cuando se manda con `runCommand` o `runCommandSilent`. Antes solo se traducía lo que pasaba por `tell`, + `Text.of` y similares. +- Mensajes que un script arma por partes (`let mensaje = 'Tienes '; mensaje += efecto.name; player.tell(mensaje)`): + los trozos de texto, los `name` que se suman y las tablas de nombres que se suman (`nombres[clave]`). Solo + cuando la variable acaba en una llamada de texto como `tell`. +- Logros (título y descripción) y funciones `.mcfunction` de `kubejs/data`, que también muestran mensajes en + el chat. Van en el grupo Scripts, con copia de seguridad, y entran en el kit para servidores. + ## [2.2.0] - 2026-11-04 Interfaz nueva: una ventana guiada en pasos, con tarjetas y un solo botón principal, y un tema propio diff --git a/README.md b/README.md index f03d634..cfe0e6e 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ y **cualquier lanzador**, y escribe a la vez las siete variantes de español del | Textos de mods (objetos, bloques, menús, tooltips) | `lang/*.json` (1.13+) y `.lang` (1.12 y antes), incluidos los jars anidados, KubeJS y los resource packs del modpack | Resource pack `Traduccion_ES` | | Misiones | FTB Quests (texto en los capítulos, carpeta `lang/` de 1.21+ y NBT de 1.12), BetterQuesting, HQM, Heracles | En su sitio, con copia de seguridad | | Libros guía | Patchouli, Mantle (Tinkers), Citadel (Alex's Mobs), GuideME (AE2), Lavender. Modonomicon va por claves de idioma | Resource pack (o en su sitio, si es un libro propio del modpack) | -| Scripts de KubeJS | Lo que ve el jugador: `tell`, `displayName`, `Text.of`, tooltips, información de JEI/REI/EMI | En su sitio, con copia de seguridad | +| Scripts de KubeJS | Lo que ve el jugador: `tell`, `displayName`, `Text.of`, tooltips, información de JEI/REI/EMI, los mensajes armados por partes antes de un `tell`, y el texto de `tellraw`, `title` y `say` en `runCommand`. También los logros y funciones de `kubejs/data` | En su sitio, con copia de seguridad | Lo que el mod ya trae en español no se toca, y si existe una variante (por ejemplo `es_es`) se copia a las demás sin gastar. Los libros antiguos de Patchouli que guardan el texto dentro del jar no se pueden traducir diff --git a/src/traductor_es/formatos/comandos.py b/src/traductor_es/formatos/comandos.py new file mode 100644 index 0000000..0e41a01 --- /dev/null +++ b/src/traductor_es/formatos/comandos.py @@ -0,0 +1,47 @@ +"""Comandos de chat de Minecraft (`tellraw`, `title`, `say`, `msg`…): separa el texto que ve el jugador. + +Sirve para una línea de un `.mcfunction` y para la cadena que un script de KubeJS le pasa a `runCommand`. +Solo reconoce la forma clásica, con el texto como JSON (`tellraw @a {"text":"Hola"}`) o como texto suelto +(`say Hola`). Lo que tenga otra forma (SNBT, macros `$`) no se toca. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +_INICIO = re.compile(r"(?:^\s*/?|\brun\s+)(?Ptellraw|title|say|msg|tell|w)[ \t]+") +#: `@a`, `@a[distance=..5, tag=x]` (con espacios dentro de los corchetes) o un nombre. +_SELECTOR = re.compile(r"(?:@[a-z]+(?:\[[^\]]*\])?|\S+)[ \t]+") +_TITULO = re.compile(r"(?:title|subtitle|actionbar)[ \t]+") +_CON_SELECTOR = frozenset({"tellraw", "title", "msg", "tell", "w"}) + + +@dataclass(frozen=True, slots=True) +class Comando: + prefijo: str #: todo lo que va antes del texto (`execute as @a run tellraw @s `) + cuerpo: str #: lo que ve el jugador: un componente JSON o un texto suelto + json: bool #: el cuerpo es un componente JSON (tellraw, title) y no texto suelto (say, msg) + + +def analizar(texto: str) -> Comando | None: + """El comando de chat que contiene el texto, o None si no es de los que muestran algo al jugador.""" + inicio = _INICIO.search(texto) + if inicio is None: + return None + orden = inicio.group("orden") + pos = inicio.end() + if orden in _CON_SELECTOR: + selector = _SELECTOR.match(texto, pos) + if selector is None: + return None + pos = selector.end() + if orden == "title": + titulo = _TITULO.match(texto, pos) + if titulo is None: # title … times / clear / reset + return None + pos = titulo.end() + cuerpo = texto[pos:] + if not cuerpo.strip(): + return None + return Comando(texto[:pos], cuerpo, json=orden in ("tellraw", "title")) diff --git a/src/traductor_es/formatos/flujo_js.py b/src/traductor_es/formatos/flujo_js.py new file mode 100644 index 0000000..c4e2578 --- /dev/null +++ b/src/traductor_es/formatos/flujo_js.py @@ -0,0 +1,86 @@ +"""Mensajes que un script arma por partes y manda después. + + let mensaje = 'Te has ganado ' + mensaje += efecto.name // «name» es un texto que ve el jugador + mensaje += global.nombres[clave] // «nombres» es una tabla de textos que ve el jugador + player.tell(mensaje) + +Aquí solo se lee el código para saber qué variables, miembros y tablas acaban en un mensaje. Decidir qué +cadenas se traducen es cosa del módulo de scripts. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable, Sequence +from dataclasses import dataclass + +from .javascript import CadenaJs + +_IDENT = r"[A-Za-z_$][\w$]*" +_RUTA = rf"{_IDENT}(?:\.{_IDENT})*" +_AGREGADO = re.compile(rf"\b({_IDENT})\s*\+=\s*({_RUTA})(?:(\[[^\]\n]*\])(?:\.({_IDENT}))?)?") +_ASIGNADA = re.compile(rf"({_IDENT})\s*\+?=\s*$") +_CLAVE = re.compile(rf"(?:({_IDENT})|['\"]([^'\"\n]+)['\"])\s*:\s*$") +_VENTANA = 160 #: cuánto código hacia atrás se mira para saber a qué se asigna una cadena + + +@dataclass(frozen=True, slots=True) +class Flujo: + variables: frozenset[str] = frozenset() #: variables que se pasan a una llamada de texto: `tell(mensaje)` + miembros: frozenset[str] = frozenset() #: `mensaje += efecto.name` → «name» + tablas: frozenset[str] = frozenset() #: `mensaje += global.nombres[clave]` → «nombres» + + +def analizar(src: str, llamadas: str) -> Flujo: + """`llamadas`: expresión regular con las llamadas cuyo argumento es texto para el jugador.""" + pasadas = re.compile(rf"(?:{llamadas})\s*\(\s*({_IDENT})\s*[,)]") + variables = frozenset(pasadas.findall(src)) + if not variables: + return Flujo() + miembros: set[str] = set() + tablas: set[str] = set() + for variable, ruta, indice, miembro in _AGREGADO.findall(src): + if variable not in variables: + continue + ultimo = ruta.rsplit(".", 1)[-1] + if miembro: # `efectos[i].name`: el texto es el miembro + miembros.add(miembro) + elif indice: # `nombres[clave]`: el texto está en la tabla + tablas.add(ultimo) + elif "." in ruta: + miembros.add(ultimo) + return Flujo(variables, frozenset(miembros), frozenset(tablas)) + + +def asignada_a(src: str, cadena: CadenaJs) -> str | None: + """La variable a la que se asigna o se suma esta cadena (`msg = '…'`, `msg += '…'`).""" + m = _ASIGNADA.search(src[max(0, cadena.inicio - _VENTANA) : cadena.inicio]) + return m.group(1) if m else None + + +def clave_de(src: str, cadena: CadenaJs) -> str | None: + """La clave de objeto a la que pertenece esta cadena como valor (`name: '…'`).""" + m = _CLAVE.search(src[max(0, cadena.inicio - _VENTANA) : cadena.inicio]) + return (m.group(1) or m.group(2)) if m else None + + +def cuerpos_de_tabla(src: str, cadenas: Sequence[CadenaJs], tablas: Iterable[str]) -> list[tuple[int, int]]: + """Posiciones (inicio, fin) del contenido de las tablas `x.nombres = { … }` con esos nombres.""" + nombres = frozenset(tablas) + if not nombres: + return [] + en_cadena = {c.inicio: c.fin for c in cadenas} + cuerpos: list[tuple[int, int]] = [] + for m in re.finditer(rf"({_RUTA})\s*=\s*\{{", src): + if m.group(1).rsplit(".", 1)[-1] not in nombres: + continue + i, profundidad = m.end(), 1 + while i < len(src) and profundidad: + if i in en_cadena: + i = en_cadena[i] + continue + profundidad += {"{": 1, "}": -1}.get(src[i], 0) + i += 1 + cuerpos.append((m.end(), i)) + return cuerpos diff --git a/src/traductor_es/modulos/datos.py b/src/traductor_es/modulos/datos.py new file mode 100644 index 0000000..755eb16 --- /dev/null +++ b/src/traductor_es/modulos/datos.py @@ -0,0 +1,145 @@ +"""Textos de los datos de KubeJS (`kubejs/data`): títulos de logros y mensajes de las funciones. + +Los logros salen en el chat cuando el jugador los consigue, y las funciones (`.mcfunction`) pueden mandar +mensajes con `tellraw`, `title` o `say`. Los archivos se traducen en el sitio, con copia de seguridad. +""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, cast + +from ..formatos import comandos +from ..nucleo.mascara import es_traducible +from .base import Contexto, Pieza, Resumen, aplicar, contar, parece_clave, unidades +from .scripts import AMBITO, aplicar_comando, unidades_comando + +RAIZ = "kubejs/data" +#: Carpeta de logros y de funciones dentro de un espacio de nombres (el nombre cambió entre versiones). +LOGROS = frozenset({"advancements", "advancement"}) +FUNCIONES = frozenset({"functions", "function"}) +CAMPOS_LOGRO = ("title", "description") + + +def _componente(valor: Any) -> str: + return json.dumps(valor, ensure_ascii=False) + + +def _logro(datos: Any) -> dict[str, Any] | None: + """El bloque `display` de un logro, o None si el archivo no es un logro con texto.""" + if not isinstance(datos, dict): + return None + display = cast("dict[str, Any]", datos).get("display") + return cast("dict[str, Any]", display) if isinstance(display, dict) else None + + +@dataclass(slots=True) +class _Archivo: + ruta: Path + texto: str + #: Logros: el JSON entero. Funciones: None (se trabaja por líneas). + datos: Any = None + piezas: list[str] = field(default_factory=list[str]) + + +def _unidades_logro(display: Mapping[str, Any]) -> list[str]: + return [t for campo in CAMPOS_LOGRO if campo in display for t in unidades(_componente(display[campo]), True)] + + +def _lineas(texto: str) -> list[tuple[str, comandos.Comando | None, str]]: + """Cada línea separada en (línea sin fin, su comando de chat, fin de línea).""" + res: list[tuple[str, comandos.Comando | None, str]] = [] + for linea in texto.split("\n"): + fin = "\r" if linea.endswith("\r") else "" + cuerpo = linea[: len(linea) - len(fin)] + cmd = comandos.analizar(cuerpo) + res.append((cuerpo, cmd if cmd is not None and unidades_comando(cmd) else None, fin)) + return res + + +class DatosKubeJS: + nombre = "Logros y funciones de KubeJS" + ambito = AMBITO + + def __init__(self, ctx: Contexto) -> None: + self.ctx = ctx + self._archivos_leidos: list[_Archivo] = [] + + def _archivos(self) -> list[Path]: + raiz = self.ctx.instancia.juego / RAIZ + if not raiz.is_dir(): + return [] + encontrados: list[Path] = [] + for ruta in sorted(raiz.rglob("*")): + partes = ruta.relative_to(raiz).parts # (espacio de nombres, carpeta, …) + if len(partes) < 3 or not ruta.is_file(): + continue + if (partes[1] in LOGROS and ruta.suffix == ".json") or ( + partes[1] in FUNCIONES and ruta.suffix == ".mcfunction" + ): + encontrados.append(ruta) + return encontrados + + def detectar(self) -> bool: + return bool(self._archivos()) + + def describir(self) -> str: + return contar(len(self._archivos()), "archivo de datos", "archivos de datos") + + def recolectar(self) -> list[Pieza]: + self._archivos_leidos = [] + piezas: list[Pieza] = [] + for ruta in self._archivos(): + original = self.ctx.respaldo.original(ruta) + if original is None: + continue + try: + texto = original.decode("utf-8-sig") + archivo = _Archivo(ruta, texto) + if ruta.suffix == ".json": + archivo.datos = json.loads(texto) + display = _logro(archivo.datos) + archivo.piezas = _unidades_logro(display) if display is not None else [] + pista = "logro del juego" + else: + archivo.piezas = [t for _, cmd, _ in _lineas(texto) if cmd for t in unidades_comando(cmd)] + pista = "mensaje de una función" + except (ValueError, UnicodeDecodeError): + self.ctx.registro(f"{ruta.name}: no pude leerlo con seguridad, se deja igual") + continue + if archivo.piezas: + self._archivos_leidos.append(archivo) + piezas += [Pieza(t, pista) for t in archivo.piezas if es_traducible(t) and not parece_clave(t)] + return piezas + + def _reescribir(self, a: _Archivo, traducciones: Mapping[str, str]) -> str: + if a.ruta.suffix == ".mcfunction": + return "\n".join( + (aplicar_comando(cmd, traducciones) if cmd else linea) + fin for linea, cmd, fin in _lineas(a.texto) + ) + datos: dict[str, Any] = a.datos + display = _logro(datos) + assert display is not None + for campo in CAMPOS_LOGRO: + if campo in display: + display[campo] = json.loads(aplicar(_componente(display[campo]), traducciones, True)) + return json.dumps(datos, indent=2, ensure_ascii=False) + "\n" + + def escribir(self, traducciones: Mapping[str, str]) -> Resumen: + resumen = Resumen() + for a in self._archivos_leidos: + hechas = sum(1 for t in set(a.piezas) if t in traducciones) + resumen.traducidos += hechas + resumen.pendientes += len(set(a.piezas)) - hechas + nuevo = self._reescribir(a, traducciones) + actual = a.ruta.read_text(encoding="utf-8-sig") if a.ruta.is_file() else None + if hechas and nuevo not in (a.texto, actual): + self.ctx.respaldo.escribir(a.ruta, nuevo.encode("utf-8")) + resumen.archivos.append(a.ruta) + return resumen + + def restaurar(self) -> list[Path]: + return self.ctx.respaldo.restaurar(RAIZ + "/") diff --git a/src/traductor_es/modulos/scripts.py b/src/traductor_es/modulos/scripts.py index ae4dd1c..19ddccc 100644 --- a/src/traductor_es/modulos/scripts.py +++ b/src/traductor_es/modulos/scripts.py @@ -8,10 +8,10 @@ import re from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from ..formatos import javascript +from ..formatos import comandos, flujo_js, javascript, json_texto from ..nucleo.mascara import es_traducible from .base import Contexto, Pieza, Resumen, contar, parece_clave @@ -24,6 +24,8 @@ "displayName", "setDisplayName", "tooltip", "addTooltip", "title", "setTitle", "subtitle", "setSubtitle", "lore", "setLore", "actionbar", "setActionbar", }) # fmt: skip +#: Llamadas que ejecutan un comando: si es `tellraw`, `title`, `say`…, el texto del mensaje se traduce. +LLAMADAS_COMANDO = frozenset({"runCommand", "runCommandSilent"}) #: `Text.of("…")`, `Text.red("…")`, `Component.literal("…")`… AYUDAS_TEXTO = frozenset({ "of", "literal", "string", "black", "darkBlue", "darkGreen", "darkAqua", "darkRed", "darkPurple", "gold", "gray", @@ -37,6 +39,11 @@ "onEvent:client.generate_assets", ) # fmt: skip LLAMADAS_EN_EVENTO = frozenset({"add", "addAll", "insert", "addAdvanced", "addItem", "addFluid", "addLang", "addLine"}) +#: Las llamadas de texto (`tell`, `Text.of`…) como expresión regular: sirve para ver qué variables acaban en un mensaje. +_LLAMADAS_RE = ( + rf"\b(?:{'|'.join(sorted(LLAMADAS_TEXTO))})|\b(?:{'|'.join(sorted(RECEPTORES_TEXTO))})" + rf"\.(?:{'|'.join(sorted(AYUDAS_TEXTO))})" +) _EXPRESION = re.compile(r"\$\{[^{}\n]*\}") @@ -63,12 +70,85 @@ def es_texto_visible(c: javascript.CadenaJs) -> bool: return c.funcion in LLAMADAS_EN_EVENTO and any(ctx.startswith(EVENTOS_TEXTO) for ctx in c.contexto) +def _plantilla_segura(c: javascript.CadenaJs) -> bool: + return c.comilla != "`" or c.texto.count("${") == len(_EXPRESION.findall(c.texto)) + + +def textos_armados( + src: str, cadenas: list[javascript.CadenaJs], flujo: flujo_js.Flujo, tablas: frozenset[str] +) -> list[javascript.CadenaJs]: + """Cadenas que son parte de un mensaje armado por trozos: `let msg = 'Hola '; msg += x; player.tell(msg)`. + + Se eligen las que se asignan o suman a una variable que acaba en `tell`…, los valores `name:` de lo que se + suma a ella (`msg += efecto.name`) y los valores de las tablas de nombres que se suman (`msg += nombres[k]`). + """ + cuerpos = flujo_js.cuerpos_de_tabla(src, cadenas, tablas) + elegidas: list[javascript.CadenaJs] = [] + for c in cadenas: + if c.comparada or not _plantilla_segura(c) or not any(ch.isalpha() for ch in c.texto): + continue + if flujo_js.asignada_a(src, c) in flujo.variables: + # Trozos como «or » o «and » no parecen texto solos, pero aquí sí lo son + visible = es_traducible(c.texto.strip()) and not parece_clave(c.texto) + else: + clave = flujo_js.clave_de(src, c) + visible = ( + clave is not None + and (clave in flujo.miembros or any(a <= c.inicio < b for a, b in cuerpos)) + and _parece_texto(c.texto) + ) + if visible: + elegidas.append(c) + return elegidas + + +def unidades_comando(cmd: comandos.Comando) -> list[str]: + """Los textos que ve el jugador dentro de un comando de chat.""" + if not cmd.json: + return [cmd.cuerpo] if _parece_texto(cmd.cuerpo) else [] + partes = json_texto.extraer(cmd.cuerpo, literal=True) # None: no es JSON (p. ej. SNBT), no se toca + return [p for p in partes or [] if es_traducible(p) and not parece_clave(p)] + + +def aplicar_comando(cmd: comandos.Comando, traducciones: Mapping[str, str]) -> str: + if not cmd.json: + return cmd.prefijo + traducciones.get(cmd.cuerpo, cmd.cuerpo) + return cmd.prefijo + json_texto.reconstruir(cmd.cuerpo, traducciones, literal=True) + + +def comando_visible(c: javascript.CadenaJs) -> comandos.Comando | None: + """El comando de chat que esta cadena le pasa a `runCommand`, si lo hay y trae texto para traducir.""" + if c.comparada or c.funcion not in LLAMADAS_COMANDO: + return None + if c.comilla == "`" and c.texto.count("${") != len(_EXPRESION.findall(c.texto)): + return None + cmd = comandos.analizar(c.texto) + return cmd if cmd is not None and unidades_comando(cmd) else None + + @dataclass(slots=True) class _Script: ruta: Path src: str cadenas: list[javascript.CadenaJs] elegidas: list[javascript.CadenaJs] + #: Cadenas elegidas que son un comando de chat (por posición): se traduce el mensaje, no la línea entera. + ordenes: dict[int, comandos.Comando] = field(default_factory=dict[int, comandos.Comando]) + + def unidades(self, c: javascript.CadenaJs) -> list[str]: + cmd = self.ordenes.get(c.inicio) + return [c.texto] if cmd is None else unidades_comando(cmd) + + def nuevo(self, c: javascript.CadenaJs, traducciones: Mapping[str, str]) -> str | None: + """El texto nuevo de la cadena, o None si no hay traducción o no es seguro usarla.""" + cmd = self.ordenes.get(c.inicio) + if cmd is None: + nuevo = traducciones.get(c.texto) + else: + nuevo = aplicar_comando(cmd, traducciones) + if nuevo == c.texto: + return None # ninguna parte del mensaje tiene traducción + return nuevo if nuevo is not None and _seguro(c, nuevo) else None class ScriptsKubeJS: @@ -89,31 +169,49 @@ def detectar(self) -> bool: def describir(self) -> str: return contar(len(self._archivos()), "script") - def recolectar(self) -> list[Pieza]: - self._scripts = [] + def _leer(self) -> list[tuple[Path, str, list[javascript.CadenaJs]]]: + leidos: list[tuple[Path, str, list[javascript.CadenaJs]]] = [] for ruta in self._archivos(): original = self.ctx.respaldo.original(ruta) if original is None: continue try: src = original.decode("utf-8") - cadenas = javascript.escanear(src) + leidos.append((ruta, src, javascript.escanear(src))) except (ValueError, UnicodeDecodeError): self.ctx.registro(f"{ruta.name}: no pude leerlo con seguridad, se deja igual") - continue - elegidas = [c for c in cadenas if es_texto_visible(c)] + return leidos + + def recolectar(self) -> list[Pieza]: + self._scripts = [] + leidos = self._leer() + flujos = [flujo_js.analizar(src, _LLAMADAS_RE) for _, src, _ in leidos] + # Una tabla de nombres se define en un archivo y se usa en otro + tablas: frozenset[str] = frozenset(t for f in flujos for t in f.tablas) + for (ruta, src, cadenas), flujo in zip(leidos, flujos, strict=True): + elegidas: list[javascript.CadenaJs] = [] + en_comando: dict[int, comandos.Comando] = {} + for c in cadenas: + if es_texto_visible(c): + elegidas.append(c) + elif (cmd := comando_visible(c)) is not None: + elegidas.append(c) + en_comando[c.inicio] = cmd + vistas = {c.inicio for c in elegidas} + elegidas += [c for c in textos_armados(src, cadenas, flujo, tablas) if c.inicio not in vistas] if elegidas: - self._scripts.append(_Script(ruta, src, cadenas, elegidas)) - return [Pieza(c.texto, f"script: {c.funcion}") for s in self._scripts for c in s.elegidas] + self._scripts.append(_Script(ruta, src, cadenas, elegidas, en_comando)) + return [ + Pieza(t, f"script: {c.funcion or 'mensaje'}") + for s in self._scripts + for c in s.elegidas + for t in s.unidades(c) + ] def escribir(self, traducciones: Mapping[str, str]) -> Resumen: resumen = Resumen() for s in self._scripts: - nuevos = { - c.inicio: traducciones[c.texto] - for c in s.elegidas - if c.texto in traducciones and _seguro(c, traducciones[c.texto]) - } + nuevos = {c.inicio: n for c in s.elegidas if (n := s.nuevo(c, traducciones)) is not None} resumen.traducidos += len(nuevos) resumen.pendientes += len(s.elegidas) - len(nuevos) datos = javascript.reemplazar(s.src, s.cadenas, nuevos).encode("utf-8") diff --git a/src/traductor_es/proceso.py b/src/traductor_es/proceso.py index c965bca..f8704f0 100644 --- a/src/traductor_es/proceso.py +++ b/src/traductor_es/proceso.py @@ -11,7 +11,7 @@ from . import configuracion from .fuentes.instancia import Instancia -from .modulos import libros, misiones, mods, scripts +from .modulos import datos, libros, misiones, mods, scripts from .modulos.base import Contexto, Modulo, Resumen from .modulos.mods import es_nombre from .motores import fabrica @@ -159,7 +159,7 @@ def modulos(self) -> list[tuple[str, Modulo]]: if "misiones" in self.grupos: lista += [("misiones", m) for m in misiones.todos(self.ctx)] if "scripts" in self.grupos: - lista.append(("scripts", scripts.ScriptsKubeJS(self.ctx))) + lista += [("scripts", scripts.ScriptsKubeJS(self.ctx)), ("scripts", datos.DatosKubeJS(self.ctx))] return [(g, m) for g, m in lista if m.detectar()] # ------------------------------------------------------------------ acciones diff --git a/src/traductor_es/salida/exportar.py b/src/traductor_es/salida/exportar.py index 5f4b97e..6f99aad 100644 --- a/src/traductor_es/salida/exportar.py +++ b/src/traductor_es/salida/exportar.py @@ -9,8 +9,9 @@ from .copias import Respaldo from .resource_pack import NOMBRE -#: Lo que lee el servidor: misiones y scripts de servidor. Los client_scripts los ejecuta cada jugador. -_DEL_SERVIDOR = ("config/", "kubejs/server_scripts/", "kubejs/startup_scripts/", "patchouli_books/") +#: Lo que lee el servidor: misiones, scripts de servidor y datos (logros, funciones). +#: Los client_scripts los ejecuta cada jugador. +_DEL_SERVIDOR = ("config/", "kubejs/server_scripts/", "kubejs/startup_scripts/", "kubejs/data/", "patchouli_books/") LEEME_KIT = """KIT DE TRADUCCIÓN PARA SERVIDOR ================================ diff --git a/tests/test_comandos_y_datos.py b/tests/test_comandos_y_datos.py new file mode 100644 index 0000000..d962abb --- /dev/null +++ b/tests/test_comandos_y_datos.py @@ -0,0 +1,176 @@ +"""Mensajes de chat: comandos `tellraw`/`say` en scripts de KubeJS, y logros y funciones de `kubejs/data`.""" + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.formatos import comandos +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.datos import DatosKubeJS +from traductor_es.modulos.scripts import ScriptsKubeJS +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.salida.copias import Respaldo + +from . import fabrica + +SCRIPT = r"""ServerEvents.commandRegistry(event => {}) +PlayerEvents.loggedIn(event => { + event.server.runCommandSilent('tellraw @a {"text":"Welcome to the village!","color":"gold"}') + event.server.runCommand("title @a actionbar {\"text\":\"Market opens soon\"}") + event.server.runCommand('say The festival begins') + event.server.runCommand(`execute as @a run tellraw @s ["",{"text":"Hello ${event.player.username}"}]`) + event.server.runCommandSilent('title @a times 10 70 20') + event.server.runCommandSilent('give @p minecraft:stone 2') + event.server.runCommandSilent('tellraw @a {text:"snbt not touched"}') +}) +""" + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +@pytest.mark.parametrize( + ("texto", "prefijo", "cuerpo", "es_json"), + [ + ('tellraw @a {"text":"Hi"}', "tellraw @a ", '{"text":"Hi"}', True), + ('/tellraw @a[distance=..5, tag=x] ["Hi"]', "/tellraw @a[distance=..5, tag=x] ", '["Hi"]', True), + ('title @s subtitle {"text":"Hi"}', "title @s subtitle ", '{"text":"Hi"}', True), + ("say Hello there", "say ", "Hello there", False), + ("execute as @a run msg @s Hello", "execute as @a run msg @s ", "Hello", False), + ], +) +def test_analiza_comandos_de_chat(texto: str, prefijo: str, cuerpo: str, es_json: bool) -> None: + assert comandos.analizar(texto) == comandos.Comando(prefijo, cuerpo, es_json) + + +@pytest.mark.parametrize("texto", ["give @p stone", "title @a times 1 2 3", "title @a clear", "tellraw @a", "# say hi"]) +def test_ignora_lo_que_no_muestra_texto(texto: str) -> None: + assert comandos.analizar(texto) is None + + +def test_runcommand_traduce_solo_el_mensaje(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "kubejs" / "server_scripts" / "chat.js" + ruta.parent.mkdir(parents=True) + ruta.write_text(SCRIPT, encoding="utf-8") + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + resumen = traducir_modulo(ScriptsKubeJS(ctx), Traductor(Simulador(), memoria, Glosario())) + assert resumen.traducidos == 4 and resumen.pendientes == 0 + nuevo = ruta.read_text(encoding="utf-8") + assert """tellraw @a {"text": "«ES» Welcome to the village!", "color": "gold"}""" in nuevo + assert r"""title @a actionbar {\"text\": \"«ES» Market opens soon\"}""" in nuevo + assert "say «ES» The festival begins" in nuevo + assert "${event.player.username}" in nuevo and "execute as @a run tellraw @s " in nuevo + assert "times 10 70 20" in nuevo and "give @p minecraft:stone 2" in nuevo + assert 'tellraw @a {text:"snbt not touched"}' in nuevo + ScriptsKubeJS(ctx).restaurar() + assert ruta.read_text(encoding="utf-8") == SCRIPT + + +LOGRO = { + "display": { + "icon": {"item": "minecraft:wheat"}, + "title": {"text": "First Harvest", "color": "gold"}, + "description": "Harvest your first crop", + "frame": "task", + }, + "criteria": {"x": {"trigger": "minecraft:impossible"}}, +} +FUNCION = ( + "# say no se toca\r\n" + 'tellraw @a {"text":"The shop is open"}\r\n' + "scoreboard players add @a visits 1\r\n" + "say Come in\r\n" +) + + +def test_logros_y_funciones(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + datos = juego / "kubejs" / "data" / "fc" / "advancements" / "farm" + funciones = juego / "kubejs" / "data" / "fc" / "functions" + datos.mkdir(parents=True) + funciones.mkdir(parents=True) + (datos / "harvest.json").write_text(json.dumps(LOGRO), encoding="utf-8") + (juego / "kubejs" / "data" / "fc" / "recipes").mkdir() + (juego / "kubejs" / "data" / "fc" / "recipes" / "r.json").write_text(json.dumps(LOGRO), encoding="utf-8") + (funciones / "open.mcfunction").write_bytes(FUNCION.encode()) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + modulo = DatosKubeJS(ctx) + assert modulo.detectar() and modulo.describir() == "2 archivos de datos" + resumen = traducir_modulo(modulo, Traductor(Simulador(), memoria, Glosario())) + assert resumen.traducidos == 4 and resumen.pendientes == 0 and len(resumen.archivos) == 2 + logro = json.loads((datos / "harvest.json").read_text(encoding="utf-8")) + assert logro["display"]["title"] == {"text": "«ES» First Harvest", "color": "gold"} + assert logro["display"]["description"] == "«ES» Harvest your first crop" + assert logro["display"]["icon"] == LOGRO["display"]["icon"] and logro["criteria"] == LOGRO["criteria"] + nueva = (funciones / "open.mcfunction").read_bytes().decode() + assert nueva == ( + "# say no se toca\r\n" + 'tellraw @a {"text": "«ES» The shop is open"}\r\n' + "scoreboard players add @a visits 1\r\n" + "say «ES» Come in\r\n" + ) + assert json.loads((juego / "kubejs" / "data" / "fc" / "recipes" / "r.json").read_text("utf-8")) == LOGRO + modulo.restaurar() + assert json.loads((datos / "harvest.json").read_text(encoding="utf-8")) == LOGRO + assert (funciones / "open.mcfunction").read_bytes().decode() == FUNCION + + +ARMADO = """const efectos = [ + { id: 'minecraft:luck', name: 'Luck' }, + { id: 'minecraft:speed', name: 'Speed' }, +] +const avisar = (player, lista) => { + let mensaje = 'The food has given you ' + for (let i = 0; i < lista.length; i++) { + if (i > 0) mensaje += ', ' + if (i > 0 && i === lista.length - 1) mensaje += 'and ' + mensaje += lista[i].name + mensaje += global.nombres[lista[i]] + } + let otro = 'Not a message at all' + player.tell(mensaje) + if (player.name == 'Steve') player.tell('Hi') +} +""" +TABLA = """global.nombres = { + fcSweet: 'Sweet', + fcSavory: 'Savory', +} +global.ids = { dulce: 'Not shown' } +""" + + +def test_mensajes_armados_por_partes(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + server = juego / "kubejs" / "server_scripts" / "dieta.js" + startup = juego / "kubejs" / "startup_scripts" / "globales.js" + for ruta, src in ((server, ARMADO), (startup, TABLA)): + ruta.parent.mkdir(parents=True, exist_ok=True) + ruta.write_text(src, encoding="utf-8") + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + resumen = traducir_modulo(ScriptsKubeJS(ctx), Traductor(Simulador(), memoria, Glosario())) + nuevo = server.read_text(encoding="utf-8") + assert "let mensaje = '«ES» The food has given you '" in nuevo and "mensaje += '«ES» and '" in nuevo + assert "name: '«ES» Luck'" in nuevo and "name: '«ES» Speed'" in nuevo + assert "id: 'minecraft:luck'" in nuevo and "mensaje += ', '" in nuevo # sin letras: no es texto + assert "let otro = 'Not a message at all'" in nuevo # no llega a ningún mensaje + assert "tell('«ES» Hi')" in nuevo and "'Steve'" in nuevo + tabla = startup.read_text(encoding="utf-8") + assert "fcSweet: '«ES» Sweet'" in tabla and "fcSavory: '«ES» Savory'" in tabla + assert "dulce: 'Not shown'" in tabla # esa tabla no se suma a ningún mensaje + assert resumen.pendientes == 0 + ScriptsKubeJS(ctx).restaurar() + assert server.read_text(encoding="utf-8") == ARMADO and startup.read_text(encoding="utf-8") == TABLA From f6292040fb670b1dd9dadde8932570906abaa825 Mon Sep 17 00:00:00 2001 From: Cristian Date: Fri, 6 Nov 2026 00:30:48 -0500 Subject: [PATCH 2/6] feat(datos): traducir datapacks, logros y tips en cualquier modpack e informar de lo que queda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Funciones, logros y tips se buscan por su estructura (data//function(s)...) en cualquier carpeta, sueltos o dentro de .zip, con copia de seguridad. - Componentes de texto en JSON y en SNBT (1.21.5+). - notify, kick y los títulos de menús de KubeJS, tomados de su código. - Más ▸ Qué queda en inglés y `modpackes cobertura`: frases de scripts sin elegir y texto escrito en el código de los mods (tabla de constantes de cada clase). Solo lee. - Pruebas con modpacks públicos de Modrinth y con la instancia propia: validez, idempotencia y restauración byte a byte. --- CHANGELOG.md | 26 +- CONTRIBUTING.md | 11 +- README.md | 6 +- scripts/descargar_corpus.py | 39 ++- src/traductor_es/__main__.py | 10 + src/traductor_es/formatos/componente.py | 50 ++++ src/traductor_es/modulos/cobertura.py | 153 ++++++++++++ src/traductor_es/modulos/datos.py | 315 +++++++++++++++++------- src/traductor_es/modulos/scripts.py | 37 ++- src/traductor_es/proceso.py | 27 +- src/traductor_es/salida/exportar.py | 13 +- src/traductor_es/ui/ventana.py | 33 ++- tests/test_cobertura.py | 73 ++++++ tests/test_comandos_y_datos.py | 8 +- tests/test_corpus_datos.py | 135 ++++++++++ tests/test_datos_generales.py | 184 ++++++++++++++ tests/test_ui.py | 16 ++ 17 files changed, 1025 insertions(+), 111 deletions(-) create mode 100644 src/traductor_es/formatos/componente.py create mode 100644 src/traductor_es/modulos/cobertura.py create mode 100644 tests/test_cobertura.py create mode 100644 tests/test_corpus_datos.py create mode 100644 tests/test_datos_generales.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d56ea0..76d83f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,24 @@ Todos los cambios importantes de ModpackES quedan en este archivo. El formato si ### Agregado -- Mensajes de chat de los scripts de KubeJS: el texto de `tellraw`, `title` y `say` (y `msg`, `tell`, `w`) - cuando se manda con `runCommand` o `runCommandSilent`. Antes solo se traducía lo que pasaba por `tell`, - `Text.of` y similares. -- Mensajes que un script arma por partes (`let mensaje = 'Tienes '; mensaje += efecto.name; player.tell(mensaje)`): - los trozos de texto, los `name` que se suman y las tablas de nombres que se suman (`nombres[clave]`). Solo - cuando la variable acaba en una llamada de texto como `tell`. -- Logros (título y descripción) y funciones `.mcfunction` de `kubejs/data`, que también muestran mensajes en - el chat. Van en el grupo Scripts, con copia de seguridad, y entran en el kit para servidores. +- **Mensajes de chat y datos de texto**, que antes quedaban en inglés: + - El texto de `tellraw`, `title`, `say`, `msg`, `tell` y `w` en las funciones (`.mcfunction`) y en lo que un script de + KubeJS manda con `runCommand` o `runCommandSilent`. + - Títulos y descripciones de logros, y los consejos de las pantallas de carga (mod Tips, en sus dos formatos). + - Mensajes que un script arma por partes (`mensaje += efecto.name; player.tell(mensaje)`), con sus tablas de nombres. + - `notify`, `kick`, `openChestGUI` y `openInventoryGUI` de KubeJS (métodos tomados del código de KubeJS 1.20 y 1.21). +- Estos datos se buscan por su estructura y no por la carpeta de un modpack: `kubejs/data`, `global_packs`, `datapacks`, + `config/openloader`, los datapacks de un mundo y cualquier otra con `data//functions/…`, sueltos o dentro de un `.zip` + (se reescribe el zip y se guarda el original). Los componentes de texto valen en JSON y en SNBT (1.21.5 y posteriores). +- **Más ▸ Qué queda en inglés** y `modpackes cobertura`: lista lo que no se puede traducir y por qué (frases de scripts cuyo uso + no se entiende y texto escrito en el código de los mods, leído de la tabla de constantes de cada clase). Solo lee. + El análisis avisa de las frases de scripts que parecen texto. +- Pruebas con modpacks públicos de Modrinth (9 más en `scripts/descargar_corpus.py`) y con la instancia propia: lo traducido + sigue siendo válido, repetir no cambia nada y restaurar devuelve los bytes originales. + +### Cambiado + +- El kit para servidores incluye las funciones y los logros traducidos dondequiera que estén. ## [2.2.0] - 2026-11-04 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7fcbd5..e2a08ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,12 +78,21 @@ referencia también baja el puntaje, así que revisa los ejemplos que lista el s | `src/traductor_es/nucleo` | Marcadores de códigos, memoria y correcciones, glosario, variantes de español y el orquestador de traducción | | `src/traductor_es/formatos` | Lectura y escritura sin pérdidas: SNBT, JSON, NBT, `.lang`, JavaScript y markdown | | `src/traductor_es/fuentes` | Lanzadores, versión, loader, jars y prioridad de los resource packs | -| `src/traductor_es/modulos` | Misiones, libros, scripts y mods: qué se traduce y dónde se escribe | +| `src/traductor_es/modulos` | Misiones, libros, scripts, datos (funciones, logros, tips) y mods: qué se traduce y dónde se escribe; `cobertura.py` dice qué queda en inglés | | `src/traductor_es/motores` | Gemini, el motor compatible con OpenAI, las instrucciones comunes a todos, la fábrica de motores y el simulador para pruebas | | `src/traductor_es/salida` | Resource pack, `options.txt`, copias de seguridad y exportación | | `src/traductor_es/validacion` | Reglas de calidad, revisión automática, medición con el banco de referencia y el validador con los parsers del juego (Java) | | `src/traductor_es/ui` | La ventana y el editor de correcciones (PySide6) | +## Agregar un formato de texto nuevo + +Los archivos de texto de datapacks y packs de recursos (funciones, logros, tips…) se reconocen por su ruta y su formato, +no por el modpack. Para agregar uno, escribe un `Formato` en `src/traductor_es/modulos/datos.py` (qué ruta lo reconoce, +cómo saca los textos y cómo los reescribe), cita su documentación en un comentario y agrega a `tests/` una prueba que +traduzca un caso real recortado, otra con algo que **no** debe tocarse y la de ida y vuelta (restaurar devuelve los bytes +originales). `uv run python scripts/descargar_corpus.py` baja partes de modpacks públicos de Modrinth a `.descargas/` +(Git lo ignora); `uv run pytest -m corpus` los usa para comprobar que lo traducido sigue siendo válido. + ## Licencia de las contribuciones ModpackES usa la licencia [GPL-3.0-or-later](LICENSE). Al mandar un cambio aceptas que se publique bajo esa diff --git a/README.md b/README.md index cfe0e6e..552bfbe 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ y **cualquier lanzador**, y escribe a la vez las siete variantes de español del | Textos de mods (objetos, bloques, menús, tooltips) | `lang/*.json` (1.13+) y `.lang` (1.12 y antes), incluidos los jars anidados, KubeJS y los resource packs del modpack | Resource pack `Traduccion_ES` | | Misiones | FTB Quests (texto en los capítulos, carpeta `lang/` de 1.21+ y NBT de 1.12), BetterQuesting, HQM, Heracles | En su sitio, con copia de seguridad | | Libros guía | Patchouli, Mantle (Tinkers), Citadel (Alex's Mobs), GuideME (AE2), Lavender. Modonomicon va por claves de idioma | Resource pack (o en su sitio, si es un libro propio del modpack) | -| Scripts de KubeJS | Lo que ve el jugador: `tell`, `displayName`, `Text.of`, tooltips, información de JEI/REI/EMI, los mensajes armados por partes antes de un `tell`, y el texto de `tellraw`, `title` y `say` en `runCommand`. También los logros y funciones de `kubejs/data` | En su sitio, con copia de seguridad | +| Scripts de KubeJS | Lo que ve el jugador: `tell`, `notify`, `displayName`, `Text.of`, tooltips, información de JEI/REI/EMI, los mensajes armados por partes antes de un `tell`, y el texto de `tellraw`, `title` y `say` en `runCommand` | En su sitio, con copia de seguridad | +| Datapacks y tips | Funciones (`.mcfunction`: `tellraw`, `title`, `say`), títulos y descripciones de logros, y consejos de pantalla de carga (mod Tips). Se buscan por su estructura (`data//functions/…`), estén en `kubejs/data`, `global_packs`, `datapacks`, `config/openloader` o en los datapacks de un mundo, sueltos o dentro de un `.zip`. Los componentes de texto valen en JSON (hasta 1.21.4) y en SNBT (desde 1.21.5) | En su sitio, con copia de seguridad | Lo que el mod ya trae en español no se toca, y si existe una variante (por ejemplo `es_es`) se copia a las demás sin gastar. Los libros antiguos de Patchouli que guardan el texto dentro del jar no se pueden traducir @@ -84,6 +85,8 @@ traducir indicando el error. La nueva traducción tiene que pasar las mismas reg Tus correcciones nunca se revisan. Sube el costo de esos textos (el análisis lo incluye) y sus marcas no son infalibles: sirven para decidir qué rehacer, no como nota de calidad. +**Más ▸ Qué queda en inglés** (o `modpackes cobertura RUTA [--java]`) lista lo que no se traduce y por qué, sin cambiar nada: frases de scripts cuyo uso no se entiende y texto escrito en el código de los mods, que un resource pack no puede alcanzar. El análisis ya avisa de las frases de scripts. + **Más ▸ Restaurar el original** devuelve misiones, scripts y libros propios al texto del modpack. Para quitar la traducción de los mods basta con desactivar el resource pack. @@ -182,6 +185,7 @@ uv run modpackes instancias uv run modpackes analizar "RUTA DE LA INSTANCIA" uv run modpackes traducir "RUTA" [--dialecto es_es] [--solo misiones,libros] [--lotes] [--modelo gemini-3.5-flash-lite] [--contexto "granja acogedora"] [--revisar] uv run modpackes restaurar "RUTA" +uv run modpackes cobertura "RUTA" [--java] # qué queda en inglés y por qué (solo lee) uv run modpackes clave guardar [--motor openrouter] uv run modpackes modelos [--motor ollama] ``` diff --git a/scripts/descargar_corpus.py b/scripts/descargar_corpus.py index c28a2f7..b353932 100644 --- a/scripts/descargar_corpus.py +++ b/scripts/descargar_corpus.py @@ -25,6 +25,26 @@ "technical-electrical": "https://cdn.modrinth.com/data/PmwWNPV6/versions/mkpGUYy6/Technical%20Electrical%20Temporal%20Totality-26.1.8.mrpack", "supernova-empires-2": "https://cdn.modrinth.com/data/Or9UFKR8/versions/ndm0obMO/Supernova%20Empires%202%207.0.3.mrpack", "prominence-2-fabric": "https://cdn.modrinth.com/data/EGs3lC8D/versions/ga7DDH29/Prominence%E2%84%A2%20II%20Hasturian%20Era%20v4.1.6.mrpack", + # Packs con datapacks, funciones, logros y scripts propios (para probar los datos de texto). Elegidos + # mirando el índice del .mrpack: el contenido de cada uno está en el comentario. + # 1.20.1 fabric · funciones, logros, datapacks .zip + "animatecraft-lankeren": "https://cdn.modrinth.com/data/rqoNUSYX/versions/iRG9ktbA/Lankeren%201.1.5-fixed.mrpack", + # 1.20.1 neoforge · kubejs y kubejs/data + "slimecrew": "https://cdn.modrinth.com/data/u3eBhKdv/versions/VHuCfVgS/SlimeCrew%20Frontier%201.3.0.mrpack", + # 1.20.1 forge · kubejs/data + "omnicraft-modpack": "https://cdn.modrinth.com/data/hcyfOf6Q/versions/Zh2npQSZ/Omnicraft%202.0.mrpack", + # 1.20.1 forge · kubejs y kubejs/data + "stormforge": "https://cdn.modrinth.com/data/Mclf6Juw/versions/Q1Znj0cU/Stormforge%202.1.7.mrpack", + # 1.21.1 neoforge · kubejs + "create-cobblemon-season-1": "https://cdn.modrinth.com/data/Mo3YEbmr/versions/KFjm9wO7/Create%20Cobblemon%20Season%201%201.0.2.mrpack", + # 1.20.1 fabric · logros, datapacks .zip + "prominence-2-chinese": "https://cdn.modrinth.com/data/2Vp3Sdd5/versions/ttpetMlI/Prominence%20II%20Hasturian%20Era%20CN%203.9.2.mrpack", + # 1.20.1 forge · funciones + "battlearmorytacz": "https://cdn.modrinth.com/data/rQiXwLhB/versions/TX0XBjIG/BattleArmory%20TACZ%2026.0.5.mrpack", + # 1.20.1 forge · datapacks .zip + "better-mc-forge-bmc4": "https://cdn.modrinth.com/data/4BV47HRn/versions/bdRIjRgs/Better%20MC%20%5BFORGE%5D%201.20.1%201.20.1%20v59.mrpack", + # 1.20.1 fabric · kubejs, datapacks .zip + "landscapes-reimagined-genesis": "https://cdn.modrinth.com/data/MdznSbKA/versions/pBrfi38A/Landscapes%20Reimagined%20Genesis%203.2.mrpack", } #: Qué se baja de cada pack (rutas dentro del .mrpack). @@ -40,6 +60,12 @@ "overrides/kubejs/server_scripts/", "overrides/kubejs/startup_scripts/", "overrides/patchouli_books/", + # Datos de texto que ve el jugador: funciones, logros y datapacks, vengan de donde vengan + "overrides/kubejs/data/", + "overrides/global_packs/", + "overrides/datapacks/", + "overrides/config/openloader/", + "overrides/config/paxi/", ) EXTENSIONES_KUBEJS_ASSETS = ("/lang/",) # de kubejs/assets solo los idiomas LIMITE_POR_PACK = 40 * 1024 * 1024 @@ -111,7 +137,9 @@ def _elegida(nombre: str) -> bool: return False if nombre in EXACTOS or nombre.startswith(PREFIJOS): return True - return nombre.startswith("overrides/kubejs/assets/") and any(x in nombre for x in EXTENSIONES_KUBEJS_ASSETS) + if nombre.startswith(("overrides/kubejs/assets/", "overrides/resourcepacks/")) and nombre.endswith(".json"): + return any(x in nombre for x in (*EXTENSIONES_KUBEJS_ASSETS, "/tips/")) + return False def _bajar(url: str, e: Entrada) -> bytes: @@ -151,9 +179,14 @@ def bajar_pack(slug: str, url: str) -> None: def main() -> int: elegidos = sys.argv[1:] or list(PACKS) + fallos = 0 for slug in elegidos: - bajar_pack(slug, PACKS[slug]) - return 0 + try: + bajar_pack(slug, PACKS[slug]) + except (RuntimeError, OSError) as e: # un pack que no se puede bajar no detiene a los demás + print(f"{slug}: no se bajó ({e})") + fallos += 1 + return 1 if fallos == len(elegidos) else 0 if __name__ == "__main__": diff --git a/src/traductor_es/__main__.py b/src/traductor_es/__main__.py index fd24e3e..73cc50b 100644 --- a/src/traductor_es/__main__.py +++ b/src/traductor_es/__main__.py @@ -85,6 +85,9 @@ def comunes(p: argparse.ArgumentParser) -> None: traducir.add_argument("--simular", action="store_true", help="prueba sin API: antepone «ES» a todo") traducir.add_argument("--no-activar", action="store_true", help="no tocar options.txt") traducir.add_argument("--no-idioma", action="store_true", help="no cambiar el idioma del juego") + cob = sub.add_parser("cobertura", help="dice qué texto queda en inglés y por qué (solo lee, no escribe nada)") + cob.add_argument("carpeta", help="carpeta de la instancia (o su carpeta minecraft)") + cob.add_argument("--java", action="store_true", help="también el texto escrito en el código de los mods (tarda)") restaurar = sub.add_parser("restaurar", help="devuelve misiones, scripts y libros propios a su original") restaurar.add_argument("carpeta") modelos = sub.add_parser("modelos", help="lista los modelos que ofrece el motor") @@ -124,6 +127,13 @@ def main(argv: Sequence[str] | None = None) -> int: if args.orden == "instancias": for e in buscar(): _escribir(f"{e.lanzador}: {e.nombre}\n {e.juego}") + elif args.orden == "cobertura": + with Proceso(args.carpeta, Simulador(), registro=_escribir) as p: + _escribir(f"Minecraft {p.instancia.version} · {p.instancia.loader or '¿loader?'} · {p.instancia.juego}") + hallazgos = p.que_queda(java=args.java) + for h in hallazgos: + _escribir(f"[{h.categoria}] {h}") + _escribir(f"{len(hallazgos)} hallazgos." if hallazgos else "No encontré texto que quede en inglés.") elif args.orden == "clave": nombre = fabrica.MOTORES[args.motor] if args.accion == "guardar": diff --git a/src/traductor_es/formatos/componente.py b/src/traductor_es/formatos/componente.py new file mode 100644 index 0000000..df018a3 --- /dev/null +++ b/src/traductor_es/formatos/componente.py @@ -0,0 +1,50 @@ +"""Texto de un componente de Minecraft, sea cual sea la versión: JSON o SNBT. + +- Hasta 1.21.4 los componentes (`tellraw`, `title`, títulos de logros…) son JSON: `{"text":"Hola"}`. +- Desde 1.21.5 se guardan como NBT y en los comandos se escriben en SNBT: `{text:"Hola"}` (Minecraft Wiki, + «Text component format»). Una cadena suelta entre comillas y las listas valen en los dos formatos. + +Solo se traduce lo que ve el jugador: los campos `text`, las cadenas sueltas y el contenido de `extra`. Los +colores, las claves `translate`, los `score`/`selector`/`nbt` (los resuelve el servidor) y las acciones quedan igual. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +from ..nucleo.mascara import es_traducible +from . import json_texto, snbt + +#: Campos SNBT cuyo valor es texto visible: `text`, el contenido de `extra` y las cadenas sueltas (sin campo). +_CAMPOS_VISIBLES = frozenset({None, "text", "extra"}) + + +def _cadenas_snbt(cuerpo: str) -> list[snbt.Cadena] | None: + t = cuerpo.strip() + if not t or t[0] not in "{[\"'": + return None + try: + return [c for c in snbt.escanear(cuerpo) if c.campo in _CAMPOS_VISIBLES] + except ValueError: + return None + + +def unidades(cuerpo: str) -> list[str] | None: + """Los textos visibles del componente, o None si no se reconoce el formato (no se toca).""" + partes = json_texto.extraer(cuerpo, literal=True) + if partes is not None: + return [p for p in partes if es_traducible(p)] + cadenas = _cadenas_snbt(cuerpo) + return None if cadenas is None else [c.texto for c in cadenas if c.texto.strip() and es_traducible(c.texto)] + + +def reconstruir(cuerpo: str, traducciones: Mapping[str, str]) -> str: + """El componente con los textos traducidos; lo demás (formato, colores, espacios del SNBT) queda igual.""" + if json_texto.cargar(cuerpo, literal=True) is not None: + return json_texto.reconstruir(cuerpo, traducciones, literal=True) + cadenas = _cadenas_snbt(cuerpo) + if cadenas is None: + return cuerpo + return snbt.reemplazar( + cuerpo, cadenas, {c.inicio: traducciones[c.texto] for c in cadenas if c.texto in traducciones} + ) diff --git a/src/traductor_es/modulos/cobertura.py b/src/traductor_es/modulos/cobertura.py new file mode 100644 index 0000000..57826fc --- /dev/null +++ b/src/traductor_es/modulos/cobertura.py @@ -0,0 +1,153 @@ +"""Qué texto en inglés queda en la instancia aunque se traduzca todo, y por qué. + +La traducción no puede cubrirlo todo: un resource pack no llega al texto escrito en el código de un mod, y hay +cadenas de scripts cuyo uso no se entiende. Este informe lo dice con datos en lugar de dejar al jugador con la duda. +Solo lee; no escribe nada en la instancia. +""" + +from __future__ import annotations + +import re +import struct +import zipfile +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path + +from ..fuentes.instancia import Instancia +from ..nucleo.mascara import es_traducible +from .base import Contexto, parece_clave +from .scripts import ScriptsKubeJS + +MAX_EJEMPLOS = 3 +#: Nombres que delatan una clase que construye texto: `Component.literal` (Mojang), `m_237113_` (SRG 1.19+), +#: `method_43470` (Fabric) y los `TextComponent`, `StringTextComponent` y `TextComponentString` anteriores. +_MARCAS_TEXTO = ("literal", "m_237113_", "method_43470") +_CLASES_TEXTO = ("TextComponent", "StringTextComponent", "TextComponentString") +_FRASE = re.compile(r"[A-Z][A-Za-z']+(?: [A-Za-z0-9,.'!?:%\-]+){2,}[.!?:]?") +_ESPANOL = re.compile(r"\b(de|la|el|los|las|con|sin|para|por|una|un|del)\b") +#: Frases de registros y errores internos: casi nunca llegan al jugador. +_INTERNO = re.compile( + r"^(Failed|Could not|Couldn't|Unable|Unexpected|Invalid|Missing|Exception|Error|Cannot|No )", re.I +) +#: Tamaño de cada tipo de constante de un .class (tras la etiqueta), según la especificación de la JVM +_TAMANO_CONSTANTE = { + 3: 4, + 4: 4, + 5: 8, + 6: 8, + 7: 2, + 8: 2, + 9: 4, + 10: 4, + 11: 4, + 12: 4, + 15: 3, + 16: 2, + 17: 4, + 18: 4, + 19: 2, + 20: 2, +} + + +@dataclass(frozen=True, slots=True) +class Hallazgo: + """Una cosa que queda en inglés: dónde, cuántos textos y unos ejemplos.""" + + categoria: str #: «script» o «java» + donde: str + cantidad: int + motivo: str + ejemplos: tuple[str, ...] = () + + def __str__(self) -> str: + ej = f" Por ejemplo: {' | '.join(f'«{e}»' for e in self.ejemplos)}." if self.ejemplos else "" + return f"{self.donde}: {self.cantidad} {'texto' if self.cantidad == 1 else 'textos'}. {self.motivo}.{ej}" + + +def hallazgos_de_scripts(juego: Path, sin_elegir: list[tuple[Path, list[str]]]) -> list[Hallazgo]: + """Frases de los scripts que parecen texto y que la traducción no tocó (pueden ser mensajes).""" + return [ + Hallazgo( + "script", ruta.relative_to(juego).as_posix(), len(textos), + "Parece una frase, pero no se usa como texto visible (puede ser lógica o un mensaje armado de otra forma)", + tuple(t[:60] for t in textos[:MAX_EJEMPLOS]), + ) + for ruta, textos in sin_elegir + ] # fmt: skip + + +def constantes_utf8(datos: bytes) -> list[str]: + """Las cadenas de la tabla de constantes de un .class (JVMS §4.4). Vacío si el archivo no se entiende.""" + if len(datos) < 10 or datos[:4] != b"\xca\xfe\xba\xbe": + return [] + (cantidad,) = struct.unpack_from(">H", datos, 8) + pos, i = 10, 1 + textos: list[str] = [] + try: + while i < cantidad: + etiqueta = datos[pos] + pos += 1 + if etiqueta == 1: + (largo,) = struct.unpack_from(">H", datos, pos) + textos.append(datos[pos + 2 : pos + 2 + largo].decode("utf-8", "ignore")) + pos += 2 + largo + else: + pos += _TAMANO_CONSTANTE[etiqueta] + i += 1 if etiqueta in (5, 6) else 0 # long y double ocupan dos casillas + i += 1 + except (IndexError, KeyError, struct.error): + return [] + return textos if pos <= len(datos) else [] # si la tabla se pasa del final, el archivo está truncado + + +def _frases_de_clase(datos: bytes) -> list[str]: + constantes = constantes_utf8(datos) + if not any(c in _MARCAS_TEXTO or any(n in c for n in _CLASES_TEXTO) for c in constantes): + return [] # la clase no construye texto para el jugador + return [ + c for c in constantes + if len(c) > 14 and _FRASE.fullmatch(c) and not _INTERNO.match(c) and not _ESPANOL.search(c.lower()) + ] # fmt: skip + + +def textos_en_java(instancia: Instancia, progreso: Callable[[int, int], None] = lambda _a, _b: None) -> list[Hallazgo]: + """Frases en inglés escritas en el código de los mods. Un resource pack no puede traducirlas.""" + res: list[Hallazgo] = [] + jars = instancia.jars() + for i, jar in enumerate(jars): + progreso(i, len(jars)) + frases: list[str] = [] + try: + with zipfile.ZipFile(jar) as z: + for info in z.infolist(): + if ( + info.filename.endswith(".class") + and not info.filename.startswith("META-INF") + and info.file_size < 3_000_000 + ): + frases += _frases_de_clase(z.read(info)) + except (zipfile.BadZipFile, OSError): + continue + unicas = list(dict.fromkeys(f for f in frases if es_traducible(f) and not parece_clave(f))) + if unicas: + res.append(Hallazgo( + "java", jar.name, len(unicas), + "Escrito en el código del mod: un resource pack no lo traduce", + tuple(f[:60] for f in unicas[:MAX_EJEMPLOS]), + )) # fmt: skip + return sorted(res, key=lambda h: -h.cantidad) + + +def revisar_cobertura( + ctx: Contexto, java: bool = False, progreso: Callable[[int, int], None] = lambda _a, _b: None +) -> list[Hallazgo]: + """Lo que queda en inglés. El análisis del código Java tarda (lee todos los mods): solo si se pide.""" + hallazgos = hallazgos_de_scripts(ctx.instancia.juego, ScriptsKubeJS(ctx).candidatas()) + if java: + hallazgos += textos_en_java(ctx.instancia, progreso) + return hallazgos + + +__all__ = ["Hallazgo", "revisar_cobertura"] diff --git a/src/traductor_es/modulos/datos.py b/src/traductor_es/modulos/datos.py index 755eb16..a5fd297 100644 --- a/src/traductor_es/modulos/datos.py +++ b/src/traductor_es/modulos/datos.py @@ -1,26 +1,37 @@ -"""Textos de los datos de KubeJS (`kubejs/data`): títulos de logros y mensajes de las funciones. +"""Textos de datapacks y packs de recursos que el jugador ve en el chat o en pantalla: funciones, logros y tips. -Los logros salen en el chat cuando el jugador los consigue, y las funciones (`.mcfunction`) pueden mandar -mensajes con `tellraw`, `title` o `say`. Los archivos se traducen en el sitio, con copia de seguridad. +Los archivos se buscan por su **estructura** y no por la carpeta de un modpack concreto: cualquier ruta que +contenga `data//function(s)/…`, `data//advancement(s)/…` o `assets//tips/…` cuenta, +esté en `kubejs/data`, `global_packs/*/*`, `datapacks`, `config/openloader`, `saves/*/datapacks`… Cada formato +reconocido es un `Formato` con su documentación; lo que no se reconoce no se toca. Se traduce en el sitio, con +copia de seguridad. """ from __future__ import annotations +import io import json -from collections.abc import Mapping +import os +import zipfile +from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass, field from pathlib import Path from typing import Any, cast -from ..formatos import comandos +from ..formatos import comandos, componente from ..nucleo.mascara import es_traducible -from .base import Contexto, Pieza, Resumen, aplicar, contar, parece_clave, unidades +from ..salida.resource_pack import NOMBRE as NOMBRE_PACK_SALIDA +from .base import Contexto, Pieza, Resumen, contar, parece_clave from .scripts import AMBITO, aplicar_comando, unidades_comando -RAIZ = "kubejs/data" -#: Carpeta de logros y de funciones dentro de un espacio de nombres (el nombre cambió entre versiones). -LOGROS = frozenset({"advancements", "advancement"}) -FUNCIONES = frozenset({"functions", "function"}) +#: Carpetas que nunca se recorren: cachés, registros, mods (sus jars se leen en otro sitio) y lo que genera la app. +IGNORAR = frozenset({ + "mods", "logs", "crash-reports", "traductor_es", "cache", "libraries", "versions", "screenshots", "shaderpacks", + "backups", "probe", "node_modules", "modernfix", "xaerowaypoints", "xaeroworldmap", "journeymap", + NOMBRE_PACK_SALIDA.lower(), +}) # fmt: skip +PROFUNDIDAD_MAXIMA = 10 +LIMITE_ZIP = 64 * 1024 * 1024 #: más grande que esto no es un datapack, es un resource pack pesado: no se abre CAMPOS_LOGRO = ("title", "description") @@ -28,27 +39,19 @@ def _componente(valor: Any) -> str: return json.dumps(valor, ensure_ascii=False) -def _logro(datos: Any) -> dict[str, Any] | None: - """El bloque `display` de un logro, o None si el archivo no es un logro con texto.""" - if not isinstance(datos, dict): - return None - display = cast("dict[str, Any]", datos).get("display") - return cast("dict[str, Any]", display) if isinstance(display, dict) else None - - -@dataclass(slots=True) -class _Archivo: - ruta: Path - texto: str - #: Logros: el JSON entero. Funciones: None (se trabaja por líneas). - datos: Any = None - piezas: list[str] = field(default_factory=list[str]) +def _traducibles(partes: list[str] | None) -> list[str]: + return [p for p in partes or [] if not parece_clave(p)] -def _unidades_logro(display: Mapping[str, Any]) -> list[str]: - return [t for campo in CAMPOS_LOGRO if campo in display for t in unidades(_componente(display[campo]), True)] +def _nodos(datos: Any, claves: tuple[str, ...]) -> list[tuple[dict[str, Any], str]]: + """(diccionario, clave) de cada valor de texto que hay que traducir dentro de un JSON.""" + if not isinstance(datos, dict): + return [] + d = cast("dict[str, Any]", datos) + return [(d, k) for k in claves if k in d] +# ---------------------------------------------------------------- funciones (.mcfunction) def _lineas(texto: str) -> list[tuple[str, comandos.Comando | None, str]]: """Cada línea separada en (línea sin fin, su comando de chat, fin de línea).""" res: list[tuple[str, comandos.Comando | None, str]] = [] @@ -60,86 +63,230 @@ def _lineas(texto: str) -> list[tuple[str, comandos.Comando | None, str]]: return res -class DatosKubeJS: - nombre = "Logros y funciones de KubeJS" +def _unidades_funcion(texto: str) -> list[str]: + return [t for _, cmd, _ in _lineas(texto) if cmd for t in unidades_comando(cmd)] + + +def _reescribir_funcion(texto: str, traducciones: Mapping[str, str]) -> str: + return "\n".join((aplicar_comando(cmd, traducciones) if cmd else linea) + fin for linea, cmd, fin in _lineas(texto)) + + +# ---------------------------------------------------------------- JSON con componentes (logros, tips) +def _nodos_logro(datos: Any) -> list[tuple[dict[str, Any], str]]: + """`display.title` y `display.description` de un logro.""" + display = cast("dict[str, Any]", datos).get("display") if isinstance(datos, dict) else None + return _nodos(display, CAMPOS_LOGRO) + + +def _nodos_tip(datos: Any) -> list[tuple[dict[str, Any], str]]: + """Tips: `{"tip": …}` (Tips de Darkhax) y `{"tips": [{"text": …}]}` (el port de NeoForge).""" + if not isinstance(datos, dict): + return [] + d = cast("dict[str, Any]", datos) + nodos = _nodos(d, ("tip",)) + lista = d.get("tips") + if isinstance(lista, list): + for t in cast("list[Any]", lista): + nodos += _nodos(t, ("text",)) + return nodos + + +def _unidades_json(texto: str, nodos: Callable[[Any], list[tuple[dict[str, Any], str]]]) -> list[str]: + return [u for d, k in nodos(json.loads(texto)) for u in _traducibles(componente.unidades(_componente(d[k])))] + + +def _reescribir_json( + texto: str, traducciones: Mapping[str, str], nodos: Callable[[Any], list[tuple[dict[str, Any], str]]] +) -> str: + datos = json.loads(texto) + for d, k in nodos(datos): + d[k] = json.loads(componente.reconstruir(_componente(d[k]), traducciones)) + return json.dumps(datos, indent=2, ensure_ascii=False) + "\n" + + +@dataclass(frozen=True, slots=True) +class Formato: + nombre: str + pista: str #: contexto que se le da al motor de traducción + reconoce: Callable[[tuple[str, ...]], bool] #: recibe la ruta desde «data» o «assets»: (data, espacio, carpeta, …) + unidades: Callable[[str], list[str]] #: puede lanzar ValueError si el archivo no se entiende + reescribir: Callable[[str, Mapping[str, str]], str] + + +def _es(ruta: tuple[str, ...], raiz: str, carpetas: tuple[str, ...], sufijo: str) -> bool: + return len(ruta) >= 4 and ruta[0] == raiz and ruta[2] in carpetas and ruta[-1].lower().endswith(sufijo) + + +FORMATOS: tuple[Formato, ...] = ( + Formato( + "función", "mensaje de una función", + lambda r: _es(r, "data", ("function", "functions"), ".mcfunction"), + _unidades_funcion, _reescribir_funcion, + ), + Formato( + "logro", "logro del juego", + lambda r: _es(r, "data", ("advancement", "advancements"), ".json"), + lambda t: _unidades_json(t, _nodos_logro), lambda t, tr: _reescribir_json(t, tr, _nodos_logro), + ), + Formato( + "tip", "consejo de las pantallas de carga", + lambda r: _es(r, "assets", ("tips",), ".json") or (_es(r, "data", ("tips",), ".json") and r[1] == "tipsmod"), + lambda t: _unidades_json(t, _nodos_tip), lambda t, tr: _reescribir_json(t, tr, _nodos_tip), + ), +) # fmt: skip + + +def clasificar(ruta: Path) -> Formato | None: + """El formato de este archivo según su ruta, o None. Busca el ancla «data» o «assets» en cualquier posición.""" + partes = ruta.parts + for i, parte in enumerate(partes): + if parte in ("data", "assets"): + for formato in FORMATOS: + if formato.reconoce(partes[i:]): + return formato + return None + + +def _recorrer(juego: Path) -> Iterator[Path]: + """Cada archivo de la instancia, sin entrar en mods, cachés ni mundos (de un mundo solo sus datapacks).""" + base = len(juego.parts) + for raiz, carpetas, archivos in os.walk(juego): + actual = Path(raiz) + if len(actual.parts) - base >= PROFUNDIDAD_MAXIMA: + carpetas.clear() + elif actual.parent.name.lower() == "saves": + carpetas[:] = [c for c in carpetas if c.lower() == "datapacks"] + else: + carpetas[:] = sorted(c for c in carpetas if c.lower() not in IGNORAR and not c.startswith(".")) + for nombre in sorted(archivos): + yield actual / nombre + + +def buscar_archivos(juego: Path) -> list[tuple[Path, Formato]]: + """Todos los archivos de texto conocidos que están sueltos en la instancia.""" + encontrados: list[tuple[Path, Formato]] = [] + for ruta in _recorrer(juego): + if (formato := clasificar(ruta.relative_to(juego))) is not None: + encontrados.append((ruta, formato)) + return encontrados + + +def _entradas_del_zip(ruta: Path) -> list[tuple[str, Formato]]: + """Las entradas de un .zip (datapack o pack de recursos) con texto conocido. Solo lee su índice.""" + if ruta.stat().st_size > LIMITE_ZIP: + return [] + try: + with zipfile.ZipFile(ruta) as z: + return [(n, f) for n in z.namelist() if not n.endswith("/") and (f := clasificar(Path(n))) is not None] + except (zipfile.BadZipFile, OSError): + return [] + + +def buscar_zips(juego: Path) -> list[tuple[Path, list[tuple[str, Formato]]]]: + """Los .zip con texto conocido dentro (datapacks de `global_packs`, `datapacks`, mundos…).""" + res: list[tuple[Path, list[tuple[str, Formato]]]] = [] + for ruta in _recorrer(juego): + if ruta.suffix.lower() == ".zip" and (entradas := _entradas_del_zip(ruta)): + res.append((ruta, entradas)) + return res + + +@dataclass(slots=True) +class _Archivo: + ruta: Path #: el archivo suelto o el .zip que lo contiene + formato: Formato + texto: str + piezas: list[str] = field(default_factory=list[str]) + entrada: str | None = None #: nombre dentro del .zip; None si es un archivo suelto + + +def _reescribir_zip(original: bytes, nuevos: Mapping[str, str]) -> bytes: + """El mismo .zip con esas entradas cambiadas; las demás, el orden y las fechas quedan igual.""" + salida = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(original)) as entrada, zipfile.ZipFile(salida, "w") as z: + for info in entrada.infolist(): + datos = nuevos[info.filename].encode("utf-8") if info.filename in nuevos else entrada.read(info) + z.writestr(info, datos, compress_type=info.compress_type) + return salida.getvalue() + + +class Datos: + nombre = "Funciones, logros y tips" ambito = AMBITO def __init__(self, ctx: Contexto) -> None: self.ctx = ctx - self._archivos_leidos: list[_Archivo] = [] - - def _archivos(self) -> list[Path]: - raiz = self.ctx.instancia.juego / RAIZ - if not raiz.is_dir(): - return [] - encontrados: list[Path] = [] - for ruta in sorted(raiz.rglob("*")): - partes = ruta.relative_to(raiz).parts # (espacio de nombres, carpeta, …) - if len(partes) < 3 or not ruta.is_file(): - continue - if (partes[1] in LOGROS and ruta.suffix == ".json") or ( - partes[1] in FUNCIONES and ruta.suffix == ".mcfunction" - ): - encontrados.append(ruta) - return encontrados + self._leidos: list[_Archivo] = [] + self._zips: dict[Path, bytes] = {} #: contenido original de cada .zip leído def detectar(self) -> bool: - return bool(self._archivos()) + juego = self.ctx.instancia.juego + return bool(buscar_archivos(juego) or buscar_zips(juego)) def describir(self) -> str: - return contar(len(self._archivos()), "archivo de datos", "archivos de datos") + juego = self.ctx.instancia.juego + n = len(buscar_archivos(juego)) + sum(len(e) for _, e in buscar_zips(juego)) + return contar(n, "archivo de datos", "archivos de datos") + + def _leer(self, ruta: Path, formato: Formato, datos: bytes, entrada: str | None) -> list[Pieza]: + try: + texto = datos.decode("utf-8-sig") + unidades = [t for t in formato.unidades(texto) if es_traducible(t)] + except (ValueError, UnicodeDecodeError): + self.ctx.registro(f"{entrada or ruta.name}: no pude leerlo con seguridad, se deja igual") + return [] + if not unidades: + return [] + self._leidos.append(_Archivo(ruta, formato, texto, unidades, entrada)) + return [Pieza(t, formato.pista) for t in unidades] def recolectar(self) -> list[Pieza]: - self._archivos_leidos = [] + self._leidos, self._zips = [], {} + juego = self.ctx.instancia.juego piezas: list[Pieza] = [] - for ruta in self._archivos(): - original = self.ctx.respaldo.original(ruta) - if original is None: + for ruta, formato in buscar_archivos(juego): + if (original := self.ctx.respaldo.original(ruta)) is not None: + piezas += self._leer(ruta, formato, original, None) + for ruta, entradas in buscar_zips(juego): + if (original := self.ctx.respaldo.original(ruta)) is None: continue try: - texto = original.decode("utf-8-sig") - archivo = _Archivo(ruta, texto) - if ruta.suffix == ".json": - archivo.datos = json.loads(texto) - display = _logro(archivo.datos) - archivo.piezas = _unidades_logro(display) if display is not None else [] - pista = "logro del juego" - else: - archivo.piezas = [t for _, cmd, _ in _lineas(texto) if cmd for t in unidades_comando(cmd)] - pista = "mensaje de una función" - except (ValueError, UnicodeDecodeError): - self.ctx.registro(f"{ruta.name}: no pude leerlo con seguridad, se deja igual") + with zipfile.ZipFile(io.BytesIO(original)) as z: + contenidos = [(n, f, z.read(n)) for n, f in entradas] + except (zipfile.BadZipFile, OSError, KeyError): + self.ctx.registro(f"{ruta.name}: no pude abrirlo, se deja igual") continue - if archivo.piezas: - self._archivos_leidos.append(archivo) - piezas += [Pieza(t, pista) for t in archivo.piezas if es_traducible(t) and not parece_clave(t)] + self._zips[ruta] = original + for nombre, formato, datos in contenidos: + piezas += self._leer(ruta, formato, datos, nombre) return piezas - def _reescribir(self, a: _Archivo, traducciones: Mapping[str, str]) -> str: - if a.ruta.suffix == ".mcfunction": - return "\n".join( - (aplicar_comando(cmd, traducciones) if cmd else linea) + fin for linea, cmd, fin in _lineas(a.texto) - ) - datos: dict[str, Any] = a.datos - display = _logro(datos) - assert display is not None - for campo in CAMPOS_LOGRO: - if campo in display: - display[campo] = json.loads(aplicar(_componente(display[campo]), traducciones, True)) - return json.dumps(datos, indent=2, ensure_ascii=False) + "\n" - def escribir(self, traducciones: Mapping[str, str]) -> Resumen: resumen = Resumen() - for a in self._archivos_leidos: + en_zip: dict[Path, dict[str, str]] = {} + for a in self._leidos: hechas = sum(1 for t in set(a.piezas) if t in traducciones) resumen.traducidos += hechas resumen.pendientes += len(set(a.piezas)) - hechas - nuevo = self._reescribir(a, traducciones) - actual = a.ruta.read_text(encoding="utf-8-sig") if a.ruta.is_file() else None - if hechas and nuevo not in (a.texto, actual): + nuevo = a.formato.reescribir(a.texto, traducciones) + if not hechas or nuevo == a.texto: + continue + if a.entrada is not None: + en_zip.setdefault(a.ruta, {})[a.entrada] = nuevo + elif not a.ruta.is_file() or a.ruta.read_bytes() != nuevo.encode("utf-8"): # en bytes: sin traducir \r\n self.ctx.respaldo.escribir(a.ruta, nuevo.encode("utf-8")) resumen.archivos.append(a.ruta) + for ruta, nuevos in en_zip.items(): + datos = _reescribir_zip(self._zips[ruta], nuevos) + if ruta.read_bytes() != datos: + self.ctx.respaldo.escribir(ruta, datos) + resumen.archivos.append(ruta) return resumen def restaurar(self) -> list[Path]: - return self.ctx.respaldo.restaurar(RAIZ + "/") + juego = self.ctx.instancia.juego + propios = [r for r in self.ctx.respaldo.registrados() if clasificar(Path(r)) is not None or r.endswith(".zip")] + hechos: list[Path] = [] + for ruta in propios: # Respaldo restaura por prefijo: la ruta completa es el prefijo más estricto + hechos += self.ctx.respaldo.restaurar(ruta) if (juego / ruta).exists() else [] + return hechos diff --git a/src/traductor_es/modulos/scripts.py b/src/traductor_es/modulos/scripts.py index 19ddccc..2e3a14a 100644 --- a/src/traductor_es/modulos/scripts.py +++ b/src/traductor_es/modulos/scripts.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, field from pathlib import Path -from ..formatos import comandos, flujo_js, javascript, json_texto +from ..formatos import comandos, componente, flujo_js, javascript from ..nucleo.mascara import es_traducible from .base import Contexto, Pieza, Resumen, contar, parece_clave @@ -23,6 +23,9 @@ "tell", "statusMessage", "setStatusMessage", "displayClientMessage", "sendSystemMessage", "sendMessage", "displayName", "setDisplayName", "tooltip", "addTooltip", "title", "setTitle", "subtitle", "setSubtitle", "lore", "setLore", "actionbar", "setActionbar", + # Del código de KubeJS (ramas 2001 y 2101, core/PlayerKJS, ServerPlayerKJS y MessageSenderKJS): + # `notify(título, texto)`, `kick(motivo)`, y el título de `openChestGUI` / `openInventoryGUI` + "notify", "kick", "openChestGUI", "openInventoryGUI", }) # fmt: skip #: Llamadas que ejecutan un comando: si es `tellraw`, `title`, `say`…, el texto del mensaje se traduce. LLAMADAS_COMANDO = frozenset({"runCommand", "runCommandSilent"}) @@ -70,6 +73,21 @@ def es_texto_visible(c: javascript.CadenaJs) -> bool: return c.funcion in LLAMADAS_EN_EVENTO and any(ctx.startswith(EVENTOS_TEXTO) for ctx in c.contexto) +_LOGICA = ("recipes", "tags", "loot", "worldgen", "registry", "highPriorityData") + + +def parece_frase_sin_elegir(c: javascript.CadenaJs) -> bool: + """Una cadena con aspecto de frase (3 palabras o más, empieza en mayúscula) fuera de recetas y etiquetas.""" + t = c.texto.strip() + return ( + not c.comparada + and t[:1].isupper() + and len(t.split()) >= 3 + and _parece_texto(t) + and not any(m in ctx for ctx in c.contexto for m in _LOGICA) + ) + + def _plantilla_segura(c: javascript.CadenaJs) -> bool: return c.comilla != "`" or c.texto.count("${") == len(_EXPRESION.findall(c.texto)) @@ -106,14 +124,14 @@ def unidades_comando(cmd: comandos.Comando) -> list[str]: """Los textos que ve el jugador dentro de un comando de chat.""" if not cmd.json: return [cmd.cuerpo] if _parece_texto(cmd.cuerpo) else [] - partes = json_texto.extraer(cmd.cuerpo, literal=True) # None: no es JSON (p. ej. SNBT), no se toca - return [p for p in partes or [] if es_traducible(p) and not parece_clave(p)] + partes = componente.unidades(cmd.cuerpo) # None: formato que no se reconoce, no se toca + return [p for p in partes or [] if not parece_clave(p)] def aplicar_comando(cmd: comandos.Comando, traducciones: Mapping[str, str]) -> str: if not cmd.json: return cmd.prefijo + traducciones.get(cmd.cuerpo, cmd.cuerpo) - return cmd.prefijo + json_texto.reconstruir(cmd.cuerpo, traducciones, literal=True) + return cmd.prefijo + componente.reconstruir(cmd.cuerpo, traducciones) def comando_visible(c: javascript.CadenaJs) -> comandos.Comando | None: @@ -158,6 +176,7 @@ class ScriptsKubeJS: def __init__(self, ctx: Contexto) -> None: self.ctx = ctx self._scripts: list[_Script] = [] + self.sin_elegir: list[tuple[Path, list[str]]] = [] def _archivos(self) -> list[Path]: juego = self.ctx.instancia.juego @@ -182,8 +201,14 @@ def _leer(self) -> list[tuple[Path, str, list[javascript.CadenaJs]]]: self.ctx.registro(f"{ruta.name}: no pude leerlo con seguridad, se deja igual") return leidos + def candidatas(self) -> list[tuple[Path, list[str]]]: + """Por script, las frases que no se eligieron para traducir (para el informe de cobertura).""" + self.recolectar() + return self.sin_elegir + def recolectar(self) -> list[Pieza]: self._scripts = [] + self.sin_elegir = [] leidos = self._leer() flujos = [flujo_js.analizar(src, _LLAMADAS_RE) for _, src, _ in leidos] # Una tabla de nombres se define en un archivo y se usa en otro @@ -201,6 +226,10 @@ def recolectar(self) -> list[Pieza]: elegidas += [c for c in textos_armados(src, cadenas, flujo, tablas) if c.inicio not in vistas] if elegidas: self._scripts.append(_Script(ruta, src, cadenas, elegidas, en_comando)) + vistas = {c.inicio for c in elegidas} + frases = [c.texto for c in cadenas if c.inicio not in vistas and parece_frase_sin_elegir(c)] + if frases: + self.sin_elegir.append((ruta, frases)) return [ Pieza(t, f"script: {c.funcion or 'mensaje'}") for s in self._scripts diff --git a/src/traductor_es/proceso.py b/src/traductor_es/proceso.py index f8704f0..e7b89fc 100644 --- a/src/traductor_es/proceso.py +++ b/src/traductor_es/proceso.py @@ -13,6 +13,7 @@ from .fuentes.instancia import Instancia from .modulos import datos, libros, misiones, mods, scripts from .modulos.base import Contexto, Modulo, Resumen +from .modulos.cobertura import Hallazgo, hallazgos_de_scripts, revisar_cobertura from .modulos.mods import es_nombre from .motores import fabrica from .motores.base import Motor @@ -56,6 +57,8 @@ class Informe: revision_automatica: revision.ResumenRevision | None = None pack_activado: list[str] = field(default_factory=list[str]) fallidos: list[str] = field(default_factory=list[str]) #: grupos que el análisis no pudo leer + #: Lo que queda en inglés aunque se traduzca todo, y por qué (ver `modulos/cobertura.py`) + cobertura: list[Hallazgo] = field(default_factory=list[Hallazgo]) @property def a_traducir(self) -> int: @@ -159,14 +162,19 @@ def modulos(self) -> list[tuple[str, Modulo]]: if "misiones" in self.grupos: lista += [("misiones", m) for m in misiones.todos(self.ctx)] if "scripts" in self.grupos: - lista += [("scripts", scripts.ScriptsKubeJS(self.ctx)), ("scripts", datos.DatosKubeJS(self.ctx))] + lista += [("scripts", scripts.ScriptsKubeJS(self.ctx)), ("scripts", datos.Datos(self.ctx))] return [(g, m) for g, m in lista if m.detectar()] # ------------------------------------------------------------------ acciones + def que_queda(self, java: bool = False, progreso: Callable[[int, int], None] | None = None) -> list[Hallazgo]: + """Lo que quedaría en inglés. Solo lee. `java`: también el texto escrito en el código de los mods (tarda).""" + return revisar_cobertura(self.ctx, java, progreso or (lambda _a, _b: None)) + def analizar(self) -> Informe: """Cuenta lo que hay que traducir. Solo lee: no escribe nada en la instancia.""" self._preparar_glosario() informe = Informe() + scripts_leidos: list[scripts.ScriptsKubeJS] = [] for grupo, modulo in self.modulos(): try: piezas = modulo.recolectar() @@ -175,13 +183,30 @@ def analizar(self) -> Informe: if grupo not in informe.fallidos: informe.fallidos.append(grupo) continue + if isinstance(modulo, scripts.ScriptsKubeJS): + scripts_leidos.append(modulo) pendientes = self.traductor.pendientes([p.texto for p in piezas], modulo.ambito) informe.modulos.append( InformeModulo(grupo, modulo.nombre, modulo.describir(), len({p.texto for p in piezas}), len(pendientes), sum(len(t) for t in pendientes)) ) # fmt: skip + informe.avisos.extend(self._avisos_de_cobertura(informe, scripts_leidos)) return informe + def _avisos_de_cobertura(self, informe: Informe, scripts_leidos: list[scripts.ScriptsKubeJS]) -> list[str]: + """Lo barato de saber antes de traducir: frases de scripts que parecen texto y no se tocan. + + Se apoya en lo que el análisis ya leyó y nunca lo detiene. + """ + for s in scripts_leidos: + informe.cobertura += hallazgos_de_scripts(self.instancia.juego, s.sin_elegir) + if not informe.cobertura: + return [] + return [ + f"{sum(h.cantidad for h in informe.cobertura)} frases de {len(informe.cobertura)} scripts parecen texto " + "pero no se traducen (puede ser lógica). Detalle con «modpackes cobertura»." + ] + def ejecutar(self, activar: bool = True, poner_idioma: bool = True) -> Informe: self._preparar_glosario() informe = Informe() diff --git a/src/traductor_es/salida/exportar.py b/src/traductor_es/salida/exportar.py index 6f99aad..2879929 100644 --- a/src/traductor_es/salida/exportar.py +++ b/src/traductor_es/salida/exportar.py @@ -6,12 +6,13 @@ import zipfile from pathlib import Path +from ..modulos.datos import clasificar from .copias import Respaldo from .resource_pack import NOMBRE -#: Lo que lee el servidor: misiones, scripts de servidor y datos (logros, funciones). +#: Lo que lee el servidor: misiones y scripts de servidor, más los datos (logros y funciones) donde estén. #: Los client_scripts los ejecuta cada jugador. -_DEL_SERVIDOR = ("config/", "kubejs/server_scripts/", "kubejs/startup_scripts/", "kubejs/data/", "patchouli_books/") +_DEL_SERVIDOR = ("config/", "kubejs/server_scripts/", "kubejs/startup_scripts/", "patchouli_books/") LEEME_KIT = """KIT DE TRADUCCIÓN PARA SERVIDOR ================================ @@ -35,6 +36,12 @@ def _agregar_carpeta(zf: zipfile.ZipFile, carpeta: Path, prefijo: str = "") -> i return n +def _es_dato_del_servidor(relativa: str) -> bool: + """Funciones y logros de un datapack los lee el servidor; los tips son de cliente.""" + formato = clasificar(Path(relativa)) + return formato is not None and formato.nombre != "tip" + + def exportar_pack(juego: Path, destino: Path) -> int: """El resource pack de la traducción como .zip listo para compartir. Devuelve cuántos archivos lleva.""" pack = juego / "resourcepacks" / NOMBRE @@ -48,7 +55,7 @@ def exportar_pack(juego: Path, destino: Path) -> int: def exportar_kit_servidor(juego: Path, destino: Path) -> int: """Kit para servidores: archivos traducidos que lee el servidor, el resource pack y unas instrucciones.""" respaldo = Respaldo(juego) - archivos = [r for r in respaldo.registrados() if r.startswith(_DEL_SERVIDOR)] + archivos = [r for r in respaldo.registrados() if r.startswith(_DEL_SERVIDOR) or _es_dato_del_servidor(r)] pack = juego / "resourcepacks" / NOMBRE if not archivos and not (pack / "pack.mcmeta").is_file(): raise ValueError("Todavía no hay nada traducido para el servidor: traduce primero.") diff --git a/src/traductor_es/ui/ventana.py b/src/traductor_es/ui/ventana.py index f666bb9..ac048c9 100644 --- a/src/traductor_es/ui/ventana.py +++ b/src/traductor_es/ui/ventana.py @@ -49,6 +49,7 @@ from ..actualizaciones import Novedad from ..fuentes.instancia import resolver_carpeta_juego from ..fuentes.lanzadores import InstanciaEncontrada, buscar +from ..modulos.cobertura import Hallazgo from ..motores import fabrica from ..motores.base import Cancelado, ErrorFatal, Motor from ..motores.compatible import Compatible @@ -165,6 +166,8 @@ def ejecutar(self) -> None: resultado: object = p.analizar() elif self.accion == "traducir": resultado = p.ejecutar(activar=self.opciones["activar"], poner_idioma=self.opciones["idioma"]) + elif self.accion == "cobertura": + resultado = p.que_queda(java=True, progreso=self.progreso.emit) else: resultado = p.restaurar() self.terminado.emit(resultado) @@ -498,6 +501,8 @@ def agregar(texto: str, nombre_icono: str, funcion: Callable[[], object]) -> QAc self.a_correcciones = agregar("Correcciones…", "pencil", self.abrir_correcciones) self.a_correcciones.setToolTip("Buscar y corregir a mano traducciones de la memoria") + self.a_cobertura = agregar("Qué queda en inglés", "circle-alert", lambda: self.lanzar("cobertura")) + self.a_cobertura.setToolTip("Lista el texto que no se puede traducir y por qué (solo lee, no cambia nada)") self.a_restaurar = agregar("Restaurar el original…", "undo-2", self._confirmar_restaurar) self.a_exportar = agregar("Exportar pack…", "package", lambda: self._exportar(kit=False)) self.a_kit = agregar("Kit de servidor…", "server", lambda: self._exportar(kit=True)) @@ -889,7 +894,11 @@ def lanzar(self, accion: str) -> None: self.barra.setMaximum(0) # sin total todavía: barra en movimiento self._inicio = None self._fase = "" - self.estado.setText({"analizar": "Analizando…", "traducir": "Preparando…"}.get(accion, "Restaurando…")) + self.estado.setText( + {"analizar": "Analizando…", "traducir": "Preparando…", "cobertura": "Buscando texto en inglés…"}.get( + accion, "Restaurando…" + ) + ) self._accion = accion self._antes = self._estado self._estado = "trabajando" @@ -923,7 +932,10 @@ def _progreso(self, hechos: int, total: int) -> None: self._inicio = (ahora, total) # cada módulo cuenta desde cero self.barra.setMaximum(max(total, 1)) self.barra.setValue(hechos) - texto = f"Traduciendo{f' {self._fase}' if self._fase else ''}… {_miles(hechos)} de {_miles(total)}" + if self._accion == "cobertura": + texto = f"Leyendo los mods… {_miles(hechos)} de {_miles(total)}" + else: + texto = f"Traduciendo{f' {self._fase}' if self._fase else ''}… {_miles(hechos)} de {_miles(total)}" pasado = ahora - self._inicio[0] if 0 < hechos < total and pasado > 3: texto += f" · quedan {_restante(pasado / hechos * (total - hechos))}" @@ -956,6 +968,9 @@ def _terminar(self, resultado: object) -> None: self.estado.setText("¡Listo! Abre el juego: ya está en español.") self.aviso.mostrar("Traducción terminada. Abre Minecraft para verla.", "exito") self._marcar_traducida() + elif accion == "cobertura": + self._estado = self._antes # no cambia nada en la instancia: el botón principal sigue como estaba + self._mostrar_cobertura(cast("list[Hallazgo]", resultado)) else: self._olvidar_analisis() # el texto volvió al original: lo contado antes ya no vale n = len(resultado) if isinstance(resultado, list) else 0 # pyright: ignore[reportUnknownArgumentType] @@ -964,6 +979,20 @@ def _terminar(self, resultado: object) -> None: self._al_cambiar_opciones() self.escribir(self.estado.text()) + def _mostrar_cobertura(self, hallazgos: list[Hallazgo]) -> None: + if not hallazgos: + self.estado.setText("No encontré texto que se quede en inglés.") + return + self.estado.setText(f"Quedan {len(hallazgos)} lugares con texto en inglés: el detalle está en Detalles.") + self.escribir("Texto que se queda en inglés aunque traduzcas todo:") + for h in hallazgos: + self.escribir(f"· [{h.categoria}] {h}") + self.escribir( + "Lo de «java» está escrito en el código del mod: un resource pack no lo alcanza. " + "Lo de «script» puede ser lógica y no un mensaje." + ) + self.detalles.fijar_abierta(True) + def _marcar_traducida(self) -> None: carpeta = self.carpeta() tarjeta = self.lista.actual() diff --git a/tests/test_cobertura.py b/tests/test_cobertura.py new file mode 100644 index 0000000..dea9955 --- /dev/null +++ b/tests/test_cobertura.py @@ -0,0 +1,73 @@ +"""Informe de lo que queda en inglés: frases de scripts sin elegir y texto escrito en el código de los mods.""" + +import struct +from pathlib import Path + +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto +from traductor_es.modulos.cobertura import constantes_utf8, revisar_cobertura +from traductor_es.motores.simulador import Simulador +from traductor_es.proceso import Proceso +from traductor_es.salida.copias import Respaldo + +from . import fabrica + +SCRIPT = """PlayerEvents.loggedIn(event => { + event.player.tell('Welcome to the village') + let aviso = 'The market is closed today' + console.log('This should be a debug message here') + if (event.player.name == 'Some Long Name Here') return +}) +ServerEvents.recipes(event => { event.shapeless('minecraft:stone', ['Not A Real Recipe Name']) }) +""" + + +def _clase(*constantes: str) -> bytes: + """Un .class mínimo: solo la cabecera y la tabla de constantes con esas cadenas (más un entero entre medias).""" + cuerpo = b"".join(b"\x01" + struct.pack(">H", len(c.encode())) + c.encode() for c in constantes[:1]) + cuerpo += b"\x03\x00\x00\x00\x07" # un Integer: el lector tiene que saltarlo bien + cuerpo += b"".join(b"\x01" + struct.pack(">H", len(c.encode())) + c.encode() for c in constantes[1:]) + cantidad = len(constantes) + 2 # las casillas empiezan en 1 + return b"\xca\xfe\xba\xbe" + struct.pack(">HHH", 0, 52, cantidad) + cuerpo + + +def test_lee_la_tabla_de_constantes_de_un_class() -> None: + assert constantes_utf8(_clase("literal", "You can't do that here!", "ñandú")) == [ + "literal", "You can't do that here!", "ñandú", + ] # fmt: skip + assert constantes_utf8(b"no es un class") == [] + assert constantes_utf8(_clase("literal")[:-3]) == [] # truncado: no se inventa nada + + +def test_cobertura_de_scripts_y_java(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + (juego / "kubejs" / "server_scripts").mkdir(parents=True) + (juego / "kubejs" / "server_scripts" / "a.js").write_text(SCRIPT, encoding="utf-8") + fabrica.jar( + juego / "mods" / "m.jar", + { + "com/m/Chat.class": _clase("literal", "You can't use this item here!", "Failed to load the config file"), + "com/m/Fabric.class": _clase("method_43470", "Click the block to open the menu"), + "com/m/Otra.class": _clase("nada", "Another English sentence here"), # no construye texto + "com/m/Spanish.class": _clase("literal", "Alacena de abedul sin corteza"), # ya está en español + }, + ) + with Instancia(juego) as inst: + sin_java = revisar_cobertura(Contexto(inst, Respaldo(juego))) + con_java = revisar_cobertura(Contexto(inst, Respaldo(juego)), java=True) + assert [(h.categoria, h.donde) for h in sin_java] == [("script", "kubejs/server_scripts/a.js")] + assert sin_java[0].cantidad == 2 # `tell` ya se traduce; recetas y comparaciones no cuentan + assert "This should be a debug message here" in sin_java[0].ejemplos + de_java = [h for h in con_java if h.categoria == "java"] + assert len(de_java) == 1 + assert set(de_java[0].ejemplos) == {"You can't use this item here!", "Click the block to open the menu"} + + +def test_analizar_avisa_sin_detener_el_analisis(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + (juego / "kubejs" / "server_scripts").mkdir(parents=True) + (juego / "kubejs" / "server_scripts" / "a.js").write_text(SCRIPT, encoding="utf-8") + with Proceso(juego, Simulador(), grupos=["scripts"]) as p: + informe = p.analizar() + assert any("2 frases de 1 scripts" in a for a in informe.avisos) + assert informe.modulos and informe.modulos[0].grupo == "scripts" diff --git a/tests/test_comandos_y_datos.py b/tests/test_comandos_y_datos.py index d962abb..0884396 100644 --- a/tests/test_comandos_y_datos.py +++ b/tests/test_comandos_y_datos.py @@ -9,7 +9,7 @@ from traductor_es.formatos import comandos from traductor_es.fuentes.instancia import Instancia from traductor_es.modulos.base import Contexto, traducir_modulo -from traductor_es.modulos.datos import DatosKubeJS +from traductor_es.modulos.datos import Datos from traductor_es.modulos.scripts import ScriptsKubeJS from traductor_es.motores.simulador import Simulador from traductor_es.nucleo.glosario import Glosario @@ -65,14 +65,14 @@ def test_runcommand_traduce_solo_el_mensaje(tmp_path: Path, memoria: Memoria) -> with Instancia(juego) as inst: ctx = Contexto(inst, Respaldo(juego)) resumen = traducir_modulo(ScriptsKubeJS(ctx), Traductor(Simulador(), memoria, Glosario())) - assert resumen.traducidos == 4 and resumen.pendientes == 0 + assert resumen.traducidos == 5 and resumen.pendientes == 0 nuevo = ruta.read_text(encoding="utf-8") assert """tellraw @a {"text": "«ES» Welcome to the village!", "color": "gold"}""" in nuevo assert r"""title @a actionbar {\"text\": \"«ES» Market opens soon\"}""" in nuevo assert "say «ES» The festival begins" in nuevo assert "${event.player.username}" in nuevo and "execute as @a run tellraw @s " in nuevo assert "times 10 70 20" in nuevo and "give @p minecraft:stone 2" in nuevo - assert 'tellraw @a {text:"snbt not touched"}' in nuevo + assert 'tellraw @a {text:"«ES» snbt not touched"}' in nuevo # SNBT (1.21.5+): se conserva el formato ScriptsKubeJS(ctx).restaurar() assert ruta.read_text(encoding="utf-8") == SCRIPT @@ -106,7 +106,7 @@ def test_logros_y_funciones(tmp_path: Path, memoria: Memoria) -> None: (funciones / "open.mcfunction").write_bytes(FUNCION.encode()) with Instancia(juego) as inst: ctx = Contexto(inst, Respaldo(juego)) - modulo = DatosKubeJS(ctx) + modulo = Datos(ctx) assert modulo.detectar() and modulo.describir() == "2 archivos de datos" resumen = traducir_modulo(modulo, Traductor(Simulador(), memoria, Glosario())) assert resumen.traducidos == 4 and resumen.pendientes == 0 and len(resumen.archivos) == 2 diff --git a/tests/test_corpus_datos.py b/tests/test_corpus_datos.py new file mode 100644 index 0000000..0ef7aba --- /dev/null +++ b/tests/test_corpus_datos.py @@ -0,0 +1,135 @@ +"""Datos de texto reales (modpacks públicos de .descargas y tu FC4): siempre sobre copias en una carpeta temporal. + +Para cada modpack: lo traducido sigue siendo válido (JSON, zips, JS), repetir no cambia nada y restaurar devuelve +los bytes originales. Los modpacks se bajan con `uv run python scripts/descargar_corpus.py`. +""" + +from __future__ import annotations + +import io +import json +import os +import shutil +import subprocess +import zipfile +from collections.abc import Iterator +from pathlib import Path +from typing import Any, cast + +import pytest + +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.cobertura import revisar_cobertura +from traductor_es.modulos.datos import Datos, buscar_archivos, buscar_zips +from traductor_es.modulos.scripts import ScriptsKubeJS +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.salida.copias import Respaldo + +from . import fabrica + +pytestmark = pytest.mark.corpus + +RAIZ = Path(__file__).resolve().parent.parent +DESCARGAS = RAIZ / ".descargas" +FC4 = ( + Path(os.environ.get("APPDATA", Path.home())) / "PrismLauncher" / "instances" / "Farming Crossing 4(1)" / "minecraft" +) +#: Lo que se copia de cada modpack: donde viven los datos de texto y los scripts. +CARPETAS = ("kubejs", "global_packs", "datapacks", "config/openloader", "config/paxi", "resourcepacks") +LOADERS = {"forge": "forge", "neoforge": "neoforge", "fabric-loader": "fabric", "quilt-loader": "quilt"} + + +def _packs() -> list[str]: + return sorted(p.parent.name for p in DESCARGAS.glob("*/resumen.json")) + + +def _version_y_loader(slug: str) -> tuple[str, str | None]: + resumen = cast("dict[str, Any]", json.loads((DESCARGAS / slug / "resumen.json").read_text(encoding="utf-8"))) + deps = cast("dict[str, str]", resumen["dependencias"]) + return deps["minecraft"], next((v for k, v in LOADERS.items() if k in deps), None) + + +def _copiar(origen: Path, juego: Path) -> None: + for c in CARPETAS: + if (origen / c).is_dir(): + shutil.copytree(origen / c, juego / c, dirs_exist_ok=True) + + +def _todos(juego: Path) -> dict[str, bytes]: + return {p.relative_to(juego).as_posix(): p.read_bytes() for p in juego.rglob("*") if p.is_file()} + + +def _node() -> str | None: + return shutil.which("node") + + +def _js_valido(ruta: Path) -> bool: + node = _node() + assert node + return subprocess.run([node, "--check", str(ruta)], capture_output=True, check=False).returncode == 0 + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +def _verificar(juego: Path, antes: dict[str, bytes]) -> int: + """Lo cambiado sigue siendo válido. Devuelve cuántos archivos cambiaron.""" + cambiados = 0 + for rel, datos in _todos(juego).items(): + if rel.startswith("traductor_es/") or antes.get(rel) == datos: + continue + cambiados += 1 + if rel.endswith(".json"): + json.loads(datos.decode("utf-8-sig")) + elif rel.endswith(".mcfunction"): + assert datos.count(b"\n") == antes[rel].count(b"\n"), rel # ni se pierden ni se parten líneas + elif rel.endswith(".zip"): + with zipfile.ZipFile(io.BytesIO(datos)) as z, zipfile.ZipFile(io.BytesIO(antes[rel])) as o: + assert z.testzip() is None and z.namelist() == o.namelist(), rel + elif rel.endswith(".js") and _node() and _js_valido(juego / rel) is False: + ruta_antes = juego / rel + ruta_antes.write_bytes(antes[rel]) # ¿ya era inválido antes? + assert not _js_valido(ruta_antes), f"{rel} era JavaScript válido y la traducción lo rompió" + ruta_antes.write_bytes(datos) + return cambiados + + +def _probar(origen: Path, version: str, loader: str | None, tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, version, loader) + _copiar(origen, juego) + antes = _todos(juego) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + if not (buscar_archivos(juego) or buscar_zips(juego) or ScriptsKubeJS(ctx).detectar()): + pytest.skip("Este modpack no trae datos de texto ni scripts") + traductor = Traductor(Simulador(), memoria, Glosario()) + for modulo in (Datos(ctx), ScriptsKubeJS(ctx)): + traducir_modulo(modulo, traductor) + assert _verificar(juego, antes) >= 0 + despues = _todos(juego) + for modulo in (Datos(ctx), ScriptsKubeJS(ctx)): + traducir_modulo(modulo, traductor) + assert _todos(juego) == despues # repetir no cambia nada + revisar_cobertura(ctx) # no debe fallar con ningún modpack + Datos(ctx).restaurar() + ScriptsKubeJS(ctx).restaurar() + restaurado = {k: v for k, v in _todos(juego).items() if not k.startswith("traductor_es/")} + assert restaurado == {k: v for k, v in antes.items() if not k.startswith("traductor_es/")} + + +@pytest.mark.parametrize("slug", _packs()) +def test_datos_de_modpacks_descargados(slug: str, tmp_path: Path, memoria: Memoria) -> None: + version, loader = _version_y_loader(slug) + _probar(DESCARGAS / slug, version, loader, tmp_path, memoria) + + +@pytest.mark.skipif(not (FC4 / "kubejs").is_dir(), reason="Falta FC4") +def test_datos_de_fc4(tmp_path: Path, memoria: Memoria) -> None: + _probar(FC4, "1.20.1", "forge", tmp_path, memoria) diff --git a/tests/test_datos_generales.py b/tests/test_datos_generales.py new file mode 100644 index 0000000..bcd7f74 --- /dev/null +++ b/tests/test_datos_generales.py @@ -0,0 +1,184 @@ +"""Datos de texto en cualquier sitio y versión: se descubren por su estructura, no por la carpeta de un modpack.""" + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.formatos import componente +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.datos import Datos, buscar_archivos, clasificar +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.salida.copias import Respaldo + +from . import fabrica + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +@pytest.mark.parametrize( + ("cuerpo", "esperado"), + [ + ('{"text":"Hi","color":"red"}', ["Hi"]), + ('["",{"text":"Hi "},{"text":"there"}]', ["Hi ", "there"]), + ('"Hi"', ["Hi"]), + ('{text:"Hi",color:"red"}', ["Hi"]), # SNBT, desde 1.21.5 + ('["",{text:"Aa"},{text:"Bb",extra:["Cc"]}]', ["Aa", "Bb", "Cc"]), + ('{translate:"chat.type.text",with:["Steve"],color:"red"}', []), + ('{"score":{"name":"@p","objective":"x"}}', []), + ("esto no es un componente", None), + ], +) +def test_componente_json_y_snbt(cuerpo: str, esperado: list[str] | None) -> None: + assert componente.unidades(cuerpo) == esperado + + +def test_componente_snbt_conserva_el_formato() -> None: + cuerpo = '{text:"Hi", color:"red", hover_event:{action:"show_text", value:"Hola"}}' + nuevo = componente.reconstruir(cuerpo, {"Hi": "Hola, amigo", "Hola": "no debe usarse"}) + assert nuevo == '{text:"Hola, amigo", color:"red", hover_event:{action:"show_text", value:"Hola"}}' + + +@pytest.mark.parametrize( + ("ruta", "formato"), + [ + ("kubejs/data/a/functions/x.mcfunction", "función"), + ("global_packs/required_data/p/data/a/function/sub/x.mcfunction", "función"), # 1.21: «function» + ("datapacks/p/data/a/advancement/x.json", "logro"), + ("saves/w/datapacks/p/data/a/advancements/b/x.json", "logro"), + ("kubejs/assets/a/tips/t.json", "tip"), + ("resourcepacks/p/assets/a/tips/t.json", "tip"), + ("kubejs/data/tipsmod/tips/t.json", "tip"), + ("kubejs/data/a/recipes/x.json", None), + ("kubejs/data/a/functions/x.txt", None), + ("config/functions/x.mcfunction", None), # sin «data»: no es un datapack + ("assets/a/lang/en_us.json", None), + ], +) +def test_clasificar_por_estructura(ruta: str, formato: str | None) -> None: + resultado = clasificar(Path(ruta)) + assert (resultado.nombre if resultado else None) == formato + + +def _escribir(base: Path, ruta: str, texto: str) -> Path: + p = base / ruta + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(texto, encoding="utf-8", newline="") + return p + + +def test_descubre_en_cualquier_carpeta_e_ignora_lo_que_no_toca(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fn = 'tellraw @a {"text":"Hi"}\n' + buenos = [ + "kubejs/data/a/functions/x.mcfunction", + "global_packs/required_data/p/data/a/functions/x.mcfunction", + "datapacks/p/data/a/functions/x.mcfunction", + "saves/Mundo/datapacks/p/data/a/functions/x.mcfunction", + "config/openloader/data/p/data/a/functions/x.mcfunction", + ] + malos = [ + "mods/m/data/a/functions/x.mcfunction", # los jars no se tocan + "saves/Mundo/region/data/a/functions/x.mcfunction", # de un mundo solo importan sus datapacks + "resourcepacks/Traduccion_ES/data/a/functions/x.mcfunction", # lo que escribe la app + "traductor_es/respaldo/kubejs/data/a/functions/x.mcfunction", # las copias de seguridad + "logs/data/a/functions/x.mcfunction", + ] + for r in (*buenos, *malos): + _escribir(juego, r, fn) + encontrados = {p.relative_to(juego).as_posix() for p, _ in buscar_archivos(juego)} + assert encontrados == set(buenos) + + +def test_traduce_logros_snbt_funciones_y_tips(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.21.5") + fn = _escribir( + juego, "datapacks/p/data/a/function/x.mcfunction", 'tellraw @a {text:"Hello",color:"gold"}\nsay Bye\n' + ) + logro = _escribir( + juego, + "kubejs/data/a/advancement/x.json", + json.dumps({"display": {"title": "Big Win", "description": {"translate": "k.d"}}, "criteria": {}}), + ) + tip1 = _escribir(juego, "kubejs/assets/a/tips/t1.json", json.dumps({"tip": {"text": "Eat your greens."}})) + tip2 = _escribir( + juego, "kubejs/data/tipsmod/tips/t2.json", json.dumps({"tips": [{"text": "Sleep in beds.", "category": "x"}]}) + ) + negativo = _escribir(juego, "kubejs/data/a/recipes/r.json", json.dumps({"result": {"item": "Big Win"}})) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + modulo = Datos(ctx) + assert modulo.describir() == "4 archivos de datos" + resumen = traducir_modulo(modulo, Traductor(Simulador(), memoria, Glosario())) + assert resumen.pendientes == 0 and len(resumen.archivos) == 4 + assert fn.read_text("utf-8") == 'tellraw @a {text:"«ES» Hello",color:"gold"}\nsay «ES» Bye\n' + assert json.loads(logro.read_text("utf-8"))["display"] == { + "title": "«ES» Big Win", + "description": {"translate": "k.d"}, + } + assert json.loads(tip1.read_text("utf-8")) == {"tip": {"text": "«ES» Eat your greens."}} + assert json.loads(tip2.read_text("utf-8"))["tips"] == [{"text": "«ES» Sleep in beds.", "category": "x"}] + assert "«ES»" not in negativo.read_text("utf-8") + # Repetir no cambia nada, y restaurar devuelve los originales + antes = {p: p.read_bytes() for p in (fn, logro, tip1, tip2)} + traducir_modulo(Datos(ctx), Traductor(Simulador(), memoria, Glosario())) + assert {p: p.read_bytes() for p in antes} == antes + assert len(Datos(ctx).restaurar()) == 4 + assert fn.read_text("utf-8") == 'tellraw @a {text:"Hello",color:"gold"}\nsay Bye\n' + assert json.loads(tip1.read_text("utf-8")) == {"tip": {"text": "Eat your greens."}} + + +def test_archivo_ilegible_se_deja_igual(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + roto = _escribir(juego, "kubejs/data/a/advancements/roto.json", '{"display": {"title": "Hi",}') + avisos: list[str] = [] + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), registro=avisos.append) + traducir_modulo(Datos(ctx), Traductor(Simulador(), memoria, Glosario())) + assert roto.read_text("utf-8") == '{"display": {"title": "Hi",}' and any("roto.json" in a for a in avisos) + + +def test_datapack_comprimido_se_traduce_y_se_restaura(tmp_path: Path, memoria: Memoria) -> None: + import io + import zipfile + + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "global_packs" / "required_data" / "mi pack v1.zip" + ruta.parent.mkdir(parents=True) + with zipfile.ZipFile(ruta, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("pack.mcmeta", '{"pack": {"pack_format": 15, "description": "Hello pack"}}') + z.writestr("data/a/functions/x.mcfunction", 'tellraw @a {"text":"Hello there"}\n') + z.writestr("data/a/recipes/r.json", '{"result": "Hello there"}') + z.writestr("data/a/advancements/g.json", json.dumps({"display": {"title": "Big Win"}})) + original = ruta.read_bytes() + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + modulo = Datos(ctx) + assert modulo.describir() == "2 archivos de datos" + resumen = traducir_modulo(modulo, Traductor(Simulador(), memoria, Glosario())) + assert resumen.traducidos == 2 and resumen.archivos == [ruta] + with zipfile.ZipFile(io.BytesIO(ruta.read_bytes())) as z: + assert z.testzip() is None + assert z.namelist() == [ + "pack.mcmeta", + "data/a/functions/x.mcfunction", + "data/a/recipes/r.json", + "data/a/advancements/g.json", + ] + assert "«ES» Hello there" in z.read("data/a/functions/x.mcfunction").decode() + assert json.loads(z.read("data/a/advancements/g.json"))["display"]["title"] == "«ES» Big Win" + assert "«ES»" not in z.read("data/a/recipes/r.json").decode() + z.read("pack.mcmeta").decode() + despues = ruta.read_bytes() + traducir_modulo(Datos(ctx), Traductor(Simulador(), memoria, Glosario())) + assert ruta.read_bytes() == despues # repetir no cambia nada + assert Datos(ctx).restaurar() == [ruta] + assert ruta.read_bytes() == original diff --git a/tests/test_ui.py b/tests/test_ui.py index 2f86880..779445d 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -465,3 +465,19 @@ def romper(_self: object) -> list[object]: _analizar(qtbot, v) assert not v.grupos["scripts"].incluido and "No se pudo leer" in v.grupos["scripts"].e_detalle.text() assert v.b_principal.isEnabled() + + +def test_que_queda_en_ingles_solo_lee(qtbot: QtBot, tmp_path: Path) -> None: + juego = _modpack(tmp_path) + ruta = juego / "kubejs" / "server_scripts" / "s.js" + ruta.write_text( + ruta.read_text("utf-8") + "\nlet nota = 'This sentence looks like a message but is not used'\n", + encoding="utf-8", + ) + antes = ruta.read_bytes() + v = _ventana(qtbot, juego) + v.a_cobertura.trigger() + qtbot.waitUntil(lambda: v._hilo is None and "lugares" in v.estado.text(), timeout=30000) # pyright: ignore[reportPrivateUsage] + assert "This sentence looks like a message" in v.log.toPlainText() + assert ruta.read_bytes() == antes and not (juego / "resourcepacks").exists() # solo lee + assert v.b_principal.isEnabled() # el botón principal sigue disponible From 3f4d271747f7febd1d8780bbbdaf97ca060457a2 Mon Sep 17 00:00:00 2001 From: Cristian Date: Fri, 6 Nov 2026 01:16:55 -0500 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20endurecer=20lectura,=20escritura=20y?= =?UTF-8?q?=20errores=20tras=20la=20verificaci=C3=B3n=20por=20secciones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Memoria: cierra la conexión si el archivo está dañado y el proceso dice cuál es. - NBT: un gzip dañado o truncado se convierte siempre en ValueError. - Carpeta de recursos: rechaza rutas que salen de su raíz. - Resource pack: un espacio de nombres imposible o reservado de Windows se salta y se avisa, en vez de cancelar toda la traducción (mods y libros). - options.txt: conserva los bytes que no son UTF-8. - Pruebas de casos límite por sección, línea de comandos real y ventana. --- src/traductor_es/formatos/nbt.py | 5 +- src/traductor_es/fuentes/contenedores.py | 5 +- src/traductor_es/modulos/libros.py | 50 ++-- src/traductor_es/modulos/mods.py | 8 +- src/traductor_es/nucleo/memoria.py | 7 + src/traductor_es/proceso.py | 16 +- src/traductor_es/salida/opciones.py | 4 +- src/traductor_es/salida/resource_pack.py | 12 +- tests/test_ui.py | 39 ++++ tests/test_verificacion_formatos.py | 205 +++++++++++++++++ tests/test_verificacion_fuentes.py | 180 +++++++++++++++ tests/test_verificacion_modulos.py | 276 +++++++++++++++++++++++ tests/test_verificacion_motores.py | 191 ++++++++++++++++ tests/test_verificacion_nucleo.py | 191 ++++++++++++++++ tests/test_verificacion_salida.py | 155 +++++++++++++ tests/test_verificacion_validacion.py | 87 +++++++ 16 files changed, 1401 insertions(+), 30 deletions(-) create mode 100644 tests/test_verificacion_formatos.py create mode 100644 tests/test_verificacion_fuentes.py create mode 100644 tests/test_verificacion_modulos.py create mode 100644 tests/test_verificacion_motores.py create mode 100644 tests/test_verificacion_nucleo.py create mode 100644 tests/test_verificacion_salida.py create mode 100644 tests/test_verificacion_validacion.py diff --git a/src/traductor_es/formatos/nbt.py b/src/traductor_es/formatos/nbt.py index 60acad8..8123e4d 100644 --- a/src/traductor_es/formatos/nbt.py +++ b/src/traductor_es/formatos/nbt.py @@ -137,7 +137,10 @@ def valor(self, tipo: int) -> Valor: def descomprimir(datos: bytes) -> tuple[bytes, Compresion]: if datos[:2] == b"\x1f\x8b": - return gzip.decompress(datos), "gzip" + try: + return gzip.decompress(datos), "gzip" + except (EOFError, OSError, zlib.error) as e: # gzip truncado o dañado: un solo tipo de error para quien llama + raise ValueError(f"Archivo NBT comprimido dañado: {e}") from e if datos[:1] == b"\x78": try: return zlib.decompress(datos), "zlib" diff --git a/src/traductor_es/fuentes/contenedores.py b/src/traductor_es/fuentes/contenedores.py index 5cd3232..b1358af 100644 --- a/src/traductor_es/fuentes/contenedores.py +++ b/src/traductor_es/fuentes/contenedores.py @@ -73,7 +73,10 @@ def nombres(self) -> list[str]: def leer(self, ruta: str) -> bytes: if not ruta.startswith(self._prefijo): raise KeyError(ruta) - return (self.raiz / ruta[len(self._prefijo) :]).read_bytes() + destino = (self.raiz / ruta[len(self._prefijo) :]).resolve() + if not destino.is_relative_to(self.raiz.resolve()): # «../» o una ruta absoluta: nunca se sale de la carpeta + raise KeyError(ruta) + return destino.read_bytes() def cerrar(self) -> None: return None diff --git a/src/traductor_es/modulos/libros.py b/src/traductor_es/modulos/libros.py index fa3dc6a..fa4a2c6 100644 --- a/src/traductor_es/modulos/libros.py +++ b/src/traductor_es/modulos/libros.py @@ -255,30 +255,36 @@ def generar(trad: Mapping[str, str]) -> tuple[bytes, int, int]: def escribir(self, traducciones: Mapping[str, str]) -> Resumen: resumen = Resumen() for a in self._archivos: - generado: bytes | None = None - for variante in self.ctx.variantes: - destino = a.destino(variante) - if variante in a.existentes: - # Ya hay traducción del autor: si el pack tiene una copia vieja en esa ruta, que sea la oficial - if not a.externo and self._en_pack(destino) not in (None, a.existentes[variante]): - resumen.archivos.append(self.ctx.pack_listo().escribir(destino, a.existentes[variante])) - continue - copia = next((a.existentes[v] for v in (*_PREFERIDAS, *a.existentes) if v in a.existentes), None) - if copia is not None: - contenido = copia - else: - if a.generar is None: - continue - if generado is None: - generado, hechos, faltan = a.generar(traducciones) - resumen.traducidos += hechos - resumen.pendientes += faltan - if hechos == 0: - break # nada traducido: el juego ya usa el inglés por su cuenta - contenido = generado - resumen.archivos.append(self._escribir(a, destino, contenido)) + try: + self._escribir_libro(a, traducciones, resumen) + except ValueError as e: # una ruta imposible de guardar en el pack: ese libro se deja en inglés + self.ctx.registro(f"No pude guardar un libro: {e}") return resumen + def _escribir_libro(self, a: _Archivo, traducciones: Mapping[str, str], resumen: Resumen) -> None: + generado: bytes | None = None + for variante in self.ctx.variantes: + destino = a.destino(variante) + if variante in a.existentes: + # Ya hay traducción del autor: si el pack tiene una copia vieja en esa ruta, que sea la oficial + if not a.externo and self._en_pack(destino) not in (None, a.existentes[variante]): + resumen.archivos.append(self.ctx.pack_listo().escribir(destino, a.existentes[variante])) + continue + copia = next((a.existentes[v] for v in (*_PREFERIDAS, *a.existentes) if v in a.existentes), None) + if copia is not None: + contenido = copia + else: + if a.generar is None: + continue + if generado is None: + generado, hechos, faltan = a.generar(traducciones) + resumen.traducidos += hechos + resumen.pendientes += faltan + if hechos == 0: + break # nada traducido: el juego ya usa el inglés por su cuenta + contenido = generado + resumen.archivos.append(self._escribir(a, destino, contenido)) + def _en_pack(self, relativa: str) -> bytes | None: pack = self.ctx.pack ruta = (pack.raiz if pack else self.ctx.instancia.juego / "resourcepacks" / "Traduccion_ES") / relativa diff --git a/src/traductor_es/modulos/mods.py b/src/traductor_es/modulos/mods.py index 23da12d..f9a10bb 100644 --- a/src/traductor_es/modulos/mods.py +++ b/src/traductor_es/modulos/mods.py @@ -82,7 +82,13 @@ def escribir(self, traducciones: Mapping[str, str]) -> Resumen: return resumen # nada que escribir y nada viejo que vaciar: no se crea un pack vacío pack = self.ctx.pack_listo() for (namespace, variante), textos in sorted(por_archivo.items()): - resumen.archivos.append(pack.escribir_lang(namespace, variante, textos)) + try: + resumen.archivos.append(pack.escribir_lang(namespace, variante, textos)) + except ValueError: # un mod con un espacio de nombres imposible de guardar no detiene a los demás + self.ctx.registro( + f"El espacio de nombres «{namespace}» no se puede guardar en el pack: se deja en inglés" + ) + resumen.pendientes += len(textos) pack.vaciar_idiomas_viejos() return resumen diff --git a/src/traductor_es/nucleo/memoria.py b/src/traductor_es/nucleo/memoria.py index 5a76860..611470b 100644 --- a/src/traductor_es/nucleo/memoria.py +++ b/src/traductor_es/nucleo/memoria.py @@ -45,6 +45,13 @@ def __init__(self, ruta: Path | str) -> None: self.ruta.parent.mkdir(parents=True, exist_ok=True) self._lock = threading.Lock() self._db = sqlite3.connect(str(ruta), check_same_thread=False) + try: + self._crear_tablas() + except sqlite3.Error: # archivo dañado o bloqueado: no se deja la conexión abierta + self._db.close() + raise + + def _crear_tablas(self) -> None: self._db.execute( """CREATE TABLE IF NOT EXISTS traducciones ( origen TEXT NOT NULL, dialecto TEXT NOT NULL, ambito TEXT NOT NULL, diff --git a/src/traductor_es/proceso.py b/src/traductor_es/proceso.py index e7b89fc..fd85f4d 100644 --- a/src/traductor_es/proceso.py +++ b/src/traductor_es/proceso.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sqlite3 import threading from collections.abc import Callable, Sequence from dataclasses import dataclass, field @@ -16,7 +17,7 @@ from .modulos.cobertura import Hallazgo, hallazgos_de_scripts, revisar_cobertura from .modulos.mods import es_nombre from .motores import fabrica -from .motores.base import Motor +from .motores.base import ErrorFatal, Motor from .motores.compatible import Compatible from .motores.gemini import PRECIOS, Gemini from .nucleo.dialectos import DIALECTO_POR_DEFECTO, VARIANTES, nombre_archivo_lang @@ -119,7 +120,7 @@ def __init__( self.registro = registro self.grupos = [g for g in GRUPOS if g in grupos] self._memoria_propia = memoria is None - self.memoria = memoria or Memoria(configuracion.ruta_memoria()) + self.memoria = memoria or self._abrir_memoria() self.glosario = Glosario() self.ctx = Contexto( self.instancia, Respaldo(self.instancia.juego), dialecto, VARIANTES, registro, PackSalida(self.instancia) @@ -166,6 +167,17 @@ def modulos(self) -> list[tuple[str, Modulo]]: return [(g, m) for g, m in lista if m.detectar()] # ------------------------------------------------------------------ acciones + def _abrir_memoria(self) -> Memoria: + """La memoria de traducciones del usuario. Si el archivo está dañado, dice cuál es y no falla a medias.""" + ruta = configuracion.ruta_memoria() + try: + return Memoria(ruta) + except sqlite3.Error as e: + self.instancia.cerrar() + raise ErrorFatal( + f"La memoria de traducciones está dañada ({e}). Muévela o bórrala y vuelve a intentarlo: {ruta}" + ) from e + def que_queda(self, java: bool = False, progreso: Callable[[int, int], None] | None = None) -> list[Hallazgo]: """Lo que quedaría en inglés. Solo lee. `java`: también el texto escrito en el código de los mods (tarda).""" return revisar_cobertura(self.ctx, java, progreso or (lambda _a, _b: None)) diff --git a/src/traductor_es/salida/opciones.py b/src/traductor_es/salida/opciones.py index bc2425d..f08f931 100644 --- a/src/traductor_es/salida/opciones.py +++ b/src/traductor_es/salida/opciones.py @@ -53,7 +53,7 @@ def activar(juego: Path, nombre_pack: str, idioma: str | None) -> list[str]: return [] crudo = ruta.read_bytes() salto = "\r\n" if b"\r\n" in crudo else "\n" - lineas = crudo.decode("utf-8", errors="replace").splitlines() + lineas = crudo.decode("utf-8", errors="surrogateescape").splitlines() # lo que no es UTF-8 se conserva igual cambios: list[str] = [] vio_packs = vio_idioma = False for i, linea in enumerate(lineas): @@ -78,6 +78,6 @@ def activar(juego: Path, nombre_pack: str, idioma: str | None) -> list[str]: cambios.append(f"idioma del juego: {idioma}") if cambios: temporal = ruta.with_name("options.txt.tmp") - temporal.write_bytes((salto.join(lineas) + salto).encode("utf-8")) + temporal.write_bytes((salto.join(lineas) + salto).encode("utf-8", errors="surrogateescape")) temporal.replace(ruta) return cambios diff --git a/src/traductor_es/salida/resource_pack.py b/src/traductor_es/salida/resource_pack.py index f95e241..0ae8223 100644 --- a/src/traductor_es/salida/resource_pack.py +++ b/src/traductor_es/salida/resource_pack.py @@ -18,6 +18,16 @@ NOMBRE = "Traduccion_ES" #: Un segmento de ruta seguro: sin barras invertidas, dos puntos ni caracteres que Windows no admite. _PARTE_VALIDA = re.compile(r"[^\\/:*?\"<>|\x00-\x1f]+") +#: Nombres de dispositivo de Windows (CON, NUL, COM1…): no se pueden usar como carpeta ni archivo. +_RESERVADOS = frozenset( + {"CON", "PRN", "AUX", "NUL", *(f"COM{n}" for n in range(1, 10)), *(f"LPT{n}" for n in range(1, 10))} +) + + +def _parte_segura(parte: str) -> bool: + if not _PARTE_VALIDA.fullmatch(parte) or parte in (".", "..") or parte != parte.rstrip(". "): + return False # Windows quita los puntos y espacios del final: «a.» y «a» serían la misma carpeta + return parte.split(".", maxsplit=1)[0].upper() not in _RESERVADOS class PackSalida: @@ -30,7 +40,7 @@ def __init__(self, instancia: Instancia, nombre: str = NOMBRE) -> None: def _ruta(self, relativa: str) -> Path: """Ruta dentro del pack. Rechaza todo lo que pueda salir de él (también trucos de Windows).""" partes = relativa.split("/") - if not relativa or any(not _PARTE_VALIDA.fullmatch(p) or p in (".", "..") for p in partes): + if not relativa or any(not _parte_segura(p) for p in partes): raise ValueError(f"Ruta fuera del pack: {relativa!r}") ruta = self.raiz.joinpath(*partes) if not ruta.resolve().is_relative_to(self.raiz.resolve()): diff --git a/tests/test_ui.py b/tests/test_ui.py index 779445d..f08bbdb 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -481,3 +481,42 @@ def test_que_queda_en_ingles_solo_lee(qtbot: QtBot, tmp_path: Path) -> None: assert "This sentence looks like a message" in v.log.toPlainText() assert ruta.read_bytes() == antes and not (juego / "resourcepacks").exists() # solo lee assert v.b_principal.isEnabled() # el botón principal sigue disponible + + +def test_sin_instancia_elegida_no_lanza_nada(qtbot: QtBot, tmp_path: Path) -> None: + v = Ventana(buscar_instancias=lambda: [], fabrica_motor=siempre(Simulador())) + qtbot.addWidget(v) + v.lanzar("analizar") + v.a_cobertura.trigger() + assert v._hilo is None and v.carpeta() is None # pyright: ignore[reportPrivateUsage] + + +def test_instancia_sin_version_detectable_se_recupera(qtbot: QtBot, tmp_path: Path) -> None: + juego = tmp_path / "Pack" / "minecraft" + (juego / "mods").mkdir(parents=True) # sin lanzador, sin log y sin mods con versión + v = _ventana(qtbot, juego) + v.b_principal.click() + qtbot.waitUntil(lambda: v._hilo is None and "Se detuvo" in v.estado.text(), timeout=30000) # pyright: ignore[reportPrivateUsage] + assert v.b_principal.isEnabled() and "Traceback" not in v.log.toPlainText() + + +def test_cobertura_sin_nada_que_decir(qtbot: QtBot, tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fabrica.jar(juego / "mods" / "m.jar", fabrica.lang("m", "en_us", {"item.m.a": "Iron Plate"})) + v = _ventana(qtbot, juego) + v.a_cobertura.trigger() + qtbot.waitUntil(lambda: v._hilo is None and "No encontré" in v.estado.text(), timeout=30000) # pyright: ignore[reportPrivateUsage] + + +def test_soltar_rutas_que_no_son_carpetas(qtbot: QtBot, tmp_path: Path) -> None: + from PySide6.QtCore import QMimeData, QUrl + + archivo = tmp_path / "x.txt" + archivo.write_text("x", encoding="utf-8") + datos = QMimeData() + datos.setUrls( + [QUrl.fromLocalFile(str(archivo)), QUrl.fromLocalFile(str(tmp_path / "no_existe")), QUrl("https://x.y/z")] + ) + assert Ventana.carpetas_soltadas(datos) == [] + datos.setUrls([QUrl.fromLocalFile(str(tmp_path))]) + assert Ventana.carpetas_soltadas(datos) == [str(tmp_path)] diff --git a/tests/test_verificacion_formatos.py b/tests/test_verificacion_formatos.py new file mode 100644 index 0000000..3cdd9ad --- /dev/null +++ b/tests/test_verificacion_formatos.py @@ -0,0 +1,205 @@ +"""Casos límite de los formatos: idioma JSON y .lang, JSON/SNBT/JS/NBT/markdown con entradas raras, y UTF-8.""" + +import contextlib +import json + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from traductor_es.formatos import ( + comandos, + componente, + javascript, + json_cadenas, + json_texto, + lang_json, + lang_legacy, + markdown, + nbt, + snbt, +) + +TEXTO = st.text(st.characters(blacklist_categories=("Cs",)), max_size=40) +SEPARADORES = "\r\n\x85" + chr(0x2028) + chr(0x2029) + chr(0xFEFF) # saltos de línea y BOM +SIN_SALTOS = st.text(st.characters(blacklist_categories=("Cs",), blacklist_characters=SEPARADORES), max_size=40) +SUAVE = settings(max_examples=200, suppress_health_check=[HealthCheck.too_slow], deadline=None) + + +# ------------------------------------------------------------------ lang JSON +@SUAVE +@given(st.dictionaries(TEXTO, TEXTO, max_size=8)) +def test_lang_json_ida_y_vuelta_y_utf8(textos: dict[str, str]) -> None: + datos = lang_json.escribir(textos) + assert lang_json.leer(datos) == textos + assert json.loads(datos.decode("utf-8")) == textos # es JSON válido y UTF-8 puro + assert not datos.startswith(b"\xef\xbb\xbf") # sin BOM: el juego no lo quiere + + +def test_lang_json_guarda_tildes_y_ene_sin_escapar() -> None: + datos = lang_json.escribir({"a": "¡Ñandú, canción y pingüino!"}) + assert "¡Ñandú, canción y pingüino!".encode() in datos and b"\\u" not in datos + + +@pytest.mark.parametrize( + "crudo", + [ + b'\xef\xbb\xbf{"a": "b"}', # con BOM + b'{"a": "b",}', # coma final + b'{"a": "b" /* comentario */}', + b"{'a': 'b'}", # comillas simples + b'{"a": "b", "n": 5, "l": [1], "o": {"x": "y"}, "z": null}', # solo se conservan los textos + ], +) +def test_lang_json_tolerante(crudo: bytes) -> None: + assert lang_json.leer(crudo)["a"] == "b" + + +@pytest.mark.parametrize("crudo", [b"[1, 2]", b'"texto"', b"5"]) +def test_lang_json_que_no_es_objeto_falla_con_valueerror(crudo: bytes) -> None: + with pytest.raises(ValueError, match="no es un objeto"): + lang_json.leer(crudo) + + +@pytest.mark.parametrize("crudo", [b"", b"{", b'{"a": ', b"no es json", b"\xff\xfe\x00\x00"]) +def test_lang_json_roto_falla_con_valueerror(crudo: bytes) -> None: + with pytest.raises(ValueError): + lang_json.leer(crudo) + + +# ------------------------------------------------------------------ .lang antiguo +@SUAVE +@given( + st.dictionaries(SIN_SALTOS.filter(lambda k: "=" not in k and not k.startswith("#") and k), SIN_SALTOS, max_size=8) +) +def test_lang_legacy_ida_y_vuelta(textos: dict[str, str]) -> None: + datos = lang_legacy.escribir(textos) + assert lang_legacy.leer(datos) == textos + datos.decode("utf-8") + + +def test_lang_legacy_detalles() -> None: + crudo = "# comentario\r\nitem.a=Espada §a%s\r\n\r\nsin_igual\r\nitem.b=a=b=c\nitem.c=\n".encode() + assert lang_legacy.leer(crudo) == {"item.a": "Espada §a%s", "item.b": "a=b=c", "item.c": ""} + with pytest.raises(ValueError, match="salto de línea"): + lang_legacy.escribir({"k": "dos\nlíneas"}) + with pytest.raises(ValueError, match="salto de línea"): + lang_legacy.escribir({"k=v": "x"}) + + +# ------------------------------------------------------------------ escáneres: nunca fallan con otra cosa que ValueError +@SUAVE +@given(st.text(max_size=120)) +def test_escaneres_solo_lanzan_valueerror(texto: str) -> None: + for escaner in (javascript.escanear, snbt.escanear, json_cadenas.escanear): + try: + cadenas = escaner(texto) + except ValueError: + continue + assert isinstance(cadenas, list) + + +@SUAVE +@given(st.text(alphabet="{}[]\"':,/\\ ab1\n`$()+-*=", max_size=80)) +def test_escaneres_con_sintaxis_hostil(texto: str) -> None: + for escaner in (javascript.escanear, snbt.escanear, json_cadenas.escanear): + with contextlib.suppress(ValueError): + escaner(texto) + + +@SUAVE +@given(st.text(max_size=100)) +def test_componentes_y_comandos_no_fallan(texto: str) -> None: + json_texto.cargar(texto) + componente.unidades(texto) + assert componente.reconstruir(texto, {}) == texto # sin traducciones no cambia nada + comandos.analizar(texto) + comandos.analizar("tellraw @a " + texto) + comandos.analizar("execute as @a run say " + texto) + + +def test_json_cadenas_unicode_y_escapes() -> None: + src = '{"a": "ca\\u00f1\\u00f3n \\"x\\" \\n fin", "b": ["\\ud83d\\ude00", "plano"]}' + cadenas = json_cadenas.escanear(src) + assert [c.texto for c in cadenas] == ['cañón "x" \n fin', "😀", "plano"] + nuevo = json_cadenas.reemplazar(src, cadenas, {cadenas[2].inicio: "llano ñ"}) + assert json.loads(nuevo)["b"][1] == "llano ñ" and json.loads(nuevo)["a"] == json.loads(src)["a"] + + +@SUAVE +@given(TEXTO) +def test_json_cadenas_reemplazo_arbitrario_es_json_valido(texto: str) -> None: + src = '{"k": "valor", "l": ["x"]}' + cadenas = json_cadenas.escanear(src) + nuevo = json_cadenas.reemplazar(src, cadenas, {cadenas[0].inicio: texto, cadenas[1].inicio: texto}) + assert json.loads(nuevo) == {"k": texto, "l": [texto]} + + +# ------------------------------------------------------------------ JavaScript +@SUAVE +@given(TEXTO, st.sampled_from("'\"`")) +def test_js_escapar_ida_y_vuelta_con_especiales(texto: str, comilla: str) -> None: + if comilla == "`" and "${" in texto: + return + src = f"x.tell({comilla}{javascript.escapar(texto, comilla)}{comilla})\n" + assert [c.texto for c in javascript.escanear(src)] == [texto] + + +@pytest.mark.parametrize( + "src", + ["x.tell('sin cerrar", 'x.tell("sin cerrar\n")', "x.tell(`sin ${cerrar`)"], +) +def test_js_cadena_sin_cerrar_es_valueerror(src: str) -> None: + with pytest.raises(ValueError, match="sin cerrar"): + javascript.escanear(src) + + +def test_js_crlf_comentarios_regex_y_division() -> None: + src = "let a = 10 / 2 / 5\r\n// 'no'\r\nlet r = /'x'/g; /* \"no\" */ x.tell('sí ñ')\r\n" + assert [c.texto for c in javascript.escanear(src)] == ["sí ñ"] + + +# ------------------------------------------------------------------ SNBT / NBT +def test_snbt_escapes_y_unicode() -> None: + src = r'{title: "Hola \"mundo\" ñ\n", list: ["a", "b"]}' + cadenas = snbt.escanear(src) + nuevo = snbt.reemplazar(src, cadenas, {cadenas[0].inicio: 'Adiós "mundo" ¡ñ!'}) + assert next(c.texto for c in snbt.escanear(nuevo)) == 'Adiós "mundo" ¡ñ!' and '"a", "b"' in nuevo + + +@pytest.mark.parametrize( + "datos", [b"", b"\x00", b"\x0a\x00", b"\x1f\x8b\x08garbage", b"\x0a\x00\x00\x08\x00\x01a\xff\xff"] +) +def test_nbt_basura_falla_con_valueerror(datos: bytes) -> None: + with pytest.raises(ValueError): + nbt.leer(datos) + + +@SUAVE +@given(st.binary(max_size=60)) +def test_nbt_bytes_cualesquiera_no_revientan(datos: bytes) -> None: + with contextlib.suppress(ValueError): + nbt.leer(datos) + + +@SUAVE +@given(TEXTO) +def test_mutf8_ida_y_vuelta(texto: str) -> None: + assert nbt.mutf8_decodificar(nbt.mutf8_codificar(texto)) == texto + + +# ------------------------------------------------------------------ markdown +@SUAVE +@given(st.text(alphabet="abc ñ#-*>`~!\n\r\t[]()$<>=", max_size=120)) +def test_markdown_sin_traducir_no_cambia_nada(md: str) -> None: + lineas, segs = markdown.segmentar(md) + assert markdown.reconstruir(lineas, segs, {}) == md + + +def test_markdown_traduce_sin_tocar_codigo_ni_crlf() -> None: + md = "# Title here\r\n\r\nSome text about iron.\r\n\r\n```\r\ncode stays\r\n```\r\n- list item here\r\n" + lineas, segs = markdown.segmentar(md) + trad = {s.texto: "«ES» " + s.texto for s in segs} + salida = markdown.reconstruir(lineas, segs, trad) + assert "code stays" in salida and "«ES» Some text about iron." in salida + assert salida.count("\r\n") == md.count("\r\n") diff --git a/tests/test_verificacion_fuentes.py b/tests/test_verificacion_fuentes.py new file mode 100644 index 0000000..cc24771 --- /dev/null +++ b/tests/test_verificacion_fuentes.py @@ -0,0 +1,180 @@ +"""Casos límite de las fuentes: instancias rotas o vacías, jars dañados, options.txt raro y versiones inesperadas.""" + +import json +import zipfile +from pathlib import Path + +import pytest + +from traductor_es.fuentes import opciones +from traductor_es.fuentes.contenedores import Carpeta, Zip, abrir_mod +from traductor_es.fuentes.deteccion import detectar +from traductor_es.fuentes.instancia import Instancia, resolver_carpeta_juego +from traductor_es.fuentes.version import Version, formato_datos, formato_recursos + +from . import fabrica + + +# ------------------------------------------------------------------ versiones +@pytest.mark.parametrize( + ("texto", "partes"), + [ + ("1.20.1", (1, 20, 1)), + ("1.20", (1, 20, 0)), + ("1.12.2-forge", (1, 12, 2)), + (" 26.1 ", (26, 1, 0)), + ("1.21.11", (1, 21, 11)), + ], +) +def test_version_se_lee_de_formas_distintas(texto: str, partes: tuple[int, ...]) -> None: + assert Version.de(texto).partes == partes + + +@pytest.mark.parametrize("texto", ["", "abc", "1", "v1.2", "..", "latest"]) +def test_version_invalida_falla_con_valueerror(texto: str) -> None: + with pytest.raises(ValueError, match="no reconocida"): + Version.de(texto) + + +def test_formatos_de_pack_son_monotonos_y_cubren_todo() -> None: + versiones = [ + "1.6", + "1.9", + "1.11", + "1.13", + "1.16.2", + "1.18", + "1.19.4", + "1.20", + "1.20.5", + "1.21", + "1.21.5", + "26.1", + "26.3", + ] + previos = [formato_recursos(Version.de(v)) for v in versiones] + assert previos == sorted(previos) # el formato nunca baja al subir la versión + assert formato_recursos(Version.de("1.5")) == (1, 0) # versiones muy viejas: valor por defecto + assert formato_datos(Version.de("1.12")) is None and formato_datos(Version.de("1.13")) == (4, 0) + + +# ------------------------------------------------------------------ carpetas y detección +def test_carpeta_que_no_existe_o_vacia(tmp_path: Path) -> None: + assert resolver_carpeta_juego(tmp_path / "no_existe") is None + vacia = tmp_path / "vacia" + vacia.mkdir() + with pytest.raises(ValueError): + Instancia(vacia) + + +def test_resolver_carpeta_acepta_la_instancia_o_su_minecraft(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + assert resolver_carpeta_juego(juego) == juego + assert resolver_carpeta_juego(juego.parent) == juego + + +def test_deteccion_con_archivos_de_configuracion_rotos(tmp_path: Path) -> None: + juego = tmp_path / "Pack" / "minecraft" + juego.mkdir(parents=True) + (juego.parent / "mmc-pack.json").write_text("{esto no es json", encoding="utf-8") + (juego / "minecraftinstance.json").write_bytes(b"\xff\xfe\x00basura") + (juego / "mods").mkdir() + (juego / "mods" / "roto.jar").write_bytes(b"no es un zip") + with pytest.raises(ValueError): + Instancia(juego) # sin versión: dice que no la pudo detectar, no revienta con otro error + + +def test_deteccion_ignora_lo_que_no_es_cadena(tmp_path: Path) -> None: + juego = tmp_path / "Pack" / "minecraft" + (juego / "mods").mkdir(parents=True) + (juego.parent / "mmc-pack.json").write_text( + json.dumps({"components": [None, 5, {"uid": 3}, {"uid": "net.minecraft"}]}) + ) + d = detectar(juego, []) # no revienta: simplemente no encuentra la versión + assert d.version is None and d.origen == "no se pudo detectar" + + +# ------------------------------------------------------------------ options.txt +@pytest.mark.parametrize( + ("contenido", "idioma", "packs"), + [ + ('lang:es_mx\nresourcePacks:["vanilla","file/Mi Pack","mod_resources"]\n', "es_mx", ("Mi Pack",)), + ("lang:\n", None, ()), + ("resourcePacks:esto no es json\n", None, ()), + ('resourcePacks:{"a": 1}\n', None, ()), # JSON válido pero no es una lista + ('lang:EN_US\r\nresourcePacks:["file/Ñandú ¡pack!"]\r\n', "en_us", ("Ñandú ¡pack!",)), + ("", None, ()), + ], +) +def test_options_txt(tmp_path: Path, contenido: str, idioma: str | None, packs: tuple[str, ...]) -> None: + (tmp_path / "options.txt").write_text(contenido, encoding="utf-8", newline="") + o = opciones.leer(tmp_path) + assert o.idioma == idioma and o.packs == packs + + +def test_options_txt_inexistente_o_con_bytes_invalidos(tmp_path: Path) -> None: + assert opciones.leer(tmp_path) == opciones.Opciones() + (tmp_path / "options.txt").write_bytes(b"lang:es_mx\n\xff\xfe\x00 basura \x80\n") + assert opciones.leer(tmp_path).idioma == "es_mx" + + +# ------------------------------------------------------------------ contenedores y jars +def test_jar_danado_no_detiene_la_lectura(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + (juego / "mods" / "roto.jar").write_bytes(b"PK\x03\x04 truncado") + fabrica.jar(juego / "mods" / "bueno.jar", fabrica.lang("m", "en_us", {"item.m.a": "Iron Plate"})) + with Instancia(juego) as inst: + tabla = inst.tabla_idiomas() + assert tabla.textos("m", "en_us")["item.m.a"] == "Iron Plate" + + +def test_jar_con_nombres_unicode_y_lang_con_ene(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fabrica.jar(juego / "mods" / "Ñandú ¡mod! 日本.jar", fabrica.lang("ñ", "en_us", {"item.a": "Plátano ñ"})) + with Instancia(juego) as inst: + assert [c.nombre for c in inst.contenedores_mods()] == ["Ñandú ¡mod! 日本.jar"] + + +def test_zip_con_entradas_peligrosas_no_se_extrae(tmp_path: Path) -> None: + ruta = tmp_path / "m.jar" + with zipfile.ZipFile(ruta, "w") as z: + z.writestr("../../fuera.txt", "x") + z.writestr("assets/m/lang/en_us.json", '{"a": "b"}') + contenedores = list(abrir_mod(ruta)) + try: + assert contenedores and contenedores[0].leer("assets/m/lang/en_us.json") == b'{"a": "b"}' + finally: + for c in contenedores: + c.cerrar() + assert not (tmp_path.parent / "fuera.txt").exists() # solo se lee en memoria: nada se escribe en disco + + +def test_jars_anidados_con_limite_de_profundidad(tmp_path: Path) -> None: + interior = fabrica.zip_en_memoria(fabrica.lang("m", "en_us", {"a": "Hello there"})) + for _ in range(6): # un jar dentro de un jar dentro de otro… + interior = fabrica.zip_en_memoria({"META-INF/jarjar/x.jar": interior}) + ruta = tmp_path / "profundo.jar" + ruta.write_bytes(interior) + contenedores = list(abrir_mod(ruta)) # no debe colgarse ni lanzar + for c in contenedores: + c.cerrar() + assert len(contenedores) >= 1 + + +def test_carpeta_como_contenedor_ignora_lo_que_sale_de_la_raiz(tmp_path: Path) -> None: + (tmp_path / "pack" / "assets" / "m" / "lang").mkdir(parents=True) + (tmp_path / "pack" / "assets" / "m" / "lang" / "en_us.json").write_text('{"a": "b"}', encoding="utf-8") + (tmp_path / "secreto.txt").write_text("no", encoding="utf-8") + c = Carpeta(tmp_path / "pack") + assert "assets/m/lang/en_us.json" in c.nombres() + for fuera in ("../secreto.txt", "assets/../../secreto.txt", str(tmp_path / "secreto.txt")): + with pytest.raises(KeyError): + c.leer(fuera) + assert c.leer("assets/m/lang/en_us.json") == b'{"a": "b"}' + + +def test_zip_abrir_un_archivo_que_no_es_zip(tmp_path: Path) -> None: + ruta = tmp_path / "x.zip" + ruta.write_bytes(b"nada") + with pytest.raises(zipfile.BadZipFile): + Zip.abrir(ruta) diff --git a/tests/test_verificacion_modulos.py b/tests/test_verificacion_modulos.py new file mode 100644 index 0000000..7c1d463 --- /dev/null +++ b/tests/test_verificacion_modulos.py @@ -0,0 +1,276 @@ +"""Casos límite de los módulos: lang con textos difíciles, scripts con codificaciones raras y archivos dañados.""" + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.formatos import lang_json, lang_legacy +from traductor_es.fuentes import vanilla +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.libros import Libros +from traductor_es.modulos.misiones import HQM, BetterQuesting, FTBQuests, Heracles +from traductor_es.modulos.mods import Mods +from traductor_es.modulos.scripts import ScriptsKubeJS +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.dialectos import VARIANTES +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.mascara import enmascarar +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.salida.copias import Respaldo +from traductor_es.salida.resource_pack import PackSalida + +from . import fabrica + +DIFICILES = { + "item.m.a": "Iron Plate", + "item.m.b": "§aGreen %s with %d items §r and %1$s", + "item.m.c": 'Say "hello" \\ back\\slash and {0} and ${x}', + "item.m.d": "Línea uno\nLínea dos con tab\tfin", + "item.m.e": "Ñandú ¡Ágüita! cañón 日本語 😀 click here", + "item.m.f": "Visit https://example.com/a_b?x=1&y=2 now please", + "item.m.g": "%s", # solo un código: no hay nada que traducir + "item.m.h": "Bold text and
a break", +} + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +@pytest.fixture(autouse=True) +def _sin_lanzadores(monkeypatch: pytest.MonkeyPatch) -> None: + + def sin_lanzadores() -> list[Path]: + return [] # sin el juego real del PC + + monkeypatch.setattr(vanilla, "raices_de_lanzadores", sin_lanzadores) + + +def _traducir_mods(juego: Path, memoria: Memoria) -> Path: + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), pack=PackSalida(inst)) + resumen = traducir_modulo(Mods(ctx), Traductor(Simulador(), memoria, Glosario())) + assert resumen.archivos + return ctx.pack_listo().raiz + + +def test_mods_modernos_producen_json_valido_con_los_mismos_codigos(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fabrica.jar(juego / "mods" / "m.jar", fabrica.lang("m", "en_us", DIFICILES)) + pack = _traducir_mods(juego, memoria) + json.loads((pack / "pack.mcmeta").read_text(encoding="utf-8")) + for variante in VARIANTES: + ruta = pack / "assets" / "m" / "lang" / f"{variante}.json" + crudo = ruta.read_bytes() + crudo.decode("utf-8") # UTF-8 puro + assert not crudo.startswith(b"\xef\xbb\xbf") + textos = json.loads(crudo) # JSON válido + assert set(textos) <= set(DIFICILES) + for clave, valor in textos.items(): + assert sorted(enmascarar(valor).tokens) == sorted(enmascarar(DIFICILES[clave]).tokens), (variante, clave) + assert textos["item.m.e"] == "«ES» Ñandú ¡Ágüita! cañón 日本語 😀 click here" + assert "item.m.g" not in textos # sin nada que traducir no se inventa nada + + +def test_mods_legacy_lang_sin_saltos_de_linea_reales(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.12.2") + textos = {k: v for k, v in DIFICILES.items() if "\n" not in v} + fabrica.jar(juego / "mods" / "m.jar", fabrica.lang("m", "en_us", textos, legacy=True)) + pack = _traducir_mods(juego, memoria) + for ruta in (pack / "assets" / "m" / "lang").glob("*.lang"): + crudo = ruta.read_bytes() + crudo.decode("utf-8") + leidos = lang_legacy.leer(crudo) + assert set(leidos) <= set(textos) + for clave, valor in leidos.items(): + assert sorted(enmascarar(valor).tokens) == sorted(enmascarar(textos[clave]).tokens) + assert any((pack / "assets" / "m" / "lang").glob("*.lang")) + + +def test_lang_vacio_roto_o_sin_texto_no_detiene_a_los_demas(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fabrica.jar(juego / "mods" / "vacio.jar", {"assets/v/lang/en_us.json": "{}"}) + fabrica.jar(juego / "mods" / "roto.jar", {"assets/r/lang/en_us.json": '{"a": "Hello there'}) + fabrica.jar(juego / "mods" / "mixto.jar", {"assets/x/lang/en_us.json": '{"a": 5, "b": null, "c": "Hello there"}'}) + fabrica.jar(juego / "mods" / "bueno.jar", fabrica.lang("m", "en_us", {"item.m.a": "Iron Plate"})) + pack = _traducir_mods(juego, memoria) + assert json.loads((pack / "assets" / "m" / "lang" / "es_mx.json").read_text("utf-8")) == { + "item.m.a": "«ES» Iron Plate" + } + assert json.loads((pack / "assets" / "x" / "lang" / "es_mx.json").read_text("utf-8")) == {"c": "«ES» Hello there"} + + +# ------------------------------------------------------------------ scripts +def _script(tmp_path: Path, contenido: bytes) -> tuple[Path, Path]: + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "kubejs" / "server_scripts" / "s.js" + ruta.parent.mkdir(parents=True) + ruta.write_bytes(contenido) + return juego, ruta + + +def _correr(juego: Path, memoria: Memoria, avisos: list[str] | None = None) -> int: + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), registro=(avisos.append if avisos is not None else lambda _m: None)) + return traducir_modulo(ScriptsKubeJS(ctx), Traductor(Simulador(), memoria, Glosario())).traducidos + + +def test_script_con_bom_y_crlf_se_conserva(tmp_path: Path, memoria: Memoria) -> None: + fuente = "PlayerEvents.chat(e => {\r\n e.player.tell('Hello there ñ')\r\n})\r\n".encode() + juego, ruta = _script(tmp_path, fuente) + assert _correr(juego, memoria) == 1 + nuevo = ruta.read_bytes() + assert nuevo.startswith(b"\xef\xbb\xbf") and nuevo.count(b"\r\n") == 3 and "«ES» Hello there ñ".encode() in nuevo + + +@pytest.mark.parametrize( + "contenido", + [ + b"", + b"\n\n", + b"// solo un comentario 'x'\n", + b"let a = 'sin cerrar\n", + b"x.tell('\xe9\xe9 latin-1')\n", + b"\x00\x01\x02", + b"`${", + ], +) +def test_script_vacio_roto_o_con_otra_codificacion_se_deja_igual( + tmp_path: Path, memoria: Memoria, contenido: bytes +) -> None: + juego, ruta = _script(tmp_path, contenido) + avisos: list[str] = [] + assert _correr(juego, memoria, avisos) == 0 + assert ruta.read_bytes() == contenido + + +def test_script_enorme(tmp_path: Path, memoria: Memoria) -> None: + cuerpo = "".join(f"e.player.tell('Mensaje número {i} para ti')\n" for i in range(5000)).encode() + juego, ruta = _script(tmp_path, cuerpo) + assert _correr(juego, memoria) == 5000 + assert ruta.read_text("utf-8").count("«ES»") == 5000 + + +# ------------------------------------------------------------------ misiones y libros dañados +@pytest.mark.parametrize( + ("modulo", "ruta", "contenido"), + [ + (FTBQuests, "config/ftbquests/quests/chapters/a.snbt", b'{ title: "sin cerrar '), + (FTBQuests, "config/ftbquests/quests/chapters/b.snbt", b"\xff\xfe\x00 no utf8"), + (FTBQuests, "config/ftbquests/quests/chapters/c.nbt", b"\x1f\x8b\x08 gzip roto"), + (BetterQuesting, "config/betterquesting/DefaultQuests.json", b'{"questDatabase:9": {'), + (HQM, "config/hqm/quests.json", b"no es json"), + (Heracles, "config/heracles/quests/q.json", b"\xff\xfe"), + ], +) +def test_mision_danada_se_deja_igual_sin_reventar( + tmp_path: Path, memoria: Memoria, modulo: type, ruta: str, contenido: bytes +) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + destino = juego / ruta + destino.parent.mkdir(parents=True) + destino.write_bytes(contenido) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + m = modulo(ctx) + if m.detectar(): + traducir_modulo(m, Traductor(Simulador(), memoria, Glosario())) + assert destino.read_bytes() == contenido + + +def test_libro_con_json_roto_y_libro_bueno(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + libros = juego / "patchouli_books" / "guia" + (libros / "en_us" / "entries").mkdir(parents=True) + (libros / "book.json").write_text( + json.dumps({"name": "Guide book", "landing_text": "Welcome to the guide."}), "utf-8" + ) + (libros / "en_us" / "entries" / "roto.json").write_text('{"name": "Roto', "utf-8") + (libros / "en_us" / "entries" / "bueno.json").write_text( + json.dumps( + { + "name": "Good entry", + "category": "x", + "pages": [{"type": "patchouli:text", "text": "Hello $(l)there$()."}], + } + ), + "utf-8", + ) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), pack=PackSalida(inst)) + modulo = Libros(ctx) + if modulo.detectar(): + traducir_modulo(modulo, Traductor(Simulador(), memoria, Glosario())) + assert (libros / "en_us" / "entries" / "roto.json").read_text("utf-8") == '{"name": "Roto' + salida = json.dumps(lang_json.leer("{}")) # el lector de lang sigue sano + assert salida == "{}" + + +# ------------------------------------------------------------------ datos (funciones, logros, tips) raros +def test_datos_con_formas_inesperadas_no_revientan(tmp_path: Path, memoria: Memoria) -> None: + from traductor_es.modulos.datos import Datos + + juego = fabrica.prism(tmp_path, "1.20.1") + base = juego / "kubejs" / "data" / "a" + casos = { + "advancements/texto.json": json.dumps({"display": "no es un objeto"}), + "advancements/lista.json": json.dumps(["x", {"display": {"title": "Hi there"}}]), + "advancements/nulos.json": json.dumps({"display": {"title": None, "description": 5}}), + "advancements/bom.json": "\ufeff" + + json.dumps({"display": {"title": "Big Win", "description": {"translate": "k"}}}), + "functions/macro.mcfunction": '$tellraw @a {"text":"$(x) hello there"}\n', + "functions/vacia.mcfunction": "", + "functions/latin.mcfunction": None, # bytes que no son UTF-8 + } + for rel, contenido in casos.items(): + ruta = base / rel + ruta.parent.mkdir(parents=True, exist_ok=True) + if contenido is None: + ruta.write_bytes(b"say \xe9\xe9 hola\n") + else: + ruta.write_text(contenido, encoding="utf-8") + antes = {p: p.read_bytes() for p in base.rglob("*") if p.is_file()} + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + traducir_modulo(Datos(ctx), Traductor(Simulador(), memoria, Glosario())) + despues = {p: p.read_bytes() for p in base.rglob("*") if p.is_file()} + cambiados = sorted(p.name for p in antes if antes[p] != despues[p]) + assert cambiados == ["bom.json"] # solo el logro bueno: lo raro se deja igual + assert json.loads((base / "advancements" / "bom.json").read_text("utf-8"))["display"]["title"] == "«ES» Big Win" + + +def test_zip_danado_se_ignora(tmp_path: Path, memoria: Memoria) -> None: + from traductor_es.modulos.datos import Datos + + juego = fabrica.prism(tmp_path, "1.20.1") + (juego / "datapacks").mkdir() + (juego / "datapacks" / "roto.zip").write_bytes(b"PK\x03\x04 no es un zip de verdad") + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + assert not Datos(ctx).detectar() + + +@pytest.mark.parametrize("namespace", ["..", "a:b", "nul", "COM1.x", "con.", "trailing ", "a|b"]) +def test_namespace_imposible_no_tumba_el_resto(tmp_path: Path, memoria: Memoria, namespace: str) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + avisos: list[str] = [] + fabrica.jar( + juego / "mods" / "x.jar", + { + f"assets/{namespace}/lang/en_us.json": '{"a": "Hello there"}', + **fabrica.lang("ok", "en_us", {"b": "Good one"}), + }, + ) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), pack=PackSalida(inst), registro=avisos.append) + resumen = traducir_modulo(Mods(ctx), Traductor(Simulador(), memoria, Glosario())) + pack = ctx.pack_listo().raiz + assert json.loads((pack / "assets" / "ok" / "lang" / "es_mx.json").read_text("utf-8")) == {"b": "«ES» Good one"} + assert resumen.pendientes >= 1 and any("no se puede guardar" in a for a in avisos) + assert not (tmp_path / "Pack" / "minecraft" / "lang").exists() # nada se escribió fuera del pack diff --git a/tests/test_verificacion_motores.py b/tests/test_verificacion_motores.py new file mode 100644 index 0000000..c0114d9 --- /dev/null +++ b/tests/test_verificacion_motores.py @@ -0,0 +1,191 @@ +"""Casos límite de los motores: respuestas raras o rotas, errores de red, cancelación y creación con datos faltantes.""" + +import json +import threading +from collections.abc import Callable, Iterator +from typing import Any + +import httpx +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from traductor_es.motores import fabrica +from traductor_es.motores.base import Cancelado, Entrada, ErrorFatal, Lote +from traductor_es.motores.compatible import PERFILES, Compatible +from traductor_es.motores.instrucciones import interpretar, sistema, usuario +from traductor_es.motores.simulador import Simulador + +SUAVE = settings(max_examples=200, suppress_health_check=[HealthCheck.too_slow], deadline=None) + + +def lote(*textos: str, tarea: str = "traducir") -> Lote: + return Lote(tuple(Entrada(str(i), t) for i, t in enumerate(textos)), "ui", "español", {}, "", tarea) + + +@pytest.fixture +def armar() -> Iterator[Callable[..., tuple[Compatible, list[httpx.Request], list[float]]]]: + abiertos: list[Compatible] = [] + + def crear( + *respuestas: httpx.Response | Exception, **kw: Any + ) -> tuple[Compatible, list[httpx.Request], list[float]]: + pedidos: list[httpx.Request] = [] + esperas: list[float] = [] + cola = list(respuestas) + + def manejar(pedido: httpx.Request) -> httpx.Response: + pedidos.append(pedido) + r = cola.pop(0) if len(cola) > 1 else cola[0] + if isinstance(r, Exception): + raise r + return r + + cliente = httpx.Client(transport=httpx.MockTransport(manejar)) + m = Compatible(PERFILES["openrouter"], "modelo", cliente=cliente, esperar=esperas.append, clave="sk-x", **kw) + abiertos.append(m) + return m, pedidos, esperas + + yield crear + for m in abiertos: + m.cerrar() + + +# ------------------------------------------------------------------ interpretar +@SUAVE +@given(st.one_of(st.none(), st.text(max_size=200))) +def test_interpretar_nunca_falla_y_siempre_devuelve_textos(texto: str | None) -> None: + resultado = interpretar(texto) + assert isinstance(resultado, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in resultado.items()) + + +@pytest.mark.parametrize( + ("crudo", "esperado"), + [ + ('{"0": "Hola ñ"}', {"0": "Hola ñ"}), + ('```json\n{"0": "Hola"}\n```', {"0": "Hola"}), + ('```\n{"0": "Hola"}\n```', {"0": "Hola"}), + (' {"0": "Hola", "1": 5, "2": null, "3": ["x"]} ', {"0": "Hola"}), + ('{"0": "con \\"comillas\\" y \\u00f1"}', {"0": 'con "comillas" y ñ'}), + ("[]", {}), + ('"solo texto"', {}), + ("", {}), + ("no es json", {}), + ('{"0": "cortado', {}), + ], +) +def test_interpretar_respuestas_tipicas_y_rotas(crudo: str, esperado: dict[str, str]) -> None: + assert interpretar(crudo) == esperado + + +# ------------------------------------------------------------------ motor compatible +@pytest.mark.parametrize( + "cuerpo", + [ # pyright: ignore[reportUnknownArgumentType] + {}, + {"choices": []}, + {"choices": [{}]}, + {"choices": [{"message": None}]}, + {"choices": [{"message": {"content": None}}]}, + {"choices": [{"message": {"content": ["no", "texto"]}}]}, + {"choices": [{"message": {"content": 5}}]}, + {"choices": "no es lista"}, + [1, 2, 3], + "texto plano", + ], +) +def test_respuesta_con_forma_inesperada_no_rompe(armar: Callable[..., Any], cuerpo: object) -> None: + m, _, _ = armar(httpx.Response(200, json=cuerpo)) + assert m.traducir_lote(lote("Hello there")) == {} + + +def test_respuesta_que_no_es_json(armar: Callable[..., Any]) -> None: + m, _, _ = armar(httpx.Response(200, content=b"Bad gateway")) + assert m.traducir_lote(lote("Hello there")) == {} + + +def test_error_de_servidor_persistente_devuelve_vacio_tras_reintentar(armar: Callable[..., Any]) -> None: + m, pedidos, esperas = armar(httpx.Response(503, text="caído"), reintentos=3) + assert m.traducir_lote(lote("Hello there")) == {} + assert len(pedidos) == 3 and len(esperas) == 2 # esperó entre intentos, no tras el último + + +@pytest.mark.parametrize( + "excepcion", [httpx.ReadTimeout("lento"), httpx.RemoteProtocolError("cortado"), httpx.WriteError("x")] +) +def test_fallos_de_red_se_reintentan_y_no_revientan(armar: Callable[..., Any], excepcion: Exception) -> None: + m, pedidos, _ = armar(excepcion, reintentos=2) + assert m.traducir_lote(lote("Hello there")) == {} + assert len(pedidos) == 2 + + +def test_retry_after_basura_no_rompe(armar: Callable[..., Any]) -> None: + ok = httpx.Response(200, json={"choices": [{"message": {"content": json.dumps({"0": "Hola"})}}]}) + m, _, _ = armar(httpx.Response(429, headers={"retry-after": "soon"}), ok, reintentos=3) + assert m.traducir_lote(lote("Hello there")) == {"0": "Hola"} + + +def test_cancelar_corta_antes_de_pedir(armar: Callable[..., Any]) -> None: + m, pedidos, _ = armar(httpx.Response(200, json={})) + m.cancelar = threading.Event() + m.cancelar.set() + with pytest.raises(Cancelado): + m.traducir_lote(lote("Hello there")) + assert pedidos == [] + + +@pytest.mark.parametrize(("codigo", "texto"), [(401, "clave"), (403, "clave"), (402, "saldo"), (404, "modelo")]) +def test_errores_fatales_dicen_que_hacer(armar: Callable[..., Any], codigo: int, texto: str) -> None: + m, pedidos, _ = armar(httpx.Response(codigo, json={"error": {"message": "x"}})) + with pytest.raises(ErrorFatal, match=texto): + m.traducir_lote(lote("Hello there")) + assert len(pedidos) == 1 # no se insiste cuando reintentar no sirve + + +def test_el_pedido_lleva_el_texto_con_utf8_intacto(armar: Callable[..., Any]) -> None: + m, pedidos, _ = armar(httpx.Response(200, json={"choices": [{"message": {"content": "{}"}}]})) + m.traducir_lote(lote("Ñandú ¡sí! 日本語 😀 ⟦0⟧")) + cuerpo = json.loads(pedidos[0].content.decode("utf-8")) + assert "Ñandú ¡sí! 日本語 😀 ⟦0⟧" in cuerpo["messages"][1]["content"] + + +# ------------------------------------------------------------------ instrucciones, simulador, fábrica +def test_instrucciones_para_cada_ambito_y_tarea() -> None: + for ambito in ("ui", "mision", "libro", "script", "desconocido"): + for tarea in ("traducir", "revisar"): + lt = Lote( + (Entrada("0", "Hello ⟦0⟧", "p", "Hola ⟦0⟧"),), + ambito, + "español mexicano", + {"Iron": "Hierro"}, + "granja", + tarea, + ) + assert "español mexicano" in sistema(lt) or tarea == "revisar" + assert "Hello ⟦0⟧" in usuario(lt) + + +def test_simulador_conserva_marcadores_y_bordes() -> None: + r = Simulador().traducir_lote(lote("⟦0⟧ Hello ⟦1⟧")) + assert r == {"0": "«ES» ⟦0⟧ Hello ⟦1⟧"} or "⟦0⟧" in r["0"] + + +@pytest.mark.parametrize("motor", list(fabrica.MOTORES)) +def test_fabrica_pide_lo_que_falta(motor: str) -> None: + if fabrica.necesita_clave(motor): + with pytest.raises(ErrorFatal, match="Falta la clave"): + fabrica.crear(motor, None, "modelo") + elif motor == "personalizado": + with pytest.raises(ErrorFatal, match="dirección"): + fabrica.crear(motor, None, "modelo") + else: + m = fabrica.crear(motor, None, "modelo") + assert m.nombre + cerrar = getattr(m, "cerrar", None) + if callable(cerrar): + cerrar() + + +def test_fabrica_motor_desconocido() -> None: + with pytest.raises(ErrorFatal, match="desconocido"): + fabrica.crear("no-existe", "clave", "modelo") diff --git a/tests/test_verificacion_nucleo.py b/tests/test_verificacion_nucleo.py new file mode 100644 index 0000000..1864c01 --- /dev/null +++ b/tests/test_verificacion_nucleo.py @@ -0,0 +1,191 @@ +"""Casos límite del núcleo: memoria, glosario y traductor con entradas raras, motores que se portan mal y UTF-8.""" + +import sqlite3 +import threading +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.motores.base import ErrorFatal, Lote +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.mascara import desenmascarar, enmascarar, es_traducible, separar_bordes +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor + +ESPECIALES = "Ñandú sí, ¿cómo estás? ¡Ágüita! 日本語 😀 §a%s §r%d {0} ${x}" + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +# ------------------------------------------------------------------ memoria +def test_memoria_guarda_y_lee_utf8_en_disco(tmp_path: Path) -> None: + ruta = tmp_path / "datos" / "memoria.db" + with Memoria(ruta) as m: + m.guardar({ESPECIALES: "Ñandú ¡sí! §a%s"}, "es_mx", "ui", "prueba") + assert m.obtener(ESPECIALES, "es_mx", "ui") == "Ñandú ¡sí! §a%s" + with Memoria(ruta) as m: # se vuelve a abrir: persiste y sigue siendo UTF-8 + assert m.obtener(ESPECIALES, "es_mx", "ui") == "Ñandú ¡sí! §a%s" + assert m.cantidad() == 1 + + +def test_memoria_buscar_no_trata_comodines_como_comodines(memoria: Memoria) -> None: + memoria.guardar({"100% real": "100 % real", "abc": "abc", "a_c": "a_c", "a!c": "a!c"}, "es_mx", "ui", "p") + assert [c.origen for c in memoria.buscar("%", "es_mx")] == ["100% real"] + assert [c.origen for c in memoria.buscar("a_c", "es_mx")] == ["a_c"] + assert [c.origen for c in memoria.buscar("a!c", "es_mx")] == ["a!c"] + + +def test_memoria_con_inyeccion_sql_en_el_texto(memoria: Memoria) -> None: + raro = "'; DROP TABLE traducciones; --" + memoria.guardar({raro: "x"}, "es_mx", "ui", "p") + assert memoria.obtener(raro, "es_mx", "ui") == "x" and memoria.cantidad() == 1 + assert memoria.buscar(raro, "es_mx")[0].origen == raro + + +def test_memoria_desde_varios_hilos(memoria: Memoria) -> None: + errores: list[BaseException] = [] + + def trabajar(n: int) -> None: + try: + for i in range(40): + memoria.guardar({f"texto {n}-{i}": f"texto {n}-{i} ñ"}, "es_mx", "ui", "p") + assert memoria.obtener(f"texto {n}-{i}", "es_mx", "ui") == f"texto {n}-{i} ñ" + except BaseException as e: + errores.append(e) + + hilos = [threading.Thread(target=trabajar, args=(n,)) for n in range(8)] + for h in hilos: + h.start() + for h in hilos: + h.join() + assert not errores and memoria.cantidad() == 8 * 40 + + +def test_correccion_manda_y_se_puede_quitar(memoria: Memoria) -> None: + memoria.guardar({"Hello": "Hola"}, "es_mx", "ui", "p") + memoria.corregir("Hello", "es_mx", "¡Buenas!") + assert memoria.correccion("Hello", "es_mx") == "¡Buenas!" and memoria.correcciones("es_mx") == {"Hello": "¡Buenas!"} + memoria.quitar_correccion("Hello", "es_mx") + assert memoria.correccion("Hello", "es_mx") is None + + +def test_memoria_corrupta_da_un_error_claro(tmp_path: Path) -> None: + ruta = tmp_path / "memoria.db" + ruta.write_bytes(b"esto no es una base de datos sqlite" * 50) + with pytest.raises(sqlite3.DatabaseError): + Memoria(ruta) + + +# ------------------------------------------------------------------ glosario +@pytest.mark.parametrize( + "termino", ["C++", "(Test)", "a.b", "Tom's [Storage]", "50% off", "back\\slash", "$HOME", "x|y"] +) +def test_glosario_con_caracteres_de_regex(termino: str) -> None: + g = Glosario() + g.agregar_pares({"k": termino}, {"k": "traducido"}) + g.fijar({termino + " extra": "fijo"}) + assert isinstance(g.relevantes([f"use {termino} here and {termino} extra"]), dict) + + +def test_glosario_respeta_palabras_completas() -> None: + g = Glosario() + g.agregar_pares({"a": "Wheat", "b": "Iron Ingot"}, {"a": "Trigo", "b": "Lingote de hierro"}) + assert g.relevantes(["Wheat bread", "Iron Ingot"]) == {"Wheat": "Trigo", "Iron Ingot": "Lingote de hierro"} + assert g.relevantes(["Wheatgrass", "Ironwork"]) == {} # no son palabras completas + assert g.relevantes([]) == {} + + +# ------------------------------------------------------------------ máscara y UTF-8 +def test_mascara_ida_y_vuelta_con_especiales() -> None: + m = enmascarar(ESPECIALES) + assert desenmascarar(m.texto, m.tokens) == ESPECIALES + assert "Ñ" in m.texto and "😀" in m.texto and "日本語" in m.texto # lo que no es código se queda legible + + +@pytest.mark.parametrize("texto", ["", " ", "\n", "§a", "%s", "{0}", "
", "https://x.y/z", "12345", "---"]) +def test_textos_sin_nada_que_traducir(texto: str) -> None: + assert not es_traducible(texto) + assert separar_bordes(texto)[1] in ("", texto.strip()) + + +# ------------------------------------------------------------------ traductor con motores que se portan mal +class _Motor: + nombre = "malo" + + def __init__(self, respuesta: object) -> None: + self.respuesta = respuesta + self.llamadas = 0 + + def traducir_lote(self, lote: Lote) -> dict[str, str]: + self.llamadas += 1 + r = self.respuesta + if callable(r): + return r(lote) # type: ignore[no-any-return] + return r # type: ignore[return-value] + + +def test_traductor_con_lista_vacia_y_duplicados(memoria: Memoria) -> None: + t = Traductor(Simulador(), memoria, Glosario()) + assert t.traducir([], "ui").traducciones == {} + r = t.traducir(["Hello world", "Hello world", " Hello world "], "ui") + assert r.traducciones["Hello world"] == "«ES» Hello world" + assert r.traducciones[" Hello world "] == " «ES» Hello world " # los bordes se conservan + + +def test_traductor_con_codigos_y_especiales(memoria: Memoria) -> None: + t = Traductor(Simulador(), memoria, Glosario()) + textos = ["§aYou have %d items in %s", "Press {0} to open the menu ¡ya!", "Ñandú ${nombre} says §chello there"] + r = t.traducir(textos, "ui") + for original in textos: + assert enmascarar(original).tokens == enmascarar(r.traducciones[original]).tokens # mismos códigos + assert "«ES»" in r.traducciones[original] + + +def test_motor_que_pierde_los_codigos_se_descarta(memoria: Memoria) -> None: + def sin_codigos(lote: Lote) -> dict[str, str]: + return {e.id: "Texto sin marcadores" for e in lote.entradas} + + motor = _Motor(sin_codigos) + r = Traductor(motor, memoria, Glosario()).traducir(["§aHello %s there"], "ui") + assert "§aHello %s there" not in r.traducciones or r.traducciones["§aHello %s there"] == "§aHello %s there" + assert motor.llamadas >= 1 # reintentó y no aceptó la traducción rota + + +@pytest.mark.parametrize("respuesta", [{}, {"id-que-no-existe": "x"}, {"0": ""}]) +def test_motor_que_omite_o_inventa_ids(memoria: Memoria, respuesta: dict[str, str]) -> None: + r = Traductor(_Motor(respuesta), memoria, Glosario()).traducir(["Hello brave world"], "ui") + assert r.traducciones.get("Hello brave world", "Hello brave world") == "Hello brave world" # queda en inglés + + +def test_error_fatal_del_motor_se_propaga(memoria: Memoria) -> None: + def falla(_lote: Lote) -> dict[str, str]: + raise ErrorFatal("clave inválida") + + with pytest.raises(ErrorFatal): + Traductor(_Motor(falla), memoria, Glosario()).traducir(["Hello brave world"], "ui") + + +def test_texto_muy_largo_se_traduce_sin_perder_nada(memoria: Memoria) -> None: + largo = " ".join(f"Palabra{i}" for i in range(4000)) + r = Traductor(Simulador(), memoria, Glosario()).traducir([largo], "libro") + assert r.traducciones[largo] == "«ES» " + largo + + +def test_memoria_danada_da_un_error_claro_en_el_proceso(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from traductor_es.proceso import Proceso + + from . import fabrica + + datos = tmp_path / "datos" + datos.mkdir() + (datos / "memoria.db").write_bytes(b"basura" * 100) + monkeypatch.setenv("TRADUCTOR_ES_DATOS", str(datos)) + juego = fabrica.prism(tmp_path, "1.20.1") + with pytest.raises(ErrorFatal, match="memoria de traducciones está dañada"): + Proceso(juego, Simulador()) diff --git a/tests/test_verificacion_salida.py b/tests/test_verificacion_salida.py new file mode 100644 index 0000000..6a9f66b --- /dev/null +++ b/tests/test_verificacion_salida.py @@ -0,0 +1,155 @@ +"""Casos límite de la salida: options.txt, copias de seguridad, exportación y la línea de comandos real.""" + +import json +import os +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +from traductor_es.salida import exportar, opciones +from traductor_es.salida.copias import Respaldo + +from . import fabrica + +RAIZ = Path(__file__).resolve().parent.parent + + +# ------------------------------------------------------------------ options.txt +def _opts(tmp_path: Path, crudo: bytes) -> Path: + (tmp_path / "options.txt").write_bytes(crudo) + return tmp_path + + +def test_activar_conserva_crlf_y_otras_lineas_y_es_idempotente(tmp_path: Path) -> None: + crudo = ( + 'fov:0.5\r\nlang:en_us\r\nresourcePacks:["vanilla","file/Otro"]\r\n' + 'incompatibleResourcePacks:["file/Traduccion_ES"]\r\n' + ) + _opts(tmp_path, crudo.encode()) + cambios = opciones.activar(tmp_path, "Traduccion_ES", "es_mx") + nuevo = (tmp_path / "options.txt").read_bytes().decode() + assert cambios and nuevo.count("\r\n") == nuevo.count("\n") and "fov:0.5\r\n" in nuevo + assert 'resourcePacks:["vanilla","file/Otro","file/Traduccion_ES"]' in nuevo and "lang:es_mx" in nuevo + assert "incompatibleResourcePacks:[]" in nuevo + assert opciones.activar(tmp_path, "Traduccion_ES", "es_mx") == [] # ya está: no cambia nada + assert (tmp_path / "options.txt").read_bytes().decode() == nuevo + + +def test_activar_sin_options_no_crea_nada_y_con_lista_danada_no_toca(tmp_path: Path) -> None: + assert opciones.activar(tmp_path, "Traduccion_ES", "es_mx") == [] and not (tmp_path / "options.txt").exists() + crudo = b"lang:en_us\nresourcePacks:[esto no es json\n" + _opts(tmp_path, crudo) + with pytest.raises(ValueError, match="lista de resource packs"): + opciones.activar(tmp_path, "Traduccion_ES", "es_mx") + assert (tmp_path / "options.txt").read_bytes() == crudo + + +def test_activar_conserva_bytes_que_no_son_utf8_y_nombres_con_ene(tmp_path: Path) -> None: + crudo = b'lastServer:caf\xe9\nresourcePacks:["vanilla","file/\xc3\x91and\xc3\xba"]\n' + _opts(tmp_path, crudo) + opciones.activar(tmp_path, "Traduccion_ES", None) + nuevo = (tmp_path / "options.txt").read_bytes() + assert b"lastServer:caf\xe9\n" in nuevo # el byte raro sigue igual + assert '"file/Ñandú","file/Traduccion_ES"'.encode() in nuevo + + +# ------------------------------------------------------------------ copias de seguridad +def test_respaldo_restaura_cuando_el_archivo_cambio_o_se_borro(tmp_path: Path) -> None: + juego = tmp_path / "juego" + ruta = juego / "kubejs" / "ñandú ¡x!.js" + ruta.parent.mkdir(parents=True) + ruta.write_bytes(b"original") + r = Respaldo(juego) + r.escribir(ruta, b"traducido") + assert r.original(ruta) == b"original" + ruta.unlink() # el usuario borró el archivo + assert r.restaurar() == [ruta] and ruta.read_bytes() == b"original" + ruta.write_bytes(b"actualizado por el modpack") # y luego lo actualizó otra versión del modpack + assert r.original(ruta) == b"actualizado por el modpack" + + +def test_respaldo_con_registro_corrupto_empieza_de_cero(tmp_path: Path) -> None: + juego = tmp_path / "juego" + (juego / "traductor_es").mkdir(parents=True) + (juego / "traductor_es" / "registro.json").write_bytes(b"{no es json") + ruta = juego / "a.js" + ruta.write_bytes(b"x") + r = Respaldo(juego) + r.escribir(ruta, b"y") + assert json.loads((juego / "traductor_es" / "registro.json").read_text("utf-8")) + + +def test_respaldo_rechaza_rutas_fuera_de_la_instancia(tmp_path: Path) -> None: + juego = tmp_path / "juego" + juego.mkdir() + fuera = tmp_path / "fuera.txt" + fuera.write_bytes(b"x") + with pytest.raises(ValueError): + Respaldo(juego).escribir(fuera, b"y") + assert fuera.read_bytes() == b"x" + + +# ------------------------------------------------------------------ exportar +def test_exportar_kit_y_pack(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + pack = juego / "resourcepacks" / "Traduccion_ES" + (pack / "assets" / "m" / "lang").mkdir(parents=True) + (pack / "pack.mcmeta").write_text("{}", encoding="utf-8") + (pack / "assets" / "m" / "lang" / "es_mx.json").write_text('{"a": "Ñandú"}', encoding="utf-8") + script = juego / "kubejs" / "server_scripts" / "s.js" + script.parent.mkdir(parents=True) + script.write_text("x", encoding="utf-8") + Respaldo(juego).escribir(script, b"traducido") + destino = tmp_path / "salida" / "kit.zip" + assert exportar.exportar_kit_servidor(juego, destino) >= 2 + with zipfile.ZipFile(destino) as z: + assert z.testzip() is None and "kubejs/server_scripts/s.js" in z.namelist() + assert exportar.exportar_pack(juego, tmp_path / "salida" / "pack.zip") == 2 + with pytest.raises(ValueError, match="traduce primero"): + exportar.exportar_pack(tmp_path / "otro", tmp_path / "x.zip") + + +# ------------------------------------------------------------------ línea de comandos real +def _cli(*args: str, datos: Path) -> subprocess.CompletedProcess[str]: + entorno = {**os.environ, "TRADUCTOR_ES_DATOS": str(datos), "PYTHONIOENCODING": "utf-8"} + return subprocess.run( + [sys.executable, "-m", "traductor_es", *args], capture_output=True, text=True, encoding="utf-8", + env=entorno, cwd=RAIZ, timeout=120, check=False, + ) # fmt: skip + + +def test_cli_ayuda_version_y_argumentos_invalidos(tmp_path: Path) -> None: + assert _cli("--version", datos=tmp_path).returncode == 0 + assert "traducir" in _cli("--help", datos=tmp_path).stdout + malo = _cli("traducir", datos=tmp_path) # falta la carpeta + assert malo.returncode != 0 and "carpeta" in malo.stderr + assert _cli("analizar", "x", "--dialecto", "klingon", datos=tmp_path).returncode != 0 + + +def test_cli_carpeta_que_no_existe_da_un_error_claro_sin_traza(tmp_path: Path) -> None: + r = _cli("analizar", str(tmp_path / "no_existe"), datos=tmp_path) + assert r.returncode != 0 and "Traceback" not in r.stderr + r.stdout + + +def test_cli_analiza_traduce_simulado_restaura_y_cobertura(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + script = juego / "kubejs" / "server_scripts" / "s.js" + script.parent.mkdir(parents=True) + original = "PlayerEvents.chat(e => { e.player.tell('Welcome to the pack ñ') })\n" + script.write_text(original, encoding="utf-8") + fabrica.jar(juego / "mods" / "m.jar", fabrica.lang("m", "en_us", {"item.m.a": "Iron Plate ñ"})) + datos = tmp_path / "datos" + carpeta = str(juego.parent) + a = _cli("analizar", carpeta, datos=datos) + assert a.returncode == 0 and "Traceback" not in a.stderr and script.read_text("utf-8") == original # solo lee + t = _cli("traducir", carpeta, "--simular", "--no-activar", "--no-idioma", datos=datos) + assert t.returncode == 0, t.stderr + assert "«ES» Welcome to the pack ñ" in script.read_text("utf-8") + assert json.loads((juego / "resourcepacks" / "Traduccion_ES" / "assets" / "m" / "lang" / "es_mx.json").read_bytes()) + c = _cli("cobertura", carpeta, datos=datos) + assert c.returncode == 0 and "Traceback" not in c.stderr + r = _cli("restaurar", carpeta, datos=datos) + assert r.returncode == 0 and script.read_text("utf-8") == original diff --git a/tests/test_verificacion_validacion.py b/tests/test_verificacion_validacion.py new file mode 100644 index 0000000..dc403d5 --- /dev/null +++ b/tests/test_verificacion_validacion.py @@ -0,0 +1,87 @@ +"""Casos límite de las reglas de calidad y de la revisión automática.""" + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from traductor_es.validacion.calidad import revisar_glosario, validar +from traductor_es.validacion.revision import ResumenRevision, interpretar_veredicto + +SUAVE = settings(max_examples=200, suppress_health_check=[HealthCheck.too_slow], deadline=None) + + +@pytest.mark.parametrize( + ("original", "traduccion", "motivo"), + [ + ("Iron Plate", "Placa de hierro", None), + ("Iron Plate", "", "vacía"), + ("Iron Plate", " \t ", "vacía"), + ("One line", "Dos\nlíneas", "salto de línea nuevo"), + ("One\nline", "Una\nlínea", None), + ("A long enough original text here", "x", "largo raro"), + ("Short", "Un texto absurdamente largo para algo tan corto, de verdad que sí lo es", None), + ( + "You can use the tool to break the stone and then place it with your hand", + "You can use the tool to break the stone and then place it with your hand", + "sin traducir", + ), + ("⟦0⟧ and ⟦1⟧", "⟦0⟧ y ⟦1⟧", None), + ("Ñandú ¡Ágüita! cañón", "Ñandú ¡Ágüita! cañón", None), # nombres propios iguales: no es error + ("日本語のテキスト", "日本語のテキスト", None), + ], +) +def test_validar_casos(original: str, traduccion: str, motivo: str | None) -> None: + assert validar(original, traduccion) == motivo + + +@SUAVE +@given(st.text(max_size=200), st.text(max_size=200)) +def test_validar_nunca_falla(original: str, traduccion: str) -> None: + resultado = validar(original, traduccion) + assert resultado is None or isinstance(resultado, str) + + +def test_revisar_glosario_con_regex_y_utf8() -> None: + glosario = {"C++": "C++", "(Test)": "(Prueba)", "Wheat": "Trigo", "Ñu": "Ñu"} + traducciones = { + "Use C++ here": "Usa C++ aquí", + "A (Test) item": "Un objeto", + "Wheat bread": "Pan de cebada", + "Ñu is Ñu": "Ñu es Ñu", + } + r = revisar_glosario(traducciones, glosario, list(glosario)) + assert r.inconsistentes == {"(Test)": 1, "Wheat": 1} + assert "Términos del glosario no respetados" in str(r) + assert str(revisar_glosario({}, glosario, [])) == "Glosario respetado en todas las traducciones." + + +@pytest.mark.parametrize( + ("valor", "esperado"), + [ + ("OK", None), + ("ok, todo bien", None), + ("", None), + ("grave|exactitud|cambia el sentido", ("grave", "exactitud", "cambia el sentido")), + ("Crítica | Terminología | falta el glosario", ("critica", "terminologia", "falta el glosario")), + ("grave|exactitud", None), # falta la explicación + ("enorme|exactitud|x", None), # gravedad desconocida + ("grave|ortografia|x", None), # categoría desconocida + ("grave|estilo|" + "x" * 1000, ("grave", "estilo", "x" * 300)), # se recorta + ("grave|estilo|con | barras | extra", ("grave", "estilo", "con | barras | extra")), + ], +) +def test_veredicto(valor: str, esperado: tuple[str, str, str] | None) -> None: + assert interpretar_veredicto(valor) == esperado + + +@SUAVE +@given(st.text(max_size=100)) +def test_veredicto_nunca_falla(valor: str) -> None: + interpretar_veredicto(valor) + + +def test_resumen_de_revision_suma_y_se_lee() -> None: + a = ResumenRevision(revisados=3, marcados={"exactitud": 1}, leves=1, corregidos=1) + a.sumar(ResumenRevision(revisados=2, marcados={"exactitud": 1, "estilo": 2}, leves=0, corregidos=2)) + assert a.revisados == 5 and a.marcados == {"exactitud": 2, "estilo": 2} and a.corregidos == 3 + assert "4 con errores graves" in str(a) and "ninguno con errores graves" in str(ResumenRevision(revisados=7)) From e86c88348929a156bfaf4b7c4a23370d1df0f827 Mon Sep 17 00:00:00 2001 From: Cristian Date: Fri, 6 Nov 2026 01:49:16 -0500 Subject: [PATCH 4/6] =?UTF-8?q?feat(runtime):=20mod=20de=20apoyo=20para=20?= =?UTF-8?q?el=20texto=20escrito=20en=20el=20c=C3=B3digo=20de=20los=20mods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Módulo TextoDeJava: traduce las frases del código Java a una tabla del resource pack. - Mod de cliente ModpackES Runtime (Forge 1.20.1) que cambia chat, barra de acción y tooltips cuyo texto es idéntico al de la tabla; con pruebas JUnit. - Instalar y quitar el mod desde la ventana y con `instalar-runtime`; opción --java. - scripts/construir_mod.py y paso de CI para compilarlo; el .exe lo incluye. --- .github/workflows/ci.yml | 11 + .gitignore | 4 + CHANGELOG.md | 5 + CONTRIBUTING.md | 10 + README.md | 3 + mod-runtime/forge-1.20.1/.gitignore | 7 + mod-runtime/forge-1.20.1/build.gradle | 81 ++++++ mod-runtime/forge-1.20.1/gradle.properties | 18 ++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 62076 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + mod-runtime/forge-1.20.1/gradlew | 245 ++++++++++++++++++ mod-runtime/forge-1.20.1/gradlew.bat | 92 +++++++ mod-runtime/forge-1.20.1/settings.gradle | 13 + .../consterna/modpackes/ModpackESRuntime.java | 118 +++++++++ .../consterna/modpackes/Reescritor.java | 79 ++++++ .../io/github/consterna/modpackes/Tabla.java | 87 +++++++ .../src/main/resources/META-INF/mods.toml | 26 ++ .../src/main/resources/pack.mcmeta | 8 + .../consterna/modpackes/ReescritorTest.java | 76 ++++++ .../github/consterna/modpackes/TablaTest.java | 76 ++++++ scripts/construir_mod.py | 79 ++++++ src/traductor_es/__main__.py | 40 ++- src/traductor_es/modulos/cobertura.py | 32 ++- src/traductor_es/modulos/runtime.py | 65 +++++ src/traductor_es/proceso.py | 6 +- src/traductor_es/salida/mod_apoyo.py | 75 ++++++ src/traductor_es/ui/ventana.py | 51 +++- tests/test_runtime.py | 151 +++++++++++ tests/test_ui.py | 20 ++ 29 files changed, 1463 insertions(+), 21 deletions(-) create mode 100644 mod-runtime/forge-1.20.1/.gitignore create mode 100644 mod-runtime/forge-1.20.1/build.gradle create mode 100644 mod-runtime/forge-1.20.1/gradle.properties create mode 100644 mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.jar create mode 100644 mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.properties create mode 100644 mod-runtime/forge-1.20.1/gradlew create mode 100644 mod-runtime/forge-1.20.1/gradlew.bat create mode 100644 mod-runtime/forge-1.20.1/settings.gradle create mode 100644 mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java create mode 100644 mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/Reescritor.java create mode 100644 mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/Tabla.java create mode 100644 mod-runtime/forge-1.20.1/src/main/resources/META-INF/mods.toml create mode 100644 mod-runtime/forge-1.20.1/src/main/resources/pack.mcmeta create mode 100644 mod-runtime/forge-1.20.1/src/test/java/io/github/consterna/modpackes/ReescritorTest.java create mode 100644 mod-runtime/forge-1.20.1/src/test/java/io/github/consterna/modpackes/TablaTest.java create mode 100644 scripts/construir_mod.py create mode 100644 src/traductor_es/modulos/runtime.py create mode 100644 src/traductor_es/salida/mod_apoyo.py create mode 100644 tests/test_runtime.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ea186a..95c8c9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,17 @@ jobs: - name: Puerta de calidad (ruff, pyright y pytest con el validador Java) run: uv run python scripts/verificar.py + - id: java17 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: "17" # ForgeGradle 6 (Forge 1.20.1) pide Java 17 + + - name: Compilar el mod de apoyo (ModpackES Runtime) con sus pruebas + run: uv run python scripts/construir_mod.py + env: + JAVA_HOME: ${{ steps.java17.outputs.path }} + - name: Construir ModpackES.exe run: uv run python scripts/construir_exe.py diff --git a/.gitignore b/.gitignore index b142568..a55e258 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ build_exe.log # Prototipo antiguo que quedó en la carpeta del proyecto (no forma parte de la app) /traducir_mods.py /TraductorModpacks/ + +# Herramientas de compilación del mod de apoyo (JDK, Gradle, caché de Forge) y su jar compilado +.herramientas/ +src/traductor_es/recursos/mods/*.jar diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d83f6..a18018f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ Todos los cambios importantes de ModpackES quedan en este archivo. El formato si ### Agregado +- **Texto escrito en el código de los mods** (opcional, apagado por defecto): la app traduce las frases que encuentra en el código Java de los + mods y las guarda en una tabla del resource pack; el nuevo mod de apoyo **ModpackES Runtime** (solo cliente, Forge 1.20.1) las muestra en + español en el chat, la barra de acción y los tooltips. Solo cambia textos idénticos a los de la tabla. «Más ▸ Instalar el mod de apoyo» y + «Quitar el mod de apoyo», `modpackes instalar-runtime` y `--java` en `analizar` y `traducir`. El CI compila el mod con sus pruebas. + - **Mensajes de chat y datos de texto**, que antes quedaban en inglés: - El texto de `tellraw`, `title`, `say`, `msg`, `tell` y `w` en las funciones (`.mcfunction`) y en lo que un script de KubeJS manda con `runCommand` o `runCommandSilent`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2a08ce..013353f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,6 +93,16 @@ traduzca un caso real recortado, otra con algo que **no** debe tocarse y la de i originales). `uv run python scripts/descargar_corpus.py` baja partes de modpacks públicos de Modrinth a `.descargas/` (Git lo ignora); `uv run pytest -m corpus` los usa para comprobar que lo traducido sigue siendo válido. +## El mod de apoyo (Java) + +`mod-runtime/forge-1.20.1/` es un mod de Forge solo de cliente que cambia, al mostrarlos, los textos que otros mods tienen en su código y que están en la tabla de la app. Se compila con Gradle y ForgeGradle 6 y necesita un JDK 17: + +``` +uv run python scripts/construir_mod.py +``` + +Corre las pruebas de Java (JUnit) y deja el jar en `src/traductor_es/recursos/mods/` (Git lo ignora; el CI lo construye y `construir_exe.py` lo incluye). Un loader o versión nuevo es un proyecto nuevo en `mod-runtime/` y una entrada en `SOPORTADOS` de `salida/mod_apoyo.py`. + ## Licencia de las contribuciones ModpackES usa la licencia [GPL-3.0-or-later](LICENSE). Al mandar un cambio aceptas que se publique bajo esa diff --git a/README.md b/README.md index 552bfbe..cdda3ca 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ y **cualquier lanzador**, y escribe a la vez las siete variantes de español del | Libros guía | Patchouli, Mantle (Tinkers), Citadel (Alex's Mobs), GuideME (AE2), Lavender. Modonomicon va por claves de idioma | Resource pack (o en su sitio, si es un libro propio del modpack) | | Scripts de KubeJS | Lo que ve el jugador: `tell`, `notify`, `displayName`, `Text.of`, tooltips, información de JEI/REI/EMI, los mensajes armados por partes antes de un `tell`, y el texto de `tellraw`, `title` y `say` en `runCommand` | En su sitio, con copia de seguridad | | Datapacks y tips | Funciones (`.mcfunction`: `tellraw`, `title`, `say`), títulos y descripciones de logros, y consejos de pantalla de carga (mod Tips). Se buscan por su estructura (`data//functions/…`), estén en `kubejs/data`, `global_packs`, `datapacks`, `config/openloader` o en los datapacks de un mundo, sueltos o dentro de un `.zip`. Los componentes de texto valen en JSON (hasta 1.21.4) y en SNBT (desde 1.21.5) | En su sitio, con copia de seguridad | +| Texto escrito en el código de los mods | Mensajes de chat, de la barra de acción y tooltips que un mod tiene en su código Java. Un resource pack no los alcanza: la app los traduce a una tabla que lee el mod de apoyo **ModpackES Runtime** (solo cliente, Forge 1.20.1 por ahora) | Tabla en el resource pack; el mod se instala solo si lo pides | Lo que el mod ya trae en español no se toca, y si existe una variante (por ejemplo `es_es`) se copia a las demás sin gastar. Los libros antiguos de Patchouli que guardan el texto dentro del jar no se pueden traducir @@ -85,6 +86,8 @@ traducir indicando el error. La nueva traducción tiene que pasar las mismas reg Tus correcciones nunca se revisan. Sube el costo de esos textos (el análisis lo incluye) y sus marcas no son infalibles: sirven para decidir qué rehacer, no como nota de calidad. +**Texto del código de los mods** (opcional): activa «Incluir texto escrito en el código de los mods» en «Opciones avanzadas» y pulsa **Más ▸ Instalar el mod de apoyo**. La app traduce las frases que encuentra en el código de los mods y el mod de apoyo las muestra en español en el chat, la barra de acción y los tooltips. Solo cambia un texto si es **idéntico** al de la tabla (no hay traducción en vivo ni en internet), no cubre pantallas ni frases con nombres o números en medio, y por ahora existe para Forge 1.20.1. **Más ▸ Quitar el mod de apoyo** lo retira de la carpeta mods; en la línea de comandos: `modpackes instalar-runtime RUTA [--quitar]` y `--java` en `analizar` y `traducir`. Para comprobarlo en el juego, con el mod instalado y el juego en español, escribe `/tellraw @s "TPA request expired!"` (o cualquier frase de la tabla). + **Más ▸ Qué queda en inglés** (o `modpackes cobertura RUTA [--java]`) lista lo que no se traduce y por qué, sin cambiar nada: frases de scripts cuyo uso no se entiende y texto escrito en el código de los mods, que un resource pack no puede alcanzar. El análisis ya avisa de las frases de scripts. **Más ▸ Restaurar el original** devuelve misiones, scripts y libros propios al texto del modpack. Para quitar diff --git a/mod-runtime/forge-1.20.1/.gitignore b/mod-runtime/forge-1.20.1/.gitignore new file mode 100644 index 0000000..96458f6 --- /dev/null +++ b/mod-runtime/forge-1.20.1/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +run/ +out/ +*.iml +.idea/ +eclipse/ diff --git a/mod-runtime/forge-1.20.1/build.gradle b/mod-runtime/forge-1.20.1/build.gradle new file mode 100644 index 0000000..406b1a6 --- /dev/null +++ b/mod-runtime/forge-1.20.1/build.gradle @@ -0,0 +1,81 @@ +plugins { + id 'eclipse' + id 'idea' + id 'net.minecraftforge.gradle' version '[6.0,6.2)' +} + +version = mod_version +group = mod_group_id + +base { + archivesName = "${mod_id}-forge-${minecraft_version}" +} + +java.toolchain.languageVersion = JavaLanguageVersion.of(17) + +minecraft { + mappings channel: mapping_channel, version: mapping_version + + runs { + client { + workingDirectory project.file('run') + property 'forge.logging.markers', 'REGISTRIES' + property 'forge.logging.console.level', 'debug' + mods { + "${mod_id}" { + source sourceSets.main + } + } + } + } +} + +repositories { + mavenCentral() + maven { url = 'https://libraries.minecraft.net/' } +} + +dependencies { + minecraft "net.minecraftforge:forge:${minecraft_version}-${forge_version}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.2' +} + +tasks.named('processResources', ProcessResources).configure { + var replaceProperties = [ + minecraft_version: minecraft_version, minecraft_version_range: minecraft_version_range, + forge_version: forge_version, forge_version_range: forge_version_range, + loader_version_range: loader_version_range, + mod_id: mod_id, mod_name: mod_name, mod_license: mod_license, mod_version: mod_version, + mod_authors: mod_authors, mod_description: mod_description, + ] + inputs.properties replaceProperties + + filesMatching(['META-INF/mods.toml', 'pack.mcmeta']) { + expand replaceProperties + [project: project] + } +} + +tasks.named('jar', Jar).configure { + manifest { + attributes([ + 'Specification-Title' : mod_id, + 'Specification-Vendor' : mod_authors, + 'Specification-Version' : '1', + 'Implementation-Title' : project.name, + 'Implementation-Version' : project.jar.archiveVersion, + 'Implementation-Vendor' : mod_authors + ]) + } + + finalizedBy 'reobfJar' +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + +tasks.named('test', Test).configure { + useJUnitPlatform() +} diff --git a/mod-runtime/forge-1.20.1/gradle.properties b/mod-runtime/forge-1.20.1/gradle.properties new file mode 100644 index 0000000..0883481 --- /dev/null +++ b/mod-runtime/forge-1.20.1/gradle.properties @@ -0,0 +1,18 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.daemon=false + +minecraft_version=1.20.1 +minecraft_version_range=[1.20.1,1.20.2) +forge_version=47.4.0 +forge_version_range=[47,48) +loader_version_range=[47,48) +mapping_channel=official +mapping_version=1.20.1 + +mod_id=modpackes_runtime +mod_name=ModpackES Runtime +mod_license=GPL-3.0-or-later +mod_version=0.1.0 +mod_group_id=io.github.consterna.modpackes +mod_authors=Consterna +mod_description=Muestra en español el texto que otros mods escriben en su código y que un resource pack no puede traducir. Solo cambia mensajes de chat, de la barra de acción y tooltips cuyo texto está en la tabla que genera ModpackES. diff --git a/mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.jar b/mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..c1962a79e29d3e0ab67b14947c167a862655af9b GIT binary patch literal 62076 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 literal 0 HcmV?d00001 diff --git a/mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.properties b/mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2617362 --- /dev/null +++ b/mod-runtime/forge-1.20.1/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod-runtime/forge-1.20.1/gradlew b/mod-runtime/forge-1.20.1/gradlew new file mode 100644 index 0000000..aeb74cb --- /dev/null +++ b/mod-runtime/forge-1.20.1/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod-runtime/forge-1.20.1/gradlew.bat b/mod-runtime/forge-1.20.1/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/mod-runtime/forge-1.20.1/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod-runtime/forge-1.20.1/settings.gradle b/mod-runtime/forge-1.20.1/settings.gradle new file mode 100644 index 0000000..93f1b80 --- /dev/null +++ b/mod-runtime/forge-1.20.1/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { + name = 'MinecraftForge' + url = 'https://maven.minecraftforge.net/' + } + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.7.0' +} \ No newline at end of file diff --git a/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java b/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java new file mode 100644 index 0000000..640e732 --- /dev/null +++ b/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/ModpackESRuntime.java @@ -0,0 +1,118 @@ +package io.github.consterna.modpackes; + +import com.mojang.logging.LogUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.SimplePreparableReloadListener; +import net.minecraft.util.profiling.ProfilerFiller; +import net.minecraftforge.client.event.ClientChatReceivedEvent; +import net.minecraftforge.client.event.RegisterClientReloadListenersEvent; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.entity.player.ItemTooltipEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.Reader; +import java.util.List; + +/** + * Muestra en español el texto que otros mods escriben en su código. Solo cambia mensajes de chat, de la barra de + * acción y tooltips cuyo texto sea exactamente uno de la tabla {@code assets/modpackes_runtime/textos/.json} + * que genera ModpackES. Si el juego no está en una variante de español, no hace nada. + */ +@Mod(ModpackESRuntime.ID) +public final class ModpackESRuntime { + public static final String ID = "modpackes_runtime"; + private static final Logger LOG = LogUtils.getLogger(); + + private static volatile Tabla tabla = Tabla.VACIA; + private static boolean avisado; + + public ModpackESRuntime() { + FMLJavaModLoadingContext.get().getModEventBus().addListener(ModpackESRuntime::alRegistrarRecargas); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alRecibirChat); + MinecraftForge.EVENT_BUS.addListener(ModpackESRuntime::alCrearTooltip); + } + + private static void alRegistrarRecargas(RegisterClientReloadListenersEvent evento) { + evento.registerReloadListener(new Cargador()); + } + + private static void alRecibirChat(ClientChatReceivedEvent evento) { + try { + Component nuevo = Reescritor.reescribir(evento.getMessage(), tabla); + if (nuevo != null) { + evento.setMessage(nuevo); + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + private static void alCrearTooltip(ItemTooltipEvent evento) { + try { + Tabla actual = tabla; + if (actual.vacia()) { + return; + } + List lineas = evento.getToolTip(); + for (int i = 0; i < lineas.size(); i++) { + Component nuevo = Reescritor.reescribir(lineas.get(i), actual); + if (nuevo != null) { + lineas.set(i, nuevo); + } + } + } catch (RuntimeException | LinkageError e) { + avisar(e); + } + } + + /** Un fallo se anota una sola vez: el juego nunca debe romperse ni llenar el registro por esto. */ + private static synchronized void avisar(Throwable e) { + if (!avisado) { + avisado = true; + LOG.warn("ModpackES Runtime: no pude cambiar un texto; se deja el original", e); + } + } + + /** Variantes de la tabla a probar, en orden. Vacío si el juego no está en español. */ + static List variantes(String codigoDeIdioma) { + if (codigoDeIdioma == null || !codigoDeIdioma.toLowerCase().startsWith("es_")) { + return List.of(); + } + String codigo = codigoDeIdioma.toLowerCase(); + return List.of(codigo, "es_mx", "es_es").stream().distinct().toList(); + } + + private static final class Cargador extends SimplePreparableReloadListener { + @Override + protected Tabla prepare(ResourceManager recursos, ProfilerFiller perfil) { + Tabla total = Tabla.VACIA; + for (String variante : variantes(Minecraft.getInstance().options.languageCode)) { + ResourceLocation ruta = new ResourceLocation(ID, "textos/" + variante + ".json"); + for (Resource recurso : recursos.getResourceStack(ruta)) { // de menor a mayor prioridad + try (Reader lector = recurso.openAsReader()) { + total = total.combinar(Tabla.leer(lector)); + } catch (IOException | RuntimeException e) { + LOG.warn("ModpackES Runtime: no pude leer {} de {}", ruta, recurso.sourcePackId(), e); + } + } + if (!total.vacia()) { + break; + } + } + return total; + } + + @Override + protected void apply(Tabla nueva, ResourceManager recursos, ProfilerFiller perfil) { + tabla = nueva; + LOG.info("ModpackES Runtime: {} textos en la tabla", nueva.tamano()); + } + } +} diff --git a/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/Reescritor.java b/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/Reescritor.java new file mode 100644 index 0000000..577996e --- /dev/null +++ b/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/Reescritor.java @@ -0,0 +1,79 @@ +package io.github.consterna.modpackes; + +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentContents; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.contents.LiteralContents; +import net.minecraft.network.chat.contents.TranslatableContents; + +import java.util.ArrayList; +import java.util.List; + +/** + * Cambia el texto literal de un componente por su traducción, sin tocar estilos, colores, eventos de clic ni + * el resto de la estructura. Nunca lanza por un componente raro: si algo no se entiende, se deja igual. + */ +public final class Reescritor { + private Reescritor() { + } + + /** El componente traducido, o {@code null} si no cambió nada (así no se copia lo que no hace falta). */ + public static Component reescribir(Component original, Tabla tabla) { + if (original == null || tabla.vacia()) { + return null; + } + ComponentContents contenido = original.getContents(); + ComponentContents nuevoContenido = contenido; + boolean cambio = false; + + if (contenido instanceof LiteralContents literal) { + String traducido = tabla.traducir(literal.text()); + if (traducido != null) { + nuevoContenido = new LiteralContents(traducido); + cambio = true; + } + } else if (contenido instanceof TranslatableContents traducible) { + Object[] argumentos = traducible.getArgs(); + Object[] nuevos = null; + for (int i = 0; i < argumentos.length; i++) { + Object reemplazo = null; + if (argumentos[i] instanceof Component hijo) { + reemplazo = reescribir(hijo, tabla); + } else if (argumentos[i] instanceof String texto) { + reemplazo = tabla.traducir(texto); + } + if (reemplazo != null) { + if (nuevos == null) { + nuevos = argumentos.clone(); + } + nuevos[i] = reemplazo; + } + } + if (nuevos != null) { + nuevoContenido = new TranslatableContents(traducible.getKey(), traducible.getFallback(), nuevos); + cambio = true; + } + } + + List hermanos = original.getSiblings(); + List nuevosHermanos = null; + for (int i = 0; i < hermanos.size(); i++) { + Component reemplazo = reescribir(hermanos.get(i), tabla); + if (reemplazo != null) { + if (nuevosHermanos == null) { + nuevosHermanos = new ArrayList<>(hermanos); + } + nuevosHermanos.set(i, reemplazo); + } + } + + if (!cambio && nuevosHermanos == null) { + return null; + } + MutableComponent resultado = MutableComponent.create(nuevoContenido).setStyle(original.getStyle()); + for (Component hermano : nuevosHermanos != null ? nuevosHermanos : hermanos) { + resultado.append(hermano); + } + return resultado; + } +} diff --git a/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/Tabla.java b/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/Tabla.java new file mode 100644 index 0000000..7f8bc02 --- /dev/null +++ b/mod-runtime/forge-1.20.1/src/main/java/io/github/consterna/modpackes/Tabla.java @@ -0,0 +1,87 @@ +package io.github.consterna.modpackes; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; + +import java.io.IOException; +import java.io.Reader; +import java.util.HashMap; +import java.util.Map; + +/** + * Tabla de textos «inglés exacto → español» que genera ModpackES. Solo hay coincidencia si el texto es idéntico + * (salvo los espacios de los bordes, que se conservan): no hay traducción aproximada ni en vivo. + */ +public final class Tabla { + public static final Tabla VACIA = new Tabla(Map.of()); + + private final Map textos; + + public Tabla(Map textos) { + this.textos = Map.copyOf(textos); + } + + /** Lee un objeto JSON {"inglés": "español"}. Lo que no sea texto se ignora. */ + public static Tabla leer(Reader lector) throws IOException { + JsonElement raiz; + try { + raiz = JsonParser.parseReader(lector); + } catch (JsonParseException e) { + throw new IOException("La tabla de textos no es JSON válido", e); + } + if (!raiz.isJsonObject()) { + throw new IOException("La tabla de textos no es un objeto JSON"); + } + JsonObject objeto = raiz.getAsJsonObject(); + Map mapa = new HashMap<>(); + for (Map.Entry e : objeto.entrySet()) { + JsonElement v = e.getValue(); + if (v.isJsonPrimitive() && v.getAsJsonPrimitive().isString() && !v.getAsString().isEmpty()) { + mapa.put(e.getKey(), v.getAsString()); + } + } + return new Tabla(mapa); + } + + /** Une dos tablas; si un texto está en las dos, manda la de {@code otra}. */ + public Tabla combinar(Tabla otra) { + if (otra.textos.isEmpty()) { + return this; + } + Map mapa = new HashMap<>(textos); + mapa.putAll(otra.textos); + return new Tabla(mapa); + } + + public boolean vacia() { + return textos.isEmpty(); + } + + public int tamano() { + return textos.size(); + } + + /** La traducción de {@code texto}, o {@code null} si no está en la tabla o no cambia nada. */ + public String traducir(String texto) { + if (texto == null || texto.isEmpty() || textos.isEmpty()) { + return null; + } + String directo = textos.get(texto); + if (directo != null) { + return directo.equals(texto) ? null : directo; + } + String nucleo = texto.strip(); + if (nucleo.isEmpty() || nucleo.length() == texto.length()) { + return null; + } + String traduccion = textos.get(nucleo); + if (traduccion == null || traduccion.equals(nucleo)) { + return null; + } + int inicio = texto.length() - texto.stripLeading().length(); + int fin = texto.length() - texto.stripTrailing().length(); + return texto.substring(0, inicio) + traduccion + texto.substring(texto.length() - fin); + } +} diff --git a/mod-runtime/forge-1.20.1/src/main/resources/META-INF/mods.toml b/mod-runtime/forge-1.20.1/src/main/resources/META-INF/mods.toml new file mode 100644 index 0000000..94577b0 --- /dev/null +++ b/mod-runtime/forge-1.20.1/src/main/resources/META-INF/mods.toml @@ -0,0 +1,26 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" +# Solo cliente: Forge no lo carga en servidores dedicados y un servidor sin el mod no marca error. +clientSideOnly=true + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +authors="${mod_authors}" +description='''${mod_description}''' + +[[dependencies.${mod_id}]] + modId="forge" + mandatory=true + versionRange="${forge_version_range}" + ordering="NONE" + side="CLIENT" + +[[dependencies.${mod_id}]] + modId="minecraft" + mandatory=true + versionRange="${minecraft_version_range}" + ordering="NONE" + side="CLIENT" diff --git a/mod-runtime/forge-1.20.1/src/main/resources/pack.mcmeta b/mod-runtime/forge-1.20.1/src/main/resources/pack.mcmeta new file mode 100644 index 0000000..e22de93 --- /dev/null +++ b/mod-runtime/forge-1.20.1/src/main/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": { + "text": "${mod_name}" + }, + "pack_format": 15 + } +} diff --git a/mod-runtime/forge-1.20.1/src/test/java/io/github/consterna/modpackes/ReescritorTest.java b/mod-runtime/forge-1.20.1/src/test/java/io/github/consterna/modpackes/ReescritorTest.java new file mode 100644 index 0000000..3e93f7d --- /dev/null +++ b/mod-runtime/forge-1.20.1/src/test/java/io/github/consterna/modpackes/ReescritorTest.java @@ -0,0 +1,76 @@ +package io.github.consterna.modpackes; + +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.contents.TranslatableContents; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReescritorTest { + private static final Tabla TABLA = new Tabla(Map.of( + "TPA request expired!", "¡La solicitud de TPA expiró!", + "Hello", "Hola", + "Click here", "Haz clic aquí")); + + @Test + void cambiaUnLiteralYConservaElEstilo() { + Style estilo = Style.EMPTY.withColor(ChatFormatting.RED).withBold(true); + Component original = Component.literal("TPA request expired!").setStyle(estilo); + Component nuevo = Reescritor.reescribir(original, TABLA); + assertEquals("¡La solicitud de TPA expiró!", nuevo.getString()); + assertEquals(estilo, nuevo.getStyle()); + } + + @Test + void sinCoincidenciaDevuelveNullYNoCopia() { + assertNull(Reescritor.reescribir(Component.literal("Nothing to see here"), TABLA)); + assertNull(Reescritor.reescribir(Component.literal("Hello"), Tabla.VACIA)); + assertNull(Reescritor.reescribir(null, TABLA)); + } + + @Test + void recorreLosHermanosYSoloCambiaLosQueCoinciden() { + MutableComponent raiz = Component.literal(" ").withStyle(ChatFormatting.AQUA); + raiz.append(Component.literal("Hello").withStyle(ChatFormatting.WHITE)); + raiz.append(Component.literal(" Steve")); + Component nuevo = Reescritor.reescribir(raiz, TABLA); + assertEquals(" Hola Steve", nuevo.getString()); + assertEquals(ChatFormatting.AQUA.getColor(), nuevo.getStyle().getColor().getValue()); + assertEquals(ChatFormatting.WHITE.getColor(), nuevo.getSiblings().get(0).getStyle().getColor().getValue()); + assertSame(raiz.getSiblings().get(1), nuevo.getSiblings().get(1)); // lo que no cambia, no se copia + } + + @Test + void conservaElEventoDeClic() { + Style estilo = Style.EMPTY.withClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://example.com")); + Component nuevo = Reescritor.reescribir(Component.literal("Click here").setStyle(estilo), TABLA); + assertEquals("Haz clic aquí", nuevo.getString()); + assertEquals("https://example.com", nuevo.getStyle().getClickEvent().getValue()); + } + + @Test + void cambiaLosArgumentosDeUnTraducible() { + Component original = Component.translatable("chat.type.text", Component.literal("Hello"), "Hello"); + Component nuevo = Reescritor.reescribir(original, TABLA); + TranslatableContents contenido = (TranslatableContents) nuevo.getContents(); + assertEquals("chat.type.text", contenido.getKey()); + assertEquals("Hola", ((Component) contenido.getArgs()[0]).getString()); + assertEquals("Hola", contenido.getArgs()[1]); + } + + @Test + void noInventaTextoConEspaciosYAcentos() { + Tabla t = new Tabla(Map.of("Ñandú says hi", "Ñandú dice ¡hola! 😀")); + Component nuevo = Reescritor.reescribir(Component.literal(" Ñandú says hi "), t); + assertTrue(nuevo.getString().equals(" Ñandú dice ¡hola! 😀 ")); + } +} diff --git a/mod-runtime/forge-1.20.1/src/test/java/io/github/consterna/modpackes/TablaTest.java b/mod-runtime/forge-1.20.1/src/test/java/io/github/consterna/modpackes/TablaTest.java new file mode 100644 index 0000000..ab1b8c3 --- /dev/null +++ b/mod-runtime/forge-1.20.1/src/test/java/io/github/consterna/modpackes/TablaTest.java @@ -0,0 +1,76 @@ +package io.github.consterna.modpackes; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TablaTest { + private static Tabla tabla(String... pares) { + Map mapa = new java.util.HashMap<>(); + for (int i = 0; i < pares.length; i += 2) { + mapa.put(pares[i], pares[i + 1]); + } + return new Tabla(mapa); + } + + @Test + void coincidenciaExactaYSinCoincidencia() { + Tabla t = tabla("TPA request expired!", "¡La solicitud de TPA expiró!"); + assertEquals("¡La solicitud de TPA expiró!", t.traducir("TPA request expired!")); + assertNull(t.traducir("tpa request expired!")); // mayúsculas distintas: no es el mismo texto + assertNull(t.traducir("TPA request expired! Now")); + assertNull(t.traducir("")); + assertNull(t.traducir(null)); + } + + @Test + void conservaLosEspaciosDeLosBordes() { + Tabla t = tabla("Hello there", "Hola"); + assertEquals(" Hola\n", t.traducir(" Hello there\n")); + assertNull(t.traducir(" ")); + } + + @Test + void unTextoIgualQueSuTraduccionNoCuentaComoCambio() { + Tabla t = tabla("Creeper", "Creeper"); + assertNull(t.traducir("Creeper")); + } + + @Test + void conservaTildesEneYEmoji() { + Tabla t = tabla("Pick 😀", "Elige ñandú ¡sí! 日本語 §a%s"); + assertEquals("Elige ñandú ¡sí! 日本語 §a%s", t.traducir("Pick 😀")); + } + + @Test + void leeJsonValidoEIgnoraLoQueNoEsTexto() throws IOException { + Tabla t = Tabla.leer(new StringReader("{\"a b\": \"uno\", \"n\": 5, \"v\": \"\", \"l\": [\"x\"], \"z\": null}")); + assertEquals(1, t.tamano()); + assertEquals("uno", t.traducir("a b")); + } + + @Test + void jsonRotoOQueNoEsObjetoFallaConIOException() { + assertThrows(IOException.class, () -> Tabla.leer(new StringReader("{no es json"))); + assertThrows(IOException.class, () -> Tabla.leer(new StringReader("[1, 2]"))); + assertThrows(IOException.class, () -> Tabla.leer(new StringReader("\"texto\""))); + } + + @Test + void combinarDejaMandarALaSegunda() { + Tabla total = tabla("a b", "viejo", "c d", "queda").combinar(tabla("a b", "nuevo")); + assertEquals("nuevo", total.traducir("a b")); + assertEquals("queda", total.traducir("c d")); + assertTrue(Tabla.VACIA.vacia()); + assertFalse(total.vacia()); + assertEquals(total, total.combinar(Tabla.VACIA)); + } +} diff --git a/scripts/construir_mod.py b/scripts/construir_mod.py new file mode 100644 index 0000000..33b7a7b --- /dev/null +++ b/scripts/construir_mod.py @@ -0,0 +1,79 @@ +"""Compila el mod de apoyo (ModpackES Runtime) y deja el jar donde la aplicación lo busca. + +Uso: uv run python scripts/construir_mod.py [--sin-pruebas] + +Necesita un JDK 17 (la variable JAVA_HOME, o la carpeta `.herramientas/jdk-17*` si existe) y acceso a internet la +primera vez, para bajar Gradle, Forge y Minecraft. El resultado queda en `src/traductor_es/recursos/mods/`, que Git +ignora. +En GitHub Actions el jar lo construye el mismo comando (`.github/workflows/release.yml`). +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +RAIZ = Path(__file__).resolve().parent.parent +PROYECTO = RAIZ / "mod-runtime" / "forge-1.20.1" +DESTINO = RAIZ / "src" / "traductor_es" / "recursos" / "mods" + + +def _herramientas() -> Path: + """`.herramientas` de este repositorio o, si se trabaja en un worktree, la del repositorio principal.""" + for carpeta in (RAIZ, *RAIZ.parents): + if (carpeta / ".herramientas").is_dir(): + return carpeta / ".herramientas" + return RAIZ / ".herramientas" + + +HERRAMIENTAS = _herramientas() + + +def _jdk17() -> Path: + candidatos = [Path(os.environ["JAVA_HOME"])] if os.environ.get("JAVA_HOME") else [] + candidatos += sorted(HERRAMIENTAS.glob("jdk-17*")) + for c in candidatos: + if (c / "bin" / ("java.exe" if os.name == "nt" else "java")).is_file(): + return c + raise SystemExit("No encuentro un JDK 17: define JAVA_HOME o ponlo en .herramientas/jdk-17*.") + + +def _gradle() -> list[str]: + envoltorio = PROYECTO / ("gradlew.bat" if os.name == "nt" else "gradlew") + local = sorted(HERRAMIENTAS.glob("gradle-*/bin/gradle" + (".bat" if os.name == "nt" else ""))) + return [str(local[-1])] if local else [str(envoltorio)] + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--sin-pruebas", action="store_true", help="compila sin correr las pruebas de Java") + args = ap.parse_args() + jdk = _jdk17() + entorno = {**os.environ, "JAVA_HOME": str(jdk), "PATH": str(jdk / "bin") + os.pathsep + os.environ["PATH"]} + if HERRAMIENTAS.is_dir(): # la caché de Gradle y de Forge queda junto al proyecto y no en la carpeta del usuario + entorno.setdefault("GRADLE_USER_HOME", str(HERRAMIENTAS / "gradle-home")) + orden = [*_gradle(), "build", "--no-daemon", "--console=plain"] + if args.sin_pruebas: + orden += ["-x", "test"] + codigo = subprocess.run(orden, cwd=PROYECTO, env=entorno, check=False).returncode + if codigo: + return codigo + jars = sorted((PROYECTO / "build" / "libs").glob("modpackes_runtime-*.jar")) + if not jars: + print("La compilación terminó pero no hay jar en build/libs.", file=sys.stderr) + return 1 + DESTINO.mkdir(parents=True, exist_ok=True) + for viejo in DESTINO.glob("modpackes_runtime-*.jar"): + viejo.unlink() + final = DESTINO / jars[-1].name + shutil.copyfile(jars[-1], final) + print(f"Mod de apoyo listo: {final}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/traductor_es/__main__.py b/src/traductor_es/__main__.py index 73cc50b..3f53e3b 100644 --- a/src/traductor_es/__main__.py +++ b/src/traductor_es/__main__.py @@ -9,6 +9,7 @@ from typing import Any, cast from . import NOMBRE, __version__, configuracion +from .fuentes.instancia import Instancia from .fuentes.lanzadores import buscar from .motores import fabrica from .motores.base import Cancelado, ErrorFatal, Motor @@ -17,6 +18,7 @@ from .nucleo.dialectos import DIALECTOS from .proceso import GRUPOS, Informe, Proceso from .registro import LOGGER, configurar +from .salida import mod_apoyo def _escribir(texto: str) -> None: @@ -77,6 +79,11 @@ def comunes(p: argparse.ArgumentParser) -> None: p.add_argument("--lotes", action="store_true", help="usar la Batch API de Gemini (mitad de precio, tarda más)") p.add_argument("--contexto", default="", help="ambientación del modpack para el tono y los términos") p.add_argument("--revisar", action="store_true", help="revisión automática de misiones y libros (cuesta más)") + p.add_argument( + "--java", + action="store_true", + help="también el texto escrito en el código de los mods (lo muestra el mod de apoyo; ver instalar-runtime)", + ) comunes(sub.add_parser("analizar", help="cuenta lo que falta traducir y estima el costo (no escribe nada)")) traducir = sub.add_parser("traducir", help="traduce y escribe el resultado") @@ -88,6 +95,11 @@ def comunes(p: argparse.ArgumentParser) -> None: cob = sub.add_parser("cobertura", help="dice qué texto queda en inglés y por qué (solo lee, no escribe nada)") cob.add_argument("carpeta", help="carpeta de la instancia (o su carpeta minecraft)") cob.add_argument("--java", action="store_true", help="también el texto escrito en el código de los mods (tarda)") + rt = sub.add_parser( + "instalar-runtime", help="pone o quita el mod de apoyo que muestra el texto del código de los mods" + ) + rt.add_argument("carpeta", help="carpeta de la instancia (o su carpeta minecraft)") + rt.add_argument("--quitar", action="store_true", help="quita el mod de apoyo de mods/") restaurar = sub.add_parser("restaurar", help="devuelve misiones, scripts y libros propios a su original") restaurar.add_argument("carpeta") modelos = sub.add_parser("modelos", help="lista los modelos que ofrece el motor") @@ -109,6 +121,7 @@ def _traducir_o_analizar(args: argparse.Namespace) -> None: with Proceso( args.carpeta, motor, args.dialecto, grupos, registro=_escribir, hilos=getattr(args, "hilos", 4), usar_lotes=args.lotes, contexto=args.contexto, revisar=args.revisar, + texto_java=args.java, ) as p: # fmt: skip _escribir(f"Minecraft {p.instancia.version} · {p.instancia.loader or '¿loader?'} · {p.instancia.juego}") if args.orden == "analizar": @@ -118,6 +131,24 @@ def _traducir_o_analizar(args: argparse.Namespace) -> None: _mostrar(informe, args) +def _instalar_runtime(args: argparse.Namespace) -> None: + with Instancia(args.carpeta) as inst: + if not args.quitar: + _escribir(f"Instalado: {mod_apoyo.instalar(inst)}") + return + quitados = mod_apoyo.quitar(inst) + _escribir("Quité: " + ", ".join(p.name for p in quitados) if quitados else "No había mod de apoyo instalado.") + + +def _cobertura(args: argparse.Namespace) -> None: + with Proceso(args.carpeta, Simulador(), registro=_escribir) as p: + _escribir(f"Minecraft {p.instancia.version} · {p.instancia.loader or '¿loader?'} · {p.instancia.juego}") + hallazgos = p.que_queda(java=args.java) + for h in hallazgos: + _escribir(f"[{h.categoria}] {h}") + _escribir(f"{len(hallazgos)} hallazgos." if hallazgos else "No encontré texto que quede en inglés.") + + def main(argv: Sequence[str] | None = None) -> int: cast(Any, sys.stdout).reconfigure(encoding="utf-8", errors="replace") args = _argumentos().parse_args(argv) @@ -127,13 +158,10 @@ def main(argv: Sequence[str] | None = None) -> int: if args.orden == "instancias": for e in buscar(): _escribir(f"{e.lanzador}: {e.nombre}\n {e.juego}") + elif args.orden == "instalar-runtime": + _instalar_runtime(args) elif args.orden == "cobertura": - with Proceso(args.carpeta, Simulador(), registro=_escribir) as p: - _escribir(f"Minecraft {p.instancia.version} · {p.instancia.loader or '¿loader?'} · {p.instancia.juego}") - hallazgos = p.que_queda(java=args.java) - for h in hallazgos: - _escribir(f"[{h.categoria}] {h}") - _escribir(f"{len(hallazgos)} hallazgos." if hallazgos else "No encontré texto que quede en inglés.") + _cobertura(args) elif args.orden == "clave": nombre = fabrica.MOTORES[args.motor] if args.accion == "guardar": diff --git a/src/traductor_es/modulos/cobertura.py b/src/traductor_es/modulos/cobertura.py index 57826fc..cb0e28e 100644 --- a/src/traductor_es/modulos/cobertura.py +++ b/src/traductor_es/modulos/cobertura.py @@ -24,7 +24,8 @@ #: `method_43470` (Fabric) y los `TextComponent`, `StringTextComponent` y `TextComponentString` anteriores. _MARCAS_TEXTO = ("literal", "m_237113_", "method_43470") _CLASES_TEXTO = ("TextComponent", "StringTextComponent", "TextComponentString") -_FRASE = re.compile(r"[A-Z][A-Za-z']+(?: [A-Za-z0-9,.'!?:%\-]+){2,}[.!?:]?") +#: Una frase en inglés: puede empezar con códigos de color (§c) y llevar %s, números y puntuación. +_FRASE = re.compile(r"(?:§[0-9a-fk-or])*[A-Z][A-Za-z']+(?: [§A-Za-z0-9,.'!?:%\-]+){2,}[.!?:]?") _ESPANOL = re.compile(r"\b(de|la|el|los|las|con|sin|para|por|una|un|del)\b") #: Frases de registros y errores internos: casi nunca llegan al jugador. _INTERNO = re.compile( @@ -112,9 +113,11 @@ def _frases_de_clase(datos: bytes) -> list[str]: ] # fmt: skip -def textos_en_java(instancia: Instancia, progreso: Callable[[int, int], None] = lambda _a, _b: None) -> list[Hallazgo]: - """Frases en inglés escritas en el código de los mods. Un resource pack no puede traducirlas.""" - res: list[Hallazgo] = [] +def frases_por_mod( + instancia: Instancia, progreso: Callable[[int, int], None] = lambda _a, _b: None +) -> list[tuple[Path, list[str]]]: + """Por cada mod, las frases en inglés escritas en su código y mostradas como texto (sin repetir).""" + res: list[tuple[Path, list[str]]] = [] jars = instancia.jars() for i, jar in enumerate(jars): progreso(i, len(jars)) @@ -132,11 +135,20 @@ def textos_en_java(instancia: Instancia, progreso: Callable[[int, int], None] = continue unicas = list(dict.fromkeys(f for f in frases if es_traducible(f) and not parece_clave(f))) if unicas: - res.append(Hallazgo( - "java", jar.name, len(unicas), - "Escrito en el código del mod: un resource pack no lo traduce", - tuple(f[:60] for f in unicas[:MAX_EJEMPLOS]), - )) # fmt: skip + res.append((jar, unicas)) + return res + + +def textos_en_java(instancia: Instancia, progreso: Callable[[int, int], None] = lambda _a, _b: None) -> list[Hallazgo]: + """Frases en inglés escritas en el código de los mods. Un resource pack no puede traducirlas.""" + res = [ + Hallazgo( + "java", jar.name, len(unicas), + "Escrito en el código del mod: un resource pack no lo traduce", + tuple(f[:60] for f in unicas[:MAX_EJEMPLOS]), + ) + for jar, unicas in frases_por_mod(instancia, progreso) + ] # fmt: skip return sorted(res, key=lambda h: -h.cantidad) @@ -150,4 +162,4 @@ def revisar_cobertura( return hallazgos -__all__ = ["Hallazgo", "revisar_cobertura"] +__all__ = ["Hallazgo", "frases_por_mod", "revisar_cobertura"] diff --git a/src/traductor_es/modulos/runtime.py b/src/traductor_es/modulos/runtime.py new file mode 100644 index 0000000..f0840d2 --- /dev/null +++ b/src/traductor_es/modulos/runtime.py @@ -0,0 +1,65 @@ +"""Texto escrito en el código Java de los mods: se traduce a una tabla que lee el mod de apoyo (ModpackES Runtime). + +Un resource pack no puede cambiar un texto que el mod tiene escrito en su código. El mod de apoyo, en el +cliente, cambia un mensaje de chat, de la barra de acción o un tooltip solo si su texto es **exactamente** uno de +la tabla `assets/modpackes_runtime/textos/.json` que se genera aquí (inglés → español). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from ..formatos import lang_json +from ..nucleo.mascara import es_traducible +from .base import Contexto, Pieza, Resumen, contar +from .cobertura import frases_por_mod + +AMBITO = "ui" +RUTA_TABLA = "assets/modpackes_runtime/textos/{variante}.json" + + +class TextoDeJava: + nombre = "Texto del código de los mods" + ambito = AMBITO + + def __init__(self, ctx: Contexto) -> None: + self.ctx = ctx + self._frases: list[tuple[Path, list[str]]] = [] + + def detectar(self) -> bool: + return bool(self.ctx.instancia.jars()) + + def describir(self) -> str: + total = sum(len(f) for _, f in self._frases) + return ( + f"{contar(total, 'frase')} en {contar(len(self._frases), 'mod')}" + if self._frases + else "frases del código de los mods" + ) + + def recolectar(self) -> list[Pieza]: + self._frases = frases_por_mod(self.ctx.instancia) + return [Pieza(f, f"texto del código de {jar.name}") for jar, frases in self._frases for f in frases] + + def escribir(self, traducciones: Mapping[str, str]) -> Resumen: + resumen = Resumen() + tabla: dict[str, str] = {} + for _, frases in self._frases: + for frase in frases: + traducida = traducciones.get(frase) + if traducida is not None and traducida != frase and es_traducible(frase): + tabla[frase] = traducida + else: + resumen.pendientes += 1 + resumen.traducidos = len(tabla) + if not tabla: + return resumen + pack = self.ctx.pack_listo() + datos = lang_json.escribir(dict(sorted(tabla.items()))) + for variante in self.ctx.variantes: # el mod lee la variante que use el jugador + resumen.archivos.append(pack.escribir(RUTA_TABLA.format(variante=variante), datos)) + return resumen + + def restaurar(self) -> list[Path]: + return [] # la tabla va en el resource pack: basta con desactivarlo diff --git a/src/traductor_es/proceso.py b/src/traductor_es/proceso.py index fd85f4d..7268e5e 100644 --- a/src/traductor_es/proceso.py +++ b/src/traductor_es/proceso.py @@ -12,7 +12,7 @@ from . import configuracion from .fuentes.instancia import Instancia -from .modulos import datos, libros, misiones, mods, scripts +from .modulos import datos, libros, misiones, mods, runtime, scripts from .modulos.base import Contexto, Modulo, Resumen from .modulos.cobertura import Hallazgo, hallazgos_de_scripts, revisar_cobertura from .modulos.mods import es_nombre @@ -114,9 +114,11 @@ def __init__( usar_lotes: bool = False, contexto: str = "", revisar: bool = False, + texto_java: bool = False, ) -> None: self.instancia = Instancia(carpeta) self.revisar = revisar + self.texto_java = texto_java self.registro = registro self.grupos = [g for g in GRUPOS if g in grupos] self._memoria_propia = memoria is None @@ -158,6 +160,8 @@ def modulos(self) -> list[tuple[str, Modulo]]: lista: list[tuple[str, Modulo]] = [] if "mods" in self.grupos: lista.append(("mods", mods.Mods(self.ctx))) + if self.texto_java: # texto escrito en el código de los mods: lo muestra el mod de apoyo + lista.append(("mods", runtime.TextoDeJava(self.ctx))) if "libros" in self.grupos: lista.append(("libros", libros.Libros(self.ctx))) if "misiones" in self.grupos: diff --git a/src/traductor_es/salida/mod_apoyo.py b/src/traductor_es/salida/mod_apoyo.py new file mode 100644 index 0000000..afd9ae0 --- /dev/null +++ b/src/traductor_es/salida/mod_apoyo.py @@ -0,0 +1,75 @@ +"""El mod de apoyo (ModpackES Runtime): qué versión le toca a una instancia y cómo ponerlo o quitarlo de `mods/`. + +El jar se construye aparte (`mod-runtime/`) y viaja dentro de la aplicación, en `traductor_es/recursos/mods/`. +La app solo lo copia cuando el jugador lo pide y solo lo quita si es uno de los suyos. +""" + +from __future__ import annotations + +import re +import shutil +from importlib import resources +from pathlib import Path + +from ..fuentes.instancia import Instancia + +PREFIJO = "modpackes_runtime-" +#: (loader, versión de Minecraft) para los que hay mod de apoyo. Cada entrada es un proyecto en `mod-runtime/`. +SOPORTADOS: dict[tuple[str, tuple[int, ...]], str] = {("forge", (1, 20, 1)): "forge-1.20.1"} +_NOMBRE = re.compile(rf"{re.escape(PREFIJO)}(?P[a-z]+-[\d.]+)-(?P[\w.+-]+)\.jar") + + +def _carpeta_de_jars() -> Path: + return Path(str(resources.files("traductor_es") / "recursos" / "mods")) + + +def destino_de(instancia: Instancia) -> str | None: + """«forge-1.20.1» si hay mod de apoyo para esta instancia; None si no.""" + return SOPORTADOS.get((instancia.loader or "", instancia.version.partes)) + + +def jar_empaquetado(instancia: Instancia, carpeta: Path | None = None) -> Path | None: + """El jar más nuevo del mod de apoyo para esta instancia, si viene con la aplicación.""" + destino = destino_de(instancia) + carpeta = carpeta or _carpeta_de_jars() + if destino is None or not carpeta.is_dir(): + return None + candidatos = [p for p in carpeta.glob(f"{PREFIJO}{destino}-*.jar") if p.is_file()] + return max(candidatos, key=lambda p: p.name, default=None) + + +def instalados(instancia: Instancia) -> list[Path]: + """Los jars de ModpackES Runtime que ya están en `mods/` (de cualquier versión).""" + carpeta = instancia.carpeta_mods + return sorted(p for p in carpeta.glob(f"{PREFIJO}*.jar") if _NOMBRE.fullmatch(p.name)) if carpeta.is_dir() else [] + + +def instalar(instancia: Instancia, carpeta_jars: Path | None = None) -> Path: + """Copia el mod de apoyo a `mods/` y quita sus versiones anteriores. ValueError si no hay uno.""" + destino = destino_de(instancia) + if destino is None: + loader = instancia.loader or "sin loader" + raise ValueError( + f"Todavía no hay mod de apoyo para Minecraft {instancia.version} con {loader}. " + "El texto escrito en el código de los mods seguirá en inglés." + ) + origen = jar_empaquetado(instancia, carpeta_jars) + if origen is None: + raise ValueError( + f"Esta copia de la aplicación no trae el mod de apoyo ({destino}). Descarga la versión completa." + ) + instancia.carpeta_mods.mkdir(parents=True, exist_ok=True) + final = instancia.carpeta_mods / origen.name + for viejo in instalados(instancia): + if viejo != final: + viejo.unlink() + shutil.copyfile(origen, final) + return final + + +def quitar(instancia: Instancia) -> list[Path]: + """Quita de `mods/` los jars del mod de apoyo. No toca ningún otro mod.""" + quitados = instalados(instancia) + for ruta in quitados: + ruta.unlink() + return quitados diff --git a/src/traductor_es/ui/ventana.py b/src/traductor_es/ui/ventana.py index ac048c9..7c6659e 100644 --- a/src/traductor_es/ui/ventana.py +++ b/src/traductor_es/ui/ventana.py @@ -47,7 +47,7 @@ from .. import AUTOR, NOMBRE, REPOSITORIO, __version__, actualizaciones, configuracion, registro from ..actualizaciones import Novedad -from ..fuentes.instancia import resolver_carpeta_juego +from ..fuentes.instancia import Instancia, resolver_carpeta_juego from ..fuentes.lanzadores import InstanciaEncontrada, buscar from ..modulos.cobertura import Hallazgo from ..motores import fabrica @@ -57,7 +57,7 @@ from ..motores.simulador import Simulador from ..nucleo.traductor import LARGO_CONTEXTO from ..proceso import GRUPOS, Informe, Proceso -from ..salida import exportar +from ..salida import exportar, mod_apoyo from . import correcciones, iconos, tema from .bienvenida import TarjetaBienvenida from .componentes import ( @@ -160,6 +160,7 @@ def ejecutar(self) -> None: usar_lotes=self.opciones["lotes"], contexto=self.opciones["contexto"], revisar=self.opciones["revisar"], + texto_java=self.opciones["texto_java"], ) as p: self.registro.emit(f"Minecraft {p.instancia.version} · {p.instancia.loader or '¿loader?'}") if self.accion == "analizar": @@ -263,7 +264,7 @@ def _construir(self) -> None: self.c_motor.setCurrentIndex(max(0, self.c_motor.findData(self._preferencias.get("motor", fabrica.GEMINI)))) self.c_motor.currentIndexChanged.connect(self._al_cambiar_motor) self._al_cambiar_motor() - for senal in (self.c_lotes.toggled, self.c_revisar.toggled, self.c_activar.toggled): + for senal in (self.c_lotes.toggled, self.c_revisar.toggled, self.c_activar.toggled, self.c_java.toggled): senal.connect(self._al_cambiar_opciones) self.c_hilos.valueChanged.connect(self._al_cambiar_opciones) self.c_dialecto.currentIndexChanged.connect(self._al_cambiar_opciones) @@ -454,6 +455,14 @@ def _opciones_avanzadas(self) -> QWidget: ) self.c_revisar.setChecked(bool(self._preferencias.get("revisar", False))) formulario.addRow("", self.c_revisar) + self.c_java = QCheckBox("Incluir texto escrito en el código de los mods (necesita el mod de apoyo)") + self.c_java.setToolTip( + "Algunos mods tienen mensajes escritos en su código y un resource pack no los alcanza. Esto los " + "traduce a una tabla que lee el mod de apoyo «ModpackES Runtime» (Más ▸ Instalar el mod de apoyo). " + "Solo Forge 1.20.1 por ahora." + ) + self.c_java.setChecked(bool(self._preferencias.get("texto_java", False))) + formulario.addRow("", self.c_java) self.c_hilos = QSpinBox() self.c_hilos.setRange(1, 16) self.c_hilos.setSuffix(" pedidos a la vez") @@ -503,6 +512,11 @@ def agregar(texto: str, nombre_icono: str, funcion: Callable[[], object]) -> QAc self.a_correcciones.setToolTip("Buscar y corregir a mano traducciones de la memoria") self.a_cobertura = agregar("Qué queda en inglés", "circle-alert", lambda: self.lanzar("cobertura")) self.a_cobertura.setToolTip("Lista el texto que no se puede traducir y por qué (solo lee, no cambia nada)") + self.a_apoyo = agregar("Instalar el mod de apoyo", "package", self._instalar_apoyo) + self.a_apoyo.setToolTip( + "Pone ModpackES Runtime en la carpeta mods: muestra en español el texto del código de los mods" + ) + self.a_quitar_apoyo = agregar("Quitar el mod de apoyo", "undo-2", self._quitar_apoyo) self.a_restaurar = agregar("Restaurar el original…", "undo-2", self._confirmar_restaurar) self.a_exportar = agregar("Exportar pack…", "package", lambda: self._exportar(kit=False)) self.a_kit = agregar("Kit de servidor…", "server", lambda: self._exportar(kit=True)) @@ -838,6 +852,7 @@ def opciones(self) -> dict[str, Any]: "idioma": self.c_activar.isChecked(), "contexto": self.e_contexto.text().strip(), "revisar": self.c_revisar.isChecked(), + "texto_java": self.c_java.isChecked(), } def _confirmar_traducir(self) -> None: @@ -979,6 +994,34 @@ def _terminar(self, resultado: object) -> None: self._al_cambiar_opciones() self.escribir(self.estado.text()) + def _con_instancia(self, accion: Callable[[Instancia], str]) -> None: + carpeta = self.carpeta() + if not carpeta: + self.avisar("Elige primero una instancia.") + return + try: + with Instancia(carpeta) as inst: + mensaje = accion(inst) + except (ValueError, OSError) as e: + self.avisar(str(e), "aviso", anotar=False) + return + self.escribir(mensaje) + self.aviso.mostrar(mensaje, "exito") + + def _instalar_apoyo(self) -> None: + self._con_instancia( + lambda inst: ( + f"Mod de apoyo instalado: {mod_apoyo.instalar(inst).name}. Abre el juego para que empiece a actuar." + ) + ) + + def _quitar_apoyo(self) -> None: + def quitar(inst: Instancia) -> str: + quitados = mod_apoyo.quitar(inst) + return "Quité: " + ", ".join(p.name for p in quitados) if quitados else "No había mod de apoyo instalado." + + self._con_instancia(quitar) + def _mostrar_cobertura(self, hallazgos: list[Hallazgo]) -> None: if not hallazgos: self.estado.setText("No encontré texto que se quede en inglés.") @@ -1114,7 +1157,7 @@ def _guardar_preferencias(self, carpeta: str) -> None: "carpeta": carpeta, "dialecto": o["dialecto"], "grupos": o["grupos"], "motor": o["motor"], "modelo": modelos.get(fabrica.GEMINI, MODELO_POR_DEFECTO), "modelos": modelos, "direcciones": direcciones, "lotes": o["lotes"], "hilos": o["hilos"], "contextos": contextos, - "revisar": o["revisar"], "avisar_version": self.c_avisar.isChecked(), + "revisar": o["revisar"], "texto_java": o["texto_java"], "avisar_version": self.c_avisar.isChecked(), "tema": self._preferencias.get("tema", "sistema"), "bienvenida_vista": bool(self._preferencias.get("bienvenida_vista", False)), } # fmt: skip diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..5e24dda --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,151 @@ +"""Texto del código de los mods: la tabla para el mod de apoyo y su instalación en mods/.""" + +import json +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.__main__ import main +from traductor_es.fuentes import vanilla +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.runtime import RUTA_TABLA, TextoDeJava +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.dialectos import VARIANTES +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.mascara import enmascarar +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.proceso import Proceso +from traductor_es.salida import mod_apoyo +from traductor_es.salida.copias import Respaldo +from traductor_es.salida.resource_pack import PackSalida + +from . import fabrica +from .test_cobertura import _clase # pyright: ignore[reportPrivateUsage] + +FRASES = { + "A": "You can't use chat, you've been muted by an admin!", + "B": "Click §ahere§r to open the menu %s now", + "C": "§cThe market isn't open today, come back later!", +} + + +@pytest.fixture(autouse=True) +def _sin_lanzadores(monkeypatch: pytest.MonkeyPatch) -> None: + def ninguno() -> list[Path]: + return [] + + monkeypatch.setattr(vanilla, "raices_de_lanzadores", ninguno) + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +def _instancia_con_mod(tmp_path: Path, version: str = "1.20.1", loader: str | None = "forge") -> Path: + juego = fabrica.prism(tmp_path, version, loader) + fabrica.jar( + juego / "mods" / "m.jar", + { + "com/m/Chat.class": _clase("literal", *FRASES.values()), + "com/m/Otra.class": _clase("nada", "Another English sentence here"), # no construye texto + }, + ) + return juego + + +def test_tabla_con_las_frases_del_codigo(tmp_path: Path, memoria: Memoria) -> None: + juego = _instancia_con_mod(tmp_path) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), pack=PackSalida(inst)) + modulo = TextoDeJava(ctx) + resumen = traducir_modulo(modulo, Traductor(Simulador(), memoria, Glosario())) + pack = ctx.pack_listo().raiz + assert resumen.traducidos == 3 and len(resumen.archivos) == len(VARIANTES) + for variante in VARIANTES: + crudo = (pack / RUTA_TABLA.format(variante=variante)).read_bytes() + tabla = json.loads(crudo.decode("utf-8")) + assert set(tabla) == set(FRASES.values()) # la clave es el inglés exacto + for ingles, espanol in tabla.items(): + assert espanol.startswith("«ES» ") + assert sorted(enmascarar(espanol).tokens) == sorted(enmascarar(ingles).tokens) # §a, §r y %s intactos + assert "Another English sentence here" not in json.dumps(tabla) + + +def test_sin_frases_no_crea_nada(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + fabrica.jar(juego / "mods" / "m.jar", fabrica.lang("m", "en_us", {"item.m.a": "Iron Plate"})) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego), pack=PackSalida(inst)) + resumen = traducir_modulo(TextoDeJava(ctx), Traductor(Simulador(), memoria, Glosario())) + assert resumen.archivos == [] and not (juego / "resourcepacks" / "Traduccion_ES" / "assets").exists() + + +def test_proceso_solo_incluye_el_modulo_si_se_pide(tmp_path: Path, memoria: Memoria) -> None: + juego = _instancia_con_mod(tmp_path) + with Proceso(juego, Simulador(), memoria=memoria, grupos=["mods"]) as p: + assert not any(isinstance(m, TextoDeJava) for _, m in p.modulos()) + sin = p.analizar() + with Proceso(juego, Simulador(), memoria=memoria, grupos=["mods"], texto_java=True) as p: + assert any(isinstance(m, TextoDeJava) for _, m in p.modulos()) + con = p.analizar() + p.ejecutar(activar=False, poner_idioma=False) + assert con.a_traducir == sin.a_traducir + 3 + assert (juego / "resourcepacks" / "Traduccion_ES" / RUTA_TABLA.format(variante="es_mx")).is_file() + + +# ------------------------------------------------------------------ mod de apoyo +def _jars(tmp_path: Path, *nombres: str) -> Path: + carpeta = tmp_path / "empaquetados" + carpeta.mkdir() + for n in nombres: + (carpeta / n).write_bytes(b"PK-" + n.encode()) + return carpeta + + +def test_mod_de_apoyo_solo_para_lo_soportado(tmp_path: Path) -> None: + casos = [ + ("1.20.1", "forge", "forge-1.20.1"), + ("1.20.1", "fabric", None), + ("1.21.1", "neoforge", None), + ("1.12.2", "forge", None), + ] + for i, (version, loader, esperado) in enumerate(casos): + juego = fabrica.prism(tmp_path / str(i), version, loader) + with Instancia(juego) as inst: + assert mod_apoyo.destino_de(inst) == esperado + + +def test_instalar_reemplaza_versiones_y_respeta_otros_mods(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path, "1.20.1", "forge") + (juego / "mods" / "otro-mod.jar").write_bytes(b"otro") + (juego / "mods" / "modpackes_runtime-forge-1.20.1-0.0.9.jar").write_bytes(b"viejo") + carpeta = _jars(tmp_path, "modpackes_runtime-forge-1.20.1-0.1.0.jar", "modpackes_runtime-forge-1.20.1-0.0.9.jar") + with Instancia(juego) as inst: + final = mod_apoyo.instalar(inst, carpeta) + assert final.name == "modpackes_runtime-forge-1.20.1-0.1.0.jar" and final.read_bytes().startswith(b"PK-") + assert [p.name for p in mod_apoyo.instalados(inst)] == [final.name] # la versión vieja ya no está + assert (juego / "mods" / "otro-mod.jar").read_bytes() == b"otro" + assert mod_apoyo.quitar(inst) == [final] and mod_apoyo.quitar(inst) == [] + assert (juego / "mods" / "otro-mod.jar").is_file() # nunca toca otro mod + + +def test_instalar_con_errores_claros(tmp_path: Path) -> None: + juego = fabrica.prism(tmp_path / "a", "1.20.1", "fabric") + with Instancia(juego) as inst, pytest.raises(ValueError, match="Todavía no hay mod de apoyo"): + mod_apoyo.instalar(inst, tmp_path) + juego2 = fabrica.prism(tmp_path / "b", "1.20.1", "forge") + with Instancia(juego2) as inst, pytest.raises(ValueError, match="no trae el mod de apoyo"): + mod_apoyo.instalar(inst, tmp_path / "no_hay") + + +def test_cli_instalar_runtime_sin_soporte(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + juego = fabrica.prism(tmp_path, "1.21.1", "neoforge") + assert main(["instalar-runtime", str(juego.parent)]) != 0 + assert "Todavía no hay mod de apoyo" in capsys.readouterr().out # los errores van por la salida normal + assert main(["instalar-runtime", str(juego.parent), "--quitar"]) == 0 + assert "No había mod de apoyo" in capsys.readouterr().out diff --git a/tests/test_ui.py b/tests/test_ui.py index f08bbdb..a14182a 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -520,3 +520,23 @@ def test_soltar_rutas_que_no_son_carpetas(qtbot: QtBot, tmp_path: Path) -> None: assert Ventana.carpetas_soltadas(datos) == [] datos.setUrls([QUrl.fromLocalFile(str(tmp_path))]) assert Ventana.carpetas_soltadas(datos) == [str(tmp_path)] + + +def test_opcion_de_texto_java_y_mod_de_apoyo_en_la_ventana( + qtbot: QtBot, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from traductor_es.salida import mod_apoyo + + vacia = tmp_path / "sin_jars" + vacia.mkdir() + monkeypatch.setattr(mod_apoyo, "_carpeta_de_jars", lambda: vacia) # sin importar si esta copia ya compiló el jar + juego = _modpack(tmp_path) + v = _ventana(qtbot, juego) + assert not v.c_java.isChecked() and v.opciones()["texto_java"] is False # apagada por defecto + v.c_java.setChecked(True) + assert v.opciones()["texto_java"] is True + v.a_apoyo.trigger() # Forge 1.20.1 sí tiene mod de apoyo, pero sin el jar empaquetado debe avisar, no romper + assert "no trae el mod de apoyo" in v.aviso.e_texto.text() + assert not list((juego / "mods").glob("modpackes_runtime-*.jar")) + v.a_quitar_apoyo.trigger() + assert "No había mod de apoyo" in v.aviso.e_texto.text() From 391531283aee57a429fd3ecf6766a7181894898d Mon Sep 17 00:00:00 2001 From: Cristian Date: Fri, 6 Nov 2026 02:17:39 -0500 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20m=C3=A9todos=20de=20KubeJS=20por=20?= =?UTF-8?q?versi=C3=B3n,=20formatos=20desconocidos,=20modo=20exhaustivo=20?= =?UTF-8?q?y=20m=C3=A1s=20corpus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Métodos de texto de KubeJS según la versión de Minecraft (formatos/kubejs_api.py). - El informe de cobertura avisa de carpetas con frases cuyo formato no se conoce. - Formato del mercado de Farming for Blockheads. - Modo exhaustivo opcional para los scripts (--exhaustivo y casilla). - Siete modpacks más en el corpus, de 1.16.5 a 26.x con Quilt incluido. --- CHANGELOG.md | 6 ++- README.md | 2 + scripts/descargar_corpus.py | 15 ++++++++ src/traductor_es/__main__.py | 6 +++ src/traductor_es/formatos/kubejs_api.py | 32 ++++++++++++++++ src/traductor_es/modulos/cobertura.py | 49 +++++++++++++++++++++++- src/traductor_es/modulos/datos.py | 21 ++++++++-- src/traductor_es/modulos/scripts.py | 51 ++++++++++++++++--------- src/traductor_es/proceso.py | 7 +++- src/traductor_es/ui/ventana.py | 20 +++++++++- tests/test_cobertura.py | 27 +++++++++++-- tests/test_datos_generales.py | 19 +++++++++ tests/test_kubejs_y_mods.py | 9 +++++ tests/test_verificacion_modulos.py | 23 +++++++++++ 14 files changed, 257 insertions(+), 30 deletions(-) create mode 100644 src/traductor_es/formatos/kubejs_api.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a18018f..1e3a504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,13 +19,17 @@ Todos los cambios importantes de ModpackES quedan en este archivo. El formato si - Títulos y descripciones de logros, y los consejos de las pantallas de carga (mod Tips, en sus dos formatos). - Mensajes que un script arma por partes (`mensaje += efecto.name; player.tell(mensaje)`), con sus tablas de nombres. - `notify`, `kick`, `openChestGUI` y `openInventoryGUI` de KubeJS (métodos tomados del código de KubeJS 1.20 y 1.21). +- **Modo exhaustivo** para los scripts de KubeJS (opcional, apagado por defecto; `--exhaustivo` y una casilla en «Opciones avanzadas»): traduce también toda frase que parezca texto aunque no se sepa dónde se muestra. Puede tocar lógica; todo queda con copia de seguridad. Nunca toca `console.log` ni comparaciones. +- Los métodos de texto de KubeJS ahora dependen de la versión (`notify`, `openChestGUI`… solo donde existen), comprobados en el código de KubeJS de 1.12 a 26.x (`formatos/kubejs_api.py`). +- Mercado de Farming for Blockheads (`farmingforblockheads_compat`): se traducen los nombres de las pestañas. +- El informe «Qué queda en inglés» también avisa de carpetas de datos con frases en inglés cuyo formato la app no conoce. - Estos datos se buscan por su estructura y no por la carpeta de un modpack: `kubejs/data`, `global_packs`, `datapacks`, `config/openloader`, los datapacks de un mundo y cualquier otra con `data//functions/…`, sueltos o dentro de un `.zip` (se reescribe el zip y se guarda el original). Los componentes de texto valen en JSON y en SNBT (1.21.5 y posteriores). - **Más ▸ Qué queda en inglés** y `modpackes cobertura`: lista lo que no se puede traducir y por qué (frases de scripts cuyo uso no se entiende y texto escrito en el código de los mods, leído de la tabla de constantes de cada clase). Solo lee. El análisis avisa de las frases de scripts que parecen texto. -- Pruebas con modpacks públicos de Modrinth (9 más en `scripts/descargar_corpus.py`) y con la instancia propia: lo traducido +- Pruebas con modpacks públicos de Modrinth (16 más en `scripts/descargar_corpus.py`, de 1.16.5 a 26.x con Forge, NeoForge, Fabric y Quilt) y con la instancia propia: lo traducido sigue siendo válido, repetir no cambia nada y restaurar devuelve los bytes originales. ### Cambiado diff --git a/README.md b/README.md index cdda3ca..9fa3a1c 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ infalibles: sirven para decidir qué rehacer, no como nota de calidad. **Texto del código de los mods** (opcional): activa «Incluir texto escrito en el código de los mods» en «Opciones avanzadas» y pulsa **Más ▸ Instalar el mod de apoyo**. La app traduce las frases que encuentra en el código de los mods y el mod de apoyo las muestra en español en el chat, la barra de acción y los tooltips. Solo cambia un texto si es **idéntico** al de la tabla (no hay traducción en vivo ni en internet), no cubre pantallas ni frases con nombres o números en medio, y por ahora existe para Forge 1.20.1. **Más ▸ Quitar el mod de apoyo** lo retira de la carpeta mods; en la línea de comandos: `modpackes instalar-runtime RUTA [--quitar]` y `--java` en `analizar` y `traducir`. Para comprobarlo en el juego, con el mod instalado y el juego en español, escribe `/tellraw @s "TPA request expired!"` (o cualquier frase de la tabla). +**Modo exhaustivo de scripts** (opcional, en «Opciones avanzadas» o con `--exhaustivo`): traduce también las frases de los scripts de KubeJS cuyo uso no se entiende (por ejemplo textos que pasan por una función propia del pack). Puede tocar lógica del script, por eso viene apagado; todo queda con copia de seguridad y se restaura con «Restaurar el original». + **Más ▸ Qué queda en inglés** (o `modpackes cobertura RUTA [--java]`) lista lo que no se traduce y por qué, sin cambiar nada: frases de scripts cuyo uso no se entiende y texto escrito en el código de los mods, que un resource pack no puede alcanzar. El análisis ya avisa de las frases de scripts. **Más ▸ Restaurar el original** devuelve misiones, scripts y libros propios al texto del modpack. Para quitar diff --git a/scripts/descargar_corpus.py b/scripts/descargar_corpus.py index b353932..7202f1c 100644 --- a/scripts/descargar_corpus.py +++ b/scripts/descargar_corpus.py @@ -45,6 +45,21 @@ "better-mc-forge-bmc4": "https://cdn.modrinth.com/data/4BV47HRn/versions/bdRIjRgs/Better%20MC%20%5BFORGE%5D%201.20.1%201.20.1%20v59.mrpack", # 1.20.1 fabric · kubejs, datapacks .zip "landscapes-reimagined-genesis": "https://cdn.modrinth.com/data/MdznSbKA/versions/pBrfi38A/Landscapes%20Reimagined%20Genesis%203.2.mrpack", + # Más versiones y loaders (1.16.5, 1.18.2, 1.19.2 Quilt, 1.20.1, 1.21.11 y 26.x): SNBT y formatos viejos + # 1.19.2 quilt · kubejs 171 / data 1297 / funciones 9 / logros 130 / zips 0 + "taidumcraft": "https://cdn.modrinth.com/data/qH6Phyax/versions/dmDeTjMb/TaidumCraft.mrpack", + # 26.1.2 fabric · kubejs 0 / data 0 / funciones 5 / logros 176 / zips 0 + "legacies": "https://cdn.modrinth.com/data/nWCaRO9f/versions/x4WpILDH/Legacies%2026-v4.1.mrpack", + # 1.16.5 forge · kubejs 125 / data 5 / funciones 0 / logros 0 / zips 0 + "techblock-skytech-3": "https://cdn.modrinth.com/data/jRXzxVGA/versions/IHN5lWsY/TechBlock%20-%20SkyTech%203%201.2.1.mrpack", + # 1.18.2 forge · kubejs 7 / data 0 / funciones 0 / logros 0 / zips 0 + "adventures-of-berk": "https://cdn.modrinth.com/data/zTK0MLqP/versions/yIog8kuK/Adventures%20of%20Berk%200.0.1.mrpack", + # 1.20.1 fabric · kubejs 3 / data 258 / funciones 0 / logros 123 / zips 0 + "spectral-creation": "https://cdn.modrinth.com/data/eqbP6Ycz/versions/iJhfeFZL/Spectral%20Creation%201.20.1-7.1.1.mrpack", + # 1.18.2 forge · kubejs 0 / data 0 / funciones 0 / logros 0 / zips 16 + "kings-dragons-of-berk": "https://cdn.modrinth.com/data/UmIII5vC/versions/b82038X3/Kings_dragon%201.0.0.mrpack", + # 1.21.11 fabric · kubejs 0 / data 0 / funciones 0 / logros 0 / zips 6 + "better-adventures-plus-plus": "https://cdn.modrinth.com/data/23niDfW7/versions/e18PwvTU/Better%20Adventures%2B%2B%201.3.mrpack", } #: Qué se baja de cada pack (rutas dentro del .mrpack). diff --git a/src/traductor_es/__main__.py b/src/traductor_es/__main__.py index 3f53e3b..ad83d03 100644 --- a/src/traductor_es/__main__.py +++ b/src/traductor_es/__main__.py @@ -79,6 +79,11 @@ def comunes(p: argparse.ArgumentParser) -> None: p.add_argument("--lotes", action="store_true", help="usar la Batch API de Gemini (mitad de precio, tarda más)") p.add_argument("--contexto", default="", help="ambientación del modpack para el tono y los términos") p.add_argument("--revisar", action="store_true", help="revisión automática de misiones y libros (cuesta más)") + p.add_argument( + "--exhaustivo", + action="store_true", + help="en los scripts de KubeJS traduce toda frase, aunque no se sepa dónde se muestra (puede tocar lógica)", + ) p.add_argument( "--java", action="store_true", @@ -122,6 +127,7 @@ def _traducir_o_analizar(args: argparse.Namespace) -> None: args.carpeta, motor, args.dialecto, grupos, registro=_escribir, hilos=getattr(args, "hilos", 4), usar_lotes=args.lotes, contexto=args.contexto, revisar=args.revisar, texto_java=args.java, + scripts_exhaustivo=args.exhaustivo, ) as p: # fmt: skip _escribir(f"Minecraft {p.instancia.version} · {p.instancia.loader or '¿loader?'} · {p.instancia.juego}") if args.orden == "analizar": diff --git a/src/traductor_es/formatos/kubejs_api.py b/src/traductor_es/formatos/kubejs_api.py new file mode 100644 index 0000000..3664a8a --- /dev/null +++ b/src/traductor_es/formatos/kubejs_api.py @@ -0,0 +1,32 @@ +"""Métodos de KubeJS que le muestran texto al jugador, según la versión de Minecraft. + +Los verificados salen del código de KubeJS (repositorio KubeJS-Mods/KubeJS, ramas 1202, 1605, 1802, 1902, 2001, +2101 y 2601: `MessageSender(KJS)`, `PlayerJS`/`PlayerKJS`, `ServerPlayerJS`/`ServerPlayerKJS`). El resto son +los de siempre (constructores de objetos, tooltips, títulos) y se aceptan en todas las versiones. +""" + +from __future__ import annotations + +#: método → primera versión de Minecraft (mayor, menor) en la que existe, comprobado en el código de cada rama +VERIFICADOS: dict[str, tuple[int, int]] = { + "tell": (1, 12), # MessageSender.tell(Component) + "setStatusMessage": (1, 12), # MessageSender / PlayerJS.setStatusMessage(Component) + "kick": (1, 12), # ServerPlayerJS.kick(Component) + "notify": (1, 19), # PlayerKJS.notify(título, texto): aparece en la rama 1902 + "openInventoryGUI": (1, 19), # ServerPlayerKJS.openInventoryGUI(…, Component título) + "openChestGUI": (1, 20), # ServerPlayerKJS.openChestGUI(Component título, …): aparece en la rama 2001 +} + +#: Se aceptan en cualquier versión; no se comprobaron una por una contra el código de KubeJS. +GENERALES = frozenset({ + "statusMessage", "displayClientMessage", "sendSystemMessage", "sendMessage", "displayName", "setDisplayName", + "tooltip", "addTooltip", "title", "setTitle", "subtitle", "setSubtitle", "lore", "setLore", "actionbar", + "setActionbar", +}) # fmt: skip + + +def llamadas_de_texto(version: tuple[int, ...] | None) -> frozenset[str]: + """Los métodos de texto que existen en esa versión de Minecraft. Sin versión: todos (es lo más prudente).""" + if version is None: + return GENERALES | VERIFICADOS.keys() + return GENERALES | {m for m, desde in VERIFICADOS.items() if tuple(version[:2]) >= desde} diff --git a/src/traductor_es/modulos/cobertura.py b/src/traductor_es/modulos/cobertura.py index cb0e28e..88bc504 100644 --- a/src/traductor_es/modulos/cobertura.py +++ b/src/traductor_es/modulos/cobertura.py @@ -17,6 +17,7 @@ from ..fuentes.instancia import Instancia from ..nucleo.mascara import es_traducible from .base import Contexto, parece_clave +from .datos import clasificar, recorrer from .scripts import ScriptsKubeJS MAX_EJEMPLOS = 3 @@ -56,7 +57,7 @@ class Hallazgo: """Una cosa que queda en inglés: dónde, cuántos textos y unos ejemplos.""" - categoria: str #: «script» o «java» + categoria: str #: «script», «formato» o «java» donde: str cantidad: int motivo: str @@ -152,11 +153,57 @@ def textos_en_java(instancia: Instancia, progreso: Callable[[int, int], None] = return sorted(res, key=lambda h: -h.cantidad) +#: Carpetas de un datapack o resource pack que no llevan texto para el jugador (o que ya se traducen por otro lado). +_SIN_TEXTO = frozenset({ + "recipes", "recipe", "tags", "loot_tables", "loot_table", "predicates", "worldgen", "dimension", "dimension_type", + "structures", "structure", "lang", "textures", "models", "blockstates", "sounds", "particles", "shaders", "font", + "atlases", "item_modifiers", "chat_type", "damage_type", "enchantment", "trim_pattern", "trim_material", + "banner_pattern", "jukebox_song", "painting_variant", "patchouli_books", "optifine", "equipment", "items", +}) # fmt: skip +#: Un valor de texto con aspecto de frase en inglés dentro de un JSON. +_TEXTO_JSON = re.compile(r'"(?:text|title|description|message|tip|name|lore)"\s*:\s*"([A-Z][^"\\]{11,})"') +_TAMANO_MAXIMO = 300_000 + + +def formatos_desconocidos(juego: Path) -> list[Hallazgo]: + """Carpetas de datos o recursos con frases en inglés cuyo formato la app no conoce (no se tocan).""" + por_carpeta: dict[str, list[str]] = {} + for ruta in recorrer(juego): + partes = ruta.relative_to(juego).parts + if ruta.suffix != ".json" or clasificar(Path(*partes)) is not None: + continue + ancla = next( + (i for i, parte in enumerate(partes) if parte in ("data", "assets") and len(partes) - i >= 4), None + ) + if ancla is None or partes[ancla + 2] in _SIN_TEXTO: + continue + try: + if ruta.stat().st_size > _TAMANO_MAXIMO: + continue + frases = [ + f + for f in _TEXTO_JSON.findall(ruta.read_text(encoding="utf-8-sig", errors="ignore")) + if es_traducible(f) + ] + except OSError: + continue + if frases: + por_carpeta.setdefault("/".join(partes[: ancla + 3]), []).extend(frases) + return [ + Hallazgo( + "formato", carpeta, len(frases), "Formato que la app no conoce: no se traduce (no se toca ningún archivo)", + tuple(f[:60] for f in frases[:MAX_EJEMPLOS]), + ) + for carpeta, frases in sorted(por_carpeta.items(), key=lambda kv: -len(kv[1])) + ] # fmt: skip + + def revisar_cobertura( ctx: Contexto, java: bool = False, progreso: Callable[[int, int], None] = lambda _a, _b: None ) -> list[Hallazgo]: """Lo que queda en inglés. El análisis del código Java tarda (lee todos los mods): solo si se pide.""" hallazgos = hallazgos_de_scripts(ctx.instancia.juego, ScriptsKubeJS(ctx).candidatas()) + hallazgos += formatos_desconocidos(ctx.instancia.juego) if java: hallazgos += textos_en_java(ctx.instancia, progreso) return hallazgos diff --git a/src/traductor_es/modulos/datos.py b/src/traductor_es/modulos/datos.py index a5fd297..84d1acf 100644 --- a/src/traductor_es/modulos/datos.py +++ b/src/traductor_es/modulos/datos.py @@ -91,6 +91,16 @@ def _nodos_tip(datos: Any) -> list[tuple[dict[str, Any], str]]: return nodos +def _nodos_mercado(datos: Any) -> list[tuple[dict[str, Any], str]]: + """Mercado de Farming for Blockheads: el `name` de cada `customCategories.` (lo que se ve en las pestañas).""" + if not isinstance(datos, dict): + return [] + categorias = cast("dict[str, Any]", datos).get("customCategories") + if not isinstance(categorias, dict): + return [] + return [n for c in cast("dict[str, Any]", categorias).values() for n in _nodos(c, ("name",))] + + def _unidades_json(texto: str, nodos: Callable[[Any], list[tuple[dict[str, Any], str]]]) -> list[str]: return [u for d, k in nodos(json.loads(texto)) for u in _traducibles(componente.unidades(_componente(d[k])))] @@ -128,6 +138,11 @@ def _es(ruta: tuple[str, ...], raiz: str, carpetas: tuple[str, ...], sufijo: str lambda r: _es(r, "data", ("advancement", "advancements"), ".json"), lambda t: _unidades_json(t, _nodos_logro), lambda t, tr: _reescribir_json(t, tr, _nodos_logro), ), + Formato( + "mercado", "pestaña de un mercado", + lambda r: _es(r, "data", ("farmingforblockheads_compat",), ".json"), + lambda t: _unidades_json(t, _nodos_mercado), lambda t, tr: _reescribir_json(t, tr, _nodos_mercado), + ), Formato( "tip", "consejo de las pantallas de carga", lambda r: _es(r, "assets", ("tips",), ".json") or (_es(r, "data", ("tips",), ".json") and r[1] == "tipsmod"), @@ -147,7 +162,7 @@ def clasificar(ruta: Path) -> Formato | None: return None -def _recorrer(juego: Path) -> Iterator[Path]: +def recorrer(juego: Path) -> Iterator[Path]: """Cada archivo de la instancia, sin entrar en mods, cachés ni mundos (de un mundo solo sus datapacks).""" base = len(juego.parts) for raiz, carpetas, archivos in os.walk(juego): @@ -165,7 +180,7 @@ def _recorrer(juego: Path) -> Iterator[Path]: def buscar_archivos(juego: Path) -> list[tuple[Path, Formato]]: """Todos los archivos de texto conocidos que están sueltos en la instancia.""" encontrados: list[tuple[Path, Formato]] = [] - for ruta in _recorrer(juego): + for ruta in recorrer(juego): if (formato := clasificar(ruta.relative_to(juego))) is not None: encontrados.append((ruta, formato)) return encontrados @@ -185,7 +200,7 @@ def _entradas_del_zip(ruta: Path) -> list[tuple[str, Formato]]: def buscar_zips(juego: Path) -> list[tuple[Path, list[tuple[str, Formato]]]]: """Los .zip con texto conocido dentro (datapacks de `global_packs`, `datapacks`, mundos…).""" res: list[tuple[Path, list[tuple[str, Formato]]]] = [] - for ruta in _recorrer(juego): + for ruta in recorrer(juego): if ruta.suffix.lower() == ".zip" and (entradas := _entradas_del_zip(ruta)): res.append((ruta, entradas)) return res diff --git a/src/traductor_es/modulos/scripts.py b/src/traductor_es/modulos/scripts.py index 2e3a14a..85b56a6 100644 --- a/src/traductor_es/modulos/scripts.py +++ b/src/traductor_es/modulos/scripts.py @@ -12,21 +12,16 @@ from pathlib import Path from ..formatos import comandos, componente, flujo_js, javascript +from ..formatos.kubejs_api import llamadas_de_texto from ..nucleo.mascara import es_traducible from .base import Contexto, Pieza, Resumen, contar, parece_clave AMBITO = "script" CARPETAS = ("kubejs/client_scripts", "kubejs/server_scripts", "kubejs/startup_scripts") -#: Llamadas cuyo argumento de texto es siempre algo que ve el jugador. -LLAMADAS_TEXTO = frozenset({ - "tell", "statusMessage", "setStatusMessage", "displayClientMessage", "sendSystemMessage", "sendMessage", - "displayName", "setDisplayName", "tooltip", "addTooltip", "title", "setTitle", "subtitle", "setSubtitle", - "lore", "setLore", "actionbar", "setActionbar", - # Del código de KubeJS (ramas 2001 y 2101, core/PlayerKJS, ServerPlayerKJS y MessageSenderKJS): - # `notify(título, texto)`, `kick(motivo)`, y el título de `openChestGUI` / `openInventoryGUI` - "notify", "kick", "openChestGUI", "openInventoryGUI", -}) # fmt: skip +#: Llamadas cuyo argumento de texto es siempre algo que ve el jugador (de todas las versiones de KubeJS; la lista por +#: versión está en `formatos/kubejs_api.py`). +LLAMADAS_TEXTO = llamadas_de_texto(None) #: Llamadas que ejecutan un comando: si es `tellraw`, `title`, `say`…, el texto del mensaje se traduce. LLAMADAS_COMANDO = frozenset({"runCommand", "runCommandSilent"}) #: `Text.of("…")`, `Text.red("…")`, `Component.literal("…")`… @@ -42,11 +37,16 @@ "onEvent:client.generate_assets", ) # fmt: skip LLAMADAS_EN_EVENTO = frozenset({"add", "addAll", "insert", "addAdvanced", "addItem", "addFluid", "addLang", "addLine"}) -#: Las llamadas de texto (`tell`, `Text.of`…) como expresión regular: sirve para ver qué variables acaban en un mensaje. -_LLAMADAS_RE = ( - rf"\b(?:{'|'.join(sorted(LLAMADAS_TEXTO))})|\b(?:{'|'.join(sorted(RECEPTORES_TEXTO))})" - rf"\.(?:{'|'.join(sorted(AYUDAS_TEXTO))})" -) + + +def _llamadas_re(llamadas: frozenset[str]) -> str: + """Las llamadas de texto (`tell`, `Text.of`…) como regex, para saber qué variables acaban en un mensaje.""" + return ( + rf"\b(?:{'|'.join(sorted(llamadas))})|\b(?:{'|'.join(sorted(RECEPTORES_TEXTO))})" + rf"\.(?:{'|'.join(sorted(AYUDAS_TEXTO))})" + ) + + _EXPRESION = re.compile(r"\$\{[^{}\n]*\}") @@ -61,12 +61,12 @@ def _seguro(c: javascript.CadenaJs, traduccion: str) -> bool: return c.comilla != "`" or traduccion.count("${") == c.texto.count("${") -def es_texto_visible(c: javascript.CadenaJs) -> bool: +def es_texto_visible(c: javascript.CadenaJs, llamadas: frozenset[str] = LLAMADAS_TEXTO) -> bool: if c.comparada or not _parece_texto(c.texto): return False if c.comilla == "`" and c.texto.count("${") != len(_EXPRESION.findall(c.texto)): return False # plantilla con expresiones complejas: no se puede proteger con seguridad - if c.funcion in LLAMADAS_TEXTO: + if c.funcion in llamadas: return True if c.funcion in AYUDAS_TEXTO and c.receptor in RECEPTORES_TEXTO: return True @@ -81,6 +81,7 @@ def parece_frase_sin_elegir(c: javascript.CadenaJs) -> bool: t = c.texto.strip() return ( not c.comparada + and c.receptor != "console" # los registros del script no los ve el jugador and t[:1].isupper() and len(t.split()) >= 3 and _parece_texto(t) @@ -173,8 +174,13 @@ class ScriptsKubeJS: nombre = "Scripts de KubeJS" ambito = AMBITO - def __init__(self, ctx: Contexto) -> None: + def __init__(self, ctx: Contexto, exhaustivo: bool = False) -> None: + """`exhaustivo`: traduce también toda cadena con aspecto de frase, aunque no se sepa dónde se muestra. + + Puede tocar lógica del script (una frase que se compara o se guarda), por eso es opcional. + """ self.ctx = ctx + self.exhaustivo = exhaustivo self._scripts: list[_Script] = [] self.sin_elegir: list[tuple[Path, list[str]]] = [] @@ -210,20 +216,27 @@ def recolectar(self) -> list[Pieza]: self._scripts = [] self.sin_elegir = [] leidos = self._leer() - flujos = [flujo_js.analizar(src, _LLAMADAS_RE) for _, src, _ in leidos] + llamadas = llamadas_de_texto(self.ctx.instancia.version.partes) # las de esta versión de KubeJS + patron = _llamadas_re(llamadas) + flujos = [flujo_js.analizar(src, patron) for _, src, _ in leidos] # Una tabla de nombres se define en un archivo y se usa en otro tablas: frozenset[str] = frozenset(t for f in flujos for t in f.tablas) for (ruta, src, cadenas), flujo in zip(leidos, flujos, strict=True): elegidas: list[javascript.CadenaJs] = [] en_comando: dict[int, comandos.Comando] = {} for c in cadenas: - if es_texto_visible(c): + if es_texto_visible(c, llamadas): elegidas.append(c) elif (cmd := comando_visible(c)) is not None: elegidas.append(c) en_comando[c.inicio] = cmd vistas = {c.inicio for c in elegidas} elegidas += [c for c in textos_armados(src, cadenas, flujo, tablas) if c.inicio not in vistas] + if self.exhaustivo: + vistas = {c.inicio for c in elegidas} + elegidas += [ + c for c in cadenas if c.inicio not in vistas and parece_frase_sin_elegir(c) and _plantilla_segura(c) + ] if elegidas: self._scripts.append(_Script(ruta, src, cadenas, elegidas, en_comando)) vistas = {c.inicio for c in elegidas} diff --git a/src/traductor_es/proceso.py b/src/traductor_es/proceso.py index 7268e5e..121d463 100644 --- a/src/traductor_es/proceso.py +++ b/src/traductor_es/proceso.py @@ -115,10 +115,12 @@ def __init__( contexto: str = "", revisar: bool = False, texto_java: bool = False, + scripts_exhaustivo: bool = False, ) -> None: self.instancia = Instancia(carpeta) self.revisar = revisar self.texto_java = texto_java + self.scripts_exhaustivo = scripts_exhaustivo self.registro = registro self.grupos = [g for g in GRUPOS if g in grupos] self._memoria_propia = memoria is None @@ -167,7 +169,10 @@ def modulos(self) -> list[tuple[str, Modulo]]: if "misiones" in self.grupos: lista += [("misiones", m) for m in misiones.todos(self.ctx)] if "scripts" in self.grupos: - lista += [("scripts", scripts.ScriptsKubeJS(self.ctx)), ("scripts", datos.Datos(self.ctx))] + lista += [ + ("scripts", scripts.ScriptsKubeJS(self.ctx, self.scripts_exhaustivo)), + ("scripts", datos.Datos(self.ctx)), + ] return [(g, m) for g, m in lista if m.detectar()] # ------------------------------------------------------------------ acciones diff --git a/src/traductor_es/ui/ventana.py b/src/traductor_es/ui/ventana.py index 7c6659e..05a6bdd 100644 --- a/src/traductor_es/ui/ventana.py +++ b/src/traductor_es/ui/ventana.py @@ -161,6 +161,7 @@ def ejecutar(self) -> None: contexto=self.opciones["contexto"], revisar=self.opciones["revisar"], texto_java=self.opciones["texto_java"], + scripts_exhaustivo=self.opciones["exhaustivo"], ) as p: self.registro.emit(f"Minecraft {p.instancia.version} · {p.instancia.loader or '¿loader?'}") if self.accion == "analizar": @@ -264,7 +265,13 @@ def _construir(self) -> None: self.c_motor.setCurrentIndex(max(0, self.c_motor.findData(self._preferencias.get("motor", fabrica.GEMINI)))) self.c_motor.currentIndexChanged.connect(self._al_cambiar_motor) self._al_cambiar_motor() - for senal in (self.c_lotes.toggled, self.c_revisar.toggled, self.c_activar.toggled, self.c_java.toggled): + for senal in ( + self.c_lotes.toggled, + self.c_revisar.toggled, + self.c_activar.toggled, + self.c_java.toggled, + self.c_exhaustivo.toggled, + ): senal.connect(self._al_cambiar_opciones) self.c_hilos.valueChanged.connect(self._al_cambiar_opciones) self.c_dialecto.currentIndexChanged.connect(self._al_cambiar_opciones) @@ -463,6 +470,13 @@ def _opciones_avanzadas(self) -> QWidget: ) self.c_java.setChecked(bool(self._preferencias.get("texto_java", False))) formulario.addRow("", self.c_java) + self.c_exhaustivo = QCheckBox("Scripts: traducir toda frase, aunque no se sepa dónde se muestra") + self.c_exhaustivo.setToolTip( + "Puede tocar lógica de los scripts de KubeJS (una frase que se compara o se guarda). Todo queda con copia " + "de seguridad y se puede restaurar. Mira antes «Más ▸ Qué queda en inglés»." + ) + self.c_exhaustivo.setChecked(bool(self._preferencias.get("exhaustivo", False))) + formulario.addRow("", self.c_exhaustivo) self.c_hilos = QSpinBox() self.c_hilos.setRange(1, 16) self.c_hilos.setSuffix(" pedidos a la vez") @@ -853,6 +867,7 @@ def opciones(self) -> dict[str, Any]: "contexto": self.e_contexto.text().strip(), "revisar": self.c_revisar.isChecked(), "texto_java": self.c_java.isChecked(), + "exhaustivo": self.c_exhaustivo.isChecked(), } def _confirmar_traducir(self) -> None: @@ -1157,7 +1172,8 @@ def _guardar_preferencias(self, carpeta: str) -> None: "carpeta": carpeta, "dialecto": o["dialecto"], "grupos": o["grupos"], "motor": o["motor"], "modelo": modelos.get(fabrica.GEMINI, MODELO_POR_DEFECTO), "modelos": modelos, "direcciones": direcciones, "lotes": o["lotes"], "hilos": o["hilos"], "contextos": contextos, - "revisar": o["revisar"], "texto_java": o["texto_java"], "avisar_version": self.c_avisar.isChecked(), + "revisar": o["revisar"], "texto_java": o["texto_java"], "exhaustivo": o["exhaustivo"], + "avisar_version": self.c_avisar.isChecked(), "tema": self._preferencias.get("tema", "sistema"), "bienvenida_vista": bool(self._preferencias.get("bienvenida_vista", False)), } # fmt: skip diff --git a/tests/test_cobertura.py b/tests/test_cobertura.py index dea9955..347f4ef 100644 --- a/tests/test_cobertura.py +++ b/tests/test_cobertura.py @@ -56,8 +56,8 @@ def test_cobertura_de_scripts_y_java(tmp_path: Path) -> None: sin_java = revisar_cobertura(Contexto(inst, Respaldo(juego))) con_java = revisar_cobertura(Contexto(inst, Respaldo(juego)), java=True) assert [(h.categoria, h.donde) for h in sin_java] == [("script", "kubejs/server_scripts/a.js")] - assert sin_java[0].cantidad == 2 # `tell` ya se traduce; recetas y comparaciones no cuentan - assert "This should be a debug message here" in sin_java[0].ejemplos + assert sin_java[0].cantidad == 1 # `tell` ya se traduce; recetas, comparaciones y console.log no cuentan + assert sin_java[0].ejemplos == ("The market is closed today",) de_java = [h for h in con_java if h.categoria == "java"] assert len(de_java) == 1 assert set(de_java[0].ejemplos) == {"You can't use this item here!", "Click the block to open the menu"} @@ -69,5 +69,26 @@ def test_analizar_avisa_sin_detener_el_analisis(tmp_path: Path) -> None: (juego / "kubejs" / "server_scripts" / "a.js").write_text(SCRIPT, encoding="utf-8") with Proceso(juego, Simulador(), grupos=["scripts"]) as p: informe = p.analizar() - assert any("2 frases de 1 scripts" in a for a in informe.avisos) + assert any("1 frases de 1 scripts" in a for a in informe.avisos) assert informe.modulos and informe.modulos[0].grupo == "scripts" + + +def test_formatos_desconocidos_se_informan_sin_tocar(tmp_path: Path) -> None: + import json + + from traductor_es.modulos.cobertura import formatos_desconocidos + + juego = fabrica.prism(tmp_path, "1.20.1") + quiz = juego / "kubejs" / "data" / "a" / "quizzes" / "q1.json" + quiz.parent.mkdir(parents=True) + quiz.write_text(json.dumps({"title": "What do cows eat for breakfast?", "id": "x"}), encoding="utf-8") + conocido = juego / "kubejs" / "data" / "a" / "advancements" / "g.json" + conocido.parent.mkdir(parents=True) + conocido.write_text(json.dumps({"display": {"title": {"text": "A conocido title here"}}}), encoding="utf-8") + receta = juego / "kubejs" / "data" / "a" / "recipes" / "r.json" + receta.parent.mkdir(parents=True) + receta.write_text(json.dumps({"result": {"text": "Not shown to the player, a recipe"}}), encoding="utf-8") + antes = quiz.read_bytes() + hallazgos = formatos_desconocidos(juego) + assert [(h.categoria, h.donde, h.cantidad) for h in hallazgos] == [("formato", "kubejs/data/a/quizzes", 1)] + assert hallazgos[0].ejemplos == ("What do cows eat for breakfast?",) and quiz.read_bytes() == antes diff --git a/tests/test_datos_generales.py b/tests/test_datos_generales.py index bcd7f74..12b29bb 100644 --- a/tests/test_datos_generales.py +++ b/tests/test_datos_generales.py @@ -182,3 +182,22 @@ def test_datapack_comprimido_se_traduce_y_se_restaura(tmp_path: Path, memoria: M assert ruta.read_bytes() == despues # repetir no cambia nada assert Datos(ctx).restaurar() == [ruta] assert ruta.read_bytes() == original + + +def test_mercado_de_farming_for_blockheads(tmp_path: Path, memoria: Memoria) -> None: + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "kubejs" / "data" / "farmingforblockheads" / "farmingforblockheads_compat" / "kits.json" + contenido = { + "customCategories": {"kits": {"name": "Clothes and Decoration Kits", "icon": {"item": "minecraft:stone"}}}, + "customEntries": [{"output": {"item": "x:y", "nbt": {"Name": "Item name stays"}}}], + } + ruta.parent.mkdir(parents=True) + ruta.write_text(json.dumps(contenido), encoding="utf-8") + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + traducir_modulo(Datos(ctx), Traductor(Simulador(), memoria, Glosario())) + nuevo = json.loads(ruta.read_text("utf-8")) + assert nuevo["customCategories"]["kits"]["name"] == "«ES» Clothes and Decoration Kits" + assert nuevo["customEntries"] == contenido["customEntries"] and nuevo["customCategories"]["kits"]["icon"] + Datos(ctx).restaurar() + assert json.loads(ruta.read_text("utf-8")) == contenido diff --git a/tests/test_kubejs_y_mods.py b/tests/test_kubejs_y_mods.py index 90bdbf6..c15c455 100644 --- a/tests/test_kubejs_y_mods.py +++ b/tests/test_kubejs_y_mods.py @@ -196,3 +196,12 @@ def sin_lanzadores() -> list[Path]: with Instancia(juego) as inst: traducir_modulo(Mods(Contexto(inst, Respaldo(juego))), Traductor(Simulador(), memoria, Glosario())) assert not (juego / "resourcepacks" / "Traduccion_ES").exists() + + +def test_metodos_de_texto_de_kubejs_por_version() -> None: + from traductor_es.formatos.kubejs_api import llamadas_de_texto + + assert {"tell", "setStatusMessage", "kick"} <= llamadas_de_texto((1, 12, 2)) + assert "notify" not in llamadas_de_texto((1, 16, 5)) and "notify" in llamadas_de_texto((1, 19, 2)) + assert "openChestGUI" not in llamadas_de_texto((1, 19, 2)) and "openChestGUI" in llamadas_de_texto((1, 20, 1)) + assert "openChestGUI" in llamadas_de_texto((26, 1, 0)) and "openChestGUI" in llamadas_de_texto(None) diff --git a/tests/test_verificacion_modulos.py b/tests/test_verificacion_modulos.py index 7c1d463..9a7b7d2 100644 --- a/tests/test_verificacion_modulos.py +++ b/tests/test_verificacion_modulos.py @@ -274,3 +274,26 @@ def test_namespace_imposible_no_tumba_el_resto(tmp_path: Path, memoria: Memoria, assert json.loads((pack / "assets" / "ok" / "lang" / "es_mx.json").read_text("utf-8")) == {"b": "«ES» Good one"} assert resumen.pendientes >= 1 and any("no se puede guardar" in a for a in avisos) assert not (tmp_path / "Pack" / "minecraft" / "lang").exists() # nada se escribió fuera del pack + + +EXHAUSTIVO = """const aviso = 'The harvest festival starts tomorrow evening' +console.log('This is only a debug line for the log here') +if (nombre == 'Some Name With Spaces') return +function f(e) { e.player.tell('Welcome to the farm') } +""" + + +def test_modo_exhaustivo_traduce_mas_pero_no_los_registros_ni_las_comparaciones( + tmp_path: Path, memoria: Memoria +) -> None: + juego, ruta = _script(tmp_path, EXHAUSTIVO.encode()) + with Instancia(juego) as inst: + ctx = Contexto(inst, Respaldo(juego)) + normal = traducir_modulo(ScriptsKubeJS(ctx), Traductor(Simulador(), memoria, Glosario())) + assert normal.traducidos == 1 # solo el `tell` + ScriptsKubeJS(ctx).restaurar() + todo = traducir_modulo(ScriptsKubeJS(ctx, exhaustivo=True), Traductor(Simulador(), memoria, Glosario())) + nuevo = ruta.read_text("utf-8") + assert todo.traducidos == 2 and "'«ES» The harvest festival starts tomorrow evening'" in nuevo + assert "'This is only a debug line for the log here'" in nuevo # console.log no es para el jugador + assert "'Some Name With Spaces'" in nuevo # una comparación es lógica From b93c013adba60b619705602de653f416c973d5fc Mon Sep 17 00:00:00 2001 From: Cristian Date: Fri, 6 Nov 2026 02:48:50 -0500 Subject: [PATCH 6/6] feat(scripts): traducir lo que se pasa a funciones propias que solo muestran texto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Analiza las funciones del script cuyo parámetro solo se muestra (directo, partido en líneas o dentro de una plantilla que se muestra) y traduce lo que se les pasa en esa posición. - Conservador: si el parámetro se compara, busca, guarda, devuelve o va en un id o comando, o la función se usa como valor o está repetida, no se toca nada. - Las cadenas del escáner de JavaScript guardan la posición de su argumento. --- CHANGELOG.md | 1 + src/traductor_es/formatos/flujo_js.py | 153 +++++++++++++++++++++++- src/traductor_es/formatos/javascript.py | 7 +- src/traductor_es/modulos/scripts.py | 17 +++ tests/test_funciones_de_texto.py | 122 +++++++++++++++++++ 5 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 tests/test_funciones_de_texto.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e3a504..af46924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Todos los cambios importantes de ModpackES quedan en este archivo. El formato si - Mensajes que un script arma por partes (`mensaje += efecto.name; player.tell(mensaje)`), con sus tablas de nombres. - `notify`, `kick`, `openChestGUI` y `openInventoryGUI` de KubeJS (métodos tomados del código de KubeJS 1.20 y 1.21). - **Modo exhaustivo** para los scripts de KubeJS (opcional, apagado por defecto; `--exhaustivo` y una casilla en «Opciones avanzadas»): traduce también toda frase que parezca texto aunque no se sepa dónde se muestra. Puede tocar lógica; todo queda con copia de seguridad. Nunca toca `console.log` ni comparaciones. +- **Funciones propias de un script que solo muestran texto** (`CreateStringTooltip(evento, objeto, texto)`, `crear(id, nombre)`…): se traduce lo que se les pasa en la posición de ese parámetro. Es conservador: si el parámetro se compara, se busca, se guarda, se devuelve, va en un id o en un comando, o la función se usa como valor o está repetida, no se toca nada. Un `id` junto a un `nombre` queda intacto. En un modpack real (TaidumCraft) pasó de 101 a 35 frases sin elegir. - Los métodos de texto de KubeJS ahora dependen de la versión (`notify`, `openChestGUI`… solo donde existen), comprobados en el código de KubeJS de 1.12 a 26.x (`formatos/kubejs_api.py`). - Mercado de Farming for Blockheads (`farmingforblockheads_compat`): se traducen los nombres de las pestañas. - El informe «Qué queda en inglés» también avisa de carpetas de datos con frases en inglés cuyo formato la app no conoce. diff --git a/src/traductor_es/formatos/flujo_js.py b/src/traductor_es/formatos/flujo_js.py index c4e2578..573ed59 100644 --- a/src/traductor_es/formatos/flujo_js.py +++ b/src/traductor_es/formatos/flujo_js.py @@ -12,7 +12,7 @@ from __future__ import annotations import re -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Iterable, Sequence from dataclasses import dataclass from .javascript import CadenaJs @@ -84,3 +84,154 @@ def cuerpos_de_tabla(src: str, cadenas: Sequence[CadenaJs], tablas: Iterable[str i += 1 cuerpos.append((m.end(), i)) return cuerpos + + +# ---------------------------------------------------------------------------------------------- funciones de texto +#: Métodos que transforman un texto sin cambiar de qué se trata (el resultado sigue siendo ese texto o sus líneas). +_TRANSFORMA = frozenset({ + "split", "trim", "trimStart", "trimEnd", "replace", "replaceAll", "slice", "substring", "concat", "join", "map", + "filter", "forEach", "toString", "padStart", "padEnd", "repeat", +}) # fmt: skip +#: Si en la línea se compara o se busca el texto, es lógica: esa función no se toca. +_LOGICA = re.compile( + r"[!=]==?(?!>)|\.(?:includes|indexOf|startsWith|endsWith|match|test|search|equals|localeCompare)\s*\(|\bswitch\b|\bcase\b" +) +_FUNCIONES = ( + re.compile(rf"\bfunction\s+({_IDENT})\s*\(([^)]*)\)\s*\{{"), + re.compile( + rf"\b(?:const|let|var)\s+({_IDENT})\s*=\s*(?:async\s*)?(?:function\s*\w*\s*)?\(([^)]*)\)\s*(?:=>\s*)?\{{" + ), + re.compile(rf"\b(?:const|let|var)\s+({_IDENT})\s*=\s*({_IDENT})\s*=>\s*\{{"), +) +_ASIGNACION = re.compile(rf"\s*(?:(?:const|let|var)\s+)?({_IDENT})\s*(\+?=)(?!=)") +_CALLBACK = re.compile(rf"(?:\(\s*({_IDENT})\s*(?:,[^)]*)?\)|\b({_IDENT}))\s*=>|function\s*\(\s*({_IDENT})") + + +def _en_blanco(src: str, cadenas: Sequence[CadenaJs]) -> str: + """El código con las cadenas y los comentarios en blanco (mismas posiciones): solo queda lo que es código.""" + letras = list(src) + for c in cadenas: + for i in range(c.inicio, c.fin): + if letras[i] != "\n": + letras[i] = " " + limpio = "".join(letras) + return re.sub(r"//[^\n]*|/\*.*?\*/", lambda m: re.sub(r"[^\n]", " ", m.group(0)), limpio, flags=re.S) + + +def _cierre(limpio: str, abierta: int) -> int: + profundidad, i = 1, abierta + while i < len(limpio) and profundidad: + profundidad += {"{": 1, "}": -1}.get(limpio[i], 0) + i += 1 + return i if profundidad == 0 else -1 + + +def _linea(cuerpo: str, pos: int) -> tuple[int, str]: + inicio = cuerpo.rfind("\n", 0, pos) + 1 + fin = cuerpo.find("\n", pos) + return inicio, cuerpo[inicio : len(cuerpo) if fin < 0 else fin] + + +def _tomar_callbacks(trozo: str, base: int, vistos: set[str], pendientes: list[str], declaraciones: set[int]) -> None: + """`texto.map(l => …)`: el parámetro del callback también es texto y se sigue; su declaración no es un uso.""" + for cb in _CALLBACK.finditer(trozo): + grupo = 1 if cb.group(1) else 2 if cb.group(2) else 3 + nuevo = cb.group(grupo) + if nuevo: + declaraciones.add(base + cb.start(grupo)) + if nuevo not in vistos: + vistos.add(nuevo) + pendientes.append(nuevo) + + +def _uso_permitido( + cuerpo: str, nombre: str, m: re.Match[str], vistos: set[str], pendientes: list[str], declaraciones: set[int] +) -> bool: + """Un uso de un nombre de texto que no es mostrarlo directamente: ¿es solo derivar otro texto de él?""" + inicio_linea, linea = _linea(cuerpo, m.start()) + if _LOGICA.search(linea): + return False + asignacion = _ASIGNACION.match(linea) + if asignacion: + if asignacion.start(1) + inicio_linea <= m.start() < asignacion.end(1) + inicio_linea: + return True # es el lado izquierdo: se vuelve a asignar + derivado = asignacion.group(1) + if derivado not in vistos: + vistos.add(derivado) + pendientes.append(derivado) + _tomar_callbacks(linea[m.end() - inicio_linea :], m.end(), vistos, pendientes, declaraciones) + return True + metodo = re.match(rf"\s*{re.escape(nombre)}\s*\.\s*(\w+)\s*\(", linea) + if metodo and metodo.group(1) in _TRANSFORMA and metodo.start() + inicio_linea <= m.start(): + _tomar_callbacks(linea[metodo.end() :], inicio_linea + metodo.end(), vistos, pendientes, declaraciones) + return True + return False + + +def _parametro_es_solo_texto( + cuerpo: str, + parametro: str, + patron: str, + plantillas: list[tuple[CadenaJs, set[str]]], + es_sumidero: Callable[[CadenaJs], bool], +) -> bool: + """Todos los usos del parámetro (y de lo derivado) son mostrarlo: nunca se compara, busca, guarda ni devuelve.""" + sumidero_antes = re.compile(rf"(?:{patron})\s*\(\s*$") + pendientes, vistos, sumideros = [parametro], {parametro}, 0 + declaraciones: set[int] = set() # dónde se declara el parámetro de un callback (`l => …`): no es un uso + while pendientes: + nombre = pendientes.pop() + # un `${nombre}` dentro de una plantilla vale solo si la plantilla se muestra tal cual + for c, nombres in plantillas: + if nombre in nombres and not es_sumidero(c): + return False + sumideros += nombre in nombres + for m in re.finditer(rf"(? 0 + + +def funciones_de_texto( + src: str, cadenas: Sequence[CadenaJs], patron: str, es_sumidero: Callable[[CadenaJs], bool] +) -> set[tuple[str, int]]: + """Funciones del propio script cuyo parámetro N solo sirve para mostrarse: {(nombre, posición del parámetro)}. + + Pasar una frase en esa posición es escribir un texto visible, aunque la llamada no sea `tell` ni `Text.of`. Es + conservador: si el parámetro se compara, se busca, se guarda, se devuelve o se pasa a algo que no se entiende, la + función queda fuera. La función tampoco puede usarse como valor (`callback = f`): solo se llama. + """ + limpio = _en_blanco(src, cadenas) + plantillas_todas = [ + (c, set(re.findall(_IDENT, " ".join(re.findall(r"\$\{([^{}\n]*)\}", c.texto))))) + for c in cadenas + if c.comilla == "`" and "${" in c.texto + ] + resultado: set[tuple[str, int]] = set() + definiciones: dict[str, int] = {} + encontradas: list[tuple[str, list[str], int, int, int]] = [] + for patron_def in _FUNCIONES: + for m in patron_def.finditer(limpio): + nombre, params = m.group(1), [p.strip() for p in m.group(2).split(",")] + fin = _cierre(limpio, m.end()) + if fin > 0: + definiciones[nombre] = definiciones.get(nombre, 0) + 1 + encontradas.append((nombre, params, m.start(), m.end(), fin)) + for nombre, params, desde, inicio, fin in encontradas: + fuera = limpio[:desde] + " " + limpio[fin:] # el resto del script, sin la propia definición + usos_como_valor = re.search(rf"(? str: base = f"{self.receptor}.{self.funcion}" if self.receptor else str(self.funcion) @@ -178,13 +180,16 @@ def escanear(src: str) -> list[CadenaJs]: if pila and pila[-1].tipo == "(" and pila[-1].primera is None: pila[-1].primera = texto contexto = tuple(m.nombre() for m in pila if m.tipo == "(" and m.funcion) - res.append(CadenaJs(i, fin, c, texto, funcion, receptor, _comparada(src, i, fin), contexto)) + argumento = marco.arg if marco and marco.tipo == "(" else -1 + res.append(CadenaJs(i, fin, c, texto, funcion, receptor, _comparada(src, i, fin), contexto, argumento)) i = fin continue if c == "(": pila.append(_Marco("(", *_llamada(src, i))) elif c in "[{": pila.append(_Marco(c)) + elif c == "," and pila and pila[-1].tipo == "(": + pila[-1].arg += 1 elif c in ")]}" and pila: pila.pop() i += 1 diff --git a/src/traductor_es/modulos/scripts.py b/src/traductor_es/modulos/scripts.py index 85b56a6..ff3d7e3 100644 --- a/src/traductor_es/modulos/scripts.py +++ b/src/traductor_es/modulos/scripts.py @@ -93,6 +93,19 @@ def _plantilla_segura(c: javascript.CadenaJs) -> bool: return c.comilla != "`" or c.texto.count("${") == len(_EXPRESION.findall(c.texto)) +def _es_sumidero(c: javascript.CadenaJs, llamadas: frozenset[str]) -> bool: + """La cadena se le pasa directamente a algo que le muestra texto al jugador (`tell`, `Text.of`…).""" + return c.funcion in llamadas or (c.funcion in AYUDAS_TEXTO and c.receptor in RECEPTORES_TEXTO) + + +def es_texto_de_funcion(c: javascript.CadenaJs, funciones: set[tuple[str, int]]) -> bool: + """La cadena va en la posición de un parámetro que la función solo muestra (ver `flujo_js.funciones_de_texto`).""" + if c.receptor is not None or c.funcion is None or c.comparada or (c.funcion, c.arg) not in funciones: + return False # una llamada con receptor (`x.f(...)`) es un método, no la función del script + t = c.texto.strip() + return _plantilla_segura(c) and es_traducible(t) and not parece_clave(t) and any(ch.isalpha() for ch in t) + + def textos_armados( src: str, cadenas: list[javascript.CadenaJs], flujo: flujo_js.Flujo, tablas: frozenset[str] ) -> list[javascript.CadenaJs]: @@ -232,6 +245,10 @@ def recolectar(self) -> list[Pieza]: en_comando[c.inicio] = cmd vistas = {c.inicio for c in elegidas} elegidas += [c for c in textos_armados(src, cadenas, flujo, tablas) if c.inicio not in vistas] + propias = flujo_js.funciones_de_texto(src, cadenas, patron, lambda c: _es_sumidero(c, llamadas)) + if propias: # funciones del propio script cuyo parámetro solo se muestra + vistas = {c.inicio for c in elegidas} + elegidas += [c for c in cadenas if c.inicio not in vistas and es_texto_de_funcion(c, propias)] if self.exhaustivo: vistas = {c.inicio for c in elegidas} elegidas += [ diff --git a/tests/test_funciones_de_texto.py b/tests/test_funciones_de_texto.py new file mode 100644 index 0000000..644f986 --- /dev/null +++ b/tests/test_funciones_de_texto.py @@ -0,0 +1,122 @@ +"""Funciones del propio script que solo muestran texto: se traduce lo que se les pasa, y nada que pueda ser lógica.""" + +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from traductor_es.fuentes import vanilla +from traductor_es.fuentes.instancia import Instancia +from traductor_es.modulos.base import Contexto, traducir_modulo +from traductor_es.modulos.scripts import ScriptsKubeJS +from traductor_es.motores.simulador import Simulador +from traductor_es.nucleo.glosario import Glosario +from traductor_es.nucleo.memoria import Memoria +from traductor_es.nucleo.traductor import Traductor +from traductor_es.salida.copias import Respaldo + +from . import fabrica + + +@pytest.fixture(autouse=True) +def _sin_lanzadores(monkeypatch: pytest.MonkeyPatch) -> None: + def ninguno() -> list[Path]: + return [] + + monkeypatch.setattr(vanilla, "raices_de_lanzadores", ninguno) + + +@pytest.fixture +def memoria() -> Iterator[Memoria]: + with Memoria(":memory:") as m: + yield m + + +def _traducir(tmp_path: Path, memoria: Memoria, src: str) -> tuple[str, int]: + juego = fabrica.prism(tmp_path, "1.20.1") + ruta = juego / "kubejs" / "server_scripts" / "s.js" + ruta.parent.mkdir(parents=True) + ruta.write_text(src, encoding="utf-8") + with Instancia(juego) as inst: + resumen = traducir_modulo( + ScriptsKubeJS(Contexto(inst, Respaldo(juego))), Traductor(Simulador(), memoria, Glosario()) + ) + return ruta.read_text("utf-8"), resumen.traducidos + + +# Parámetros que solo se muestran, directamente o después de partirlos en líneas (como el tooltip de Create de un modpack real) +TEXTO_PURO = r""" +function aviso(player, msg) { player.tell(msg) } +const consejo = (evento, objeto, texto) => { + texto = texto.trim() + const lineas = texto.split('\n') + lineas.forEach(linea => { + evento.add(objeto, Text.of(linea).gold()) + }) +} +const decir = (p, quien, frase) => { p.tell(`${quien}: ${frase}`) } +aviso(player, 'Welcome back to the farm') +consejo(e, 'minecraft:stone', 'Hold shift to see the summary\nIt can be mined with a pickaxe') +decir(player, 'Laly', 'The museum opens at dawn') +""" + + +def test_funciones_que_solo_muestran_texto(tmp_path: Path, memoria: Memoria) -> None: + nuevo, n = _traducir(tmp_path, memoria, TEXTO_PURO) + assert "aviso(player, '«ES» Welcome back to the farm')" in nuevo + assert "'«ES» Hold shift to see the summary\\nIt can be mined with a pickaxe'" in nuevo + assert "'«ES» The museum opens at dawn'" in nuevo # `quien` y `frase` se muestran juntos en la plantilla + assert "'minecraft:stone'" in nuevo and n >= 3 + + +# Cada función usa su parámetro como lógica de algún modo: ninguna debe tocarse +PELIGROSAS = r""" +function comparar(p, texto) { if (texto == 'Open the door now') p.tell(texto) } +function buscar(p, texto) { if (texto.includes('door')) p.tell(texto) } +function guardar(p, texto) { p.persistentData.put(texto, 1); p.tell(texto) } +function devolver(texto) { return texto } +function mostrar_y_clave(p, texto) { p.tell(texto); p.data[texto] = true } +function por_plantilla(p, texto) { p.runCommand(`give @s ${texto}`) ; p.tell(texto) } +function como_valor(p, texto) { p.tell(texto) } +const alias = como_valor +function repetida(p, texto) { p.tell(texto) } +function repetida(p, texto) { p.tell(texto) } +comparar(player, 'Open the door now') +buscar(player, 'Look at the door here') +guardar(player, 'Some key with spaces') +devolver('Return this phrase please') +mostrar_y_clave(player, 'Another key with spaces') +por_plantilla(player, 'Some item with spaces') +como_valor(player, 'Called through a value') +repetida(player, 'Defined twice in the file') +""" + + +def test_si_el_parametro_es_logica_no_se_toca_nada(tmp_path: Path, memoria: Memoria) -> None: + nuevo, n = _traducir(tmp_path, memoria, PELIGROSAS) + assert nuevo == PELIGROSAS and n == 0 + + +def test_un_metodo_con_el_mismo_nombre_no_es_la_funcion_del_script(tmp_path: Path, memoria: Memoria) -> None: + src = "function aviso(p, msg) { p.tell(msg) }\ninventario.aviso(player, 'This is another object method')\n" + nuevo, n = _traducir(tmp_path, memoria, src) + assert nuevo == src and n == 0 + + +def test_solo_la_posicion_del_parametro_y_nunca_ids(tmp_path: Path, memoria: Memoria) -> None: + src = ( + "function aviso(p, msg, id) { p.tell(msg) }\naviso(player, 'Something to show here', 'quest id with spaces')\n" + ) + nuevo, _ = _traducir(tmp_path, memoria, src) + assert "'«ES» Something to show here'" in nuevo and "'quest id with spaces'" in nuevo + + +def test_el_id_que_va_en_una_plantilla_no_se_toca_pero_el_nombre_si(tmp_path: Path, memoria: Memoria) -> None: + src = ( + "function crear(id, nombre) {\n" + " event.create(id).displayName(nombre).model(`kubejs:block/${id}`)\n" + "}\n" + "crear('plushie with spaces', 'Taidum Plushie')\n" + ) + nuevo, _ = _traducir(tmp_path, memoria, src) + assert "'plushie with spaces'" in nuevo and "'«ES» Taidum Plushie'" in nuevo