"""Traducciones de los addons que toca un PR. Qué términos son traducibles y a qué registro pertenece cada uno (las líneas «#:» del .po, sin las que Odoo no carga la traducción) solo lo sabe Odoo, así que el .pot no se escribe a mano: lo exporta el job de tests del CI (tools/ci/run_tests.py) y aquí se recoge. Con él se preparan los .po de cada idioma; rellenar los msgstr vacíos es trabajo de Claude, y check() repasa lo que deja. """ import re import polib # Odoo pone la fecha de exportación en la cabecera: cambiaría en cada PR. VOLATILE_METADATA = ("POT-Creation-Date", "PO-Revision-Date") PLURAL_FORMS = "nplurals=2; plural=(n != 1);" PLACEHOLDER = re.compile(r"%\(\w+\)[sdfr]|%[sdfr]|\{\w*\}") def changed_addons(repo_dir, files): """Addons con algún fichero modificado fuera de su carpeta i18n.""" addons = set() for name in files: addon, _, rest = name.partition("/") if rest and not rest.startswith("i18n/"): if (repo_dir / addon / "__manifest__.py").is_file(): addons.add(addon) return sorted(addons) def terms(po): return [(e.msgid, e.msgstr, e.comment, e.occurrences, e.flags) for e in po] def pending(po): return sum(1 for entry in po if not entry.msgstr) def placeholders(text): return sorted(PLACEHOLDER.findall(text)) def write_if_changed(po, path): """No reescribe un fichero que ya dice lo mismo: evita diffs de formato.""" if path.is_file() and terms(polib.pofile(str(path))) == terms(po): return path.parent.mkdir(exist_ok=True) po.save(str(path)) def merge(po, pot): """Deja en el .po los términos del .pot, y solo esos, con sus referencias.""" po.merge(pot) for entry in po.obsolete_entries(): po.remove(entry) po.sort(key=lambda entry: entry.msgid) def prepare(addon_dir, pot_file, langs): """Pone al día el .pot del addon y un .po por idioma. Devuelve el estado que necesita check(), o None si el addon no tiene términos traducibles. """ pot = polib.pofile(str(pot_file)) if not pot: return None for key in VOLATILE_METADATA: pot.metadata.pop(key, None) i18n_dir = addon_dir / "i18n" write_if_changed(pot, i18n_dir / f"{addon_dir.name}.pot") files = {} for lang in langs: path = i18n_dir / f"{lang}.po" new = not path.is_file() if new: po = polib.POFile() po.header = pot.header po.metadata = dict(pot.metadata, Language=lang) po.metadata["Plural-Forms"] = PLURAL_FORMS else: po = polib.pofile(str(path)) merge(po, pot) write_if_changed(po, path) files[lang] = { "path": path, "new": new, "pending": pending(po), "backup": path.read_text(), } return {"pot": pot, "files": files} def check(state): """Repasa los .po después de que Claude los haya rellenado. Anota en cada fichero cuántas traducciones se han añadido (`done`) y cuántas faltan (`pending`), y devuelve los avisos que deben salir en la revisión. """ notes = [] for info in state["files"].values(): path = info["path"] name = f"{path.parent.parent.name}/i18n/{path.name}" try: po = polib.pofile(str(path)) if path.is_file() else None except (OSError, UnicodeDecodeError): po = None if po is None: notes.append( f"`{name}` quedó ilegible y se han descartado sus traducciones." ) path.write_text(info["backup"]) po = polib.pofile(str(path)) merge(po, state["pot"]) before = {e.msgid: e.msgstr for e in polib.pofile(info["backup"])} broken = 0 for entry in po: if not entry.msgstr or entry.msgstr == before.get(entry.msgid): continue # Una traducción con otros marcadores falla al formatear el texto. if placeholders(entry.msgstr) != placeholders(entry.msgid): entry.msgstr = "" broken += 1 if broken: notes.append( f"`{name}`: {broken} traducciones descartadas porque no " "conservaban los marcadores (`%s`, `%(nombre)s`) del original." ) info["done"] = info["pending"] - pending(po) info["pending"] = pending(po) if info["new"] and info["pending"] == len(po): # Un .po recién creado y sin ninguna traducción no aporta nada. path.unlink() info["removed"] = True elif path.read_text() != info["backup"]: po.save(str(path)) return notes