vienalatina/scripts/translate.py
Claude da8cc784be
Remove generated siblings when their source is deleted
Deleting a post in Decap removes one file: the Spanish original. The German
and Portuguese versions were written by this script, so nothing else deletes
them, and they stayed on the site as posts in a language with no original —
/de/ and /pt-br/ kept listing articles that no longer exist in Spanish.

The pipeline had no concept of removal at all. changed_markdown() filters the
push diff to A and M, so a deletion never reached the translate step; a push
that only deletes posts arrived with no sources and returned early, which is
precisely the case that leaves siblings stranded.

Siblings are now reaped by scanning the content tree for a translated_from
whose source file is gone, which also clears debris from earlier runs and from
renames. A frozen sibling is reported rather than deleted: someone edited that
translation by hand, and throwing the work away on an inference is worse than
leaving one stale page until they remove it themselves. The reaper runs even
when nothing was translated, and commit_and_push stages with --all so a
vanished path commits as a deletion.

Verified: reaps both siblings of a deleted source, keeps siblings whose source
is alive, keeps a frozen orphan, never touches a human-authored original.
16/16 pipeline and 15/15 markdown checks still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-22 09:49:20 +00:00

332 lines
13 KiB
Python

#!/usr/bin/env python3
"""Self-hosted translation step for Woodpecker CI.
Replaces the DeepL API call this script used to make, and restores the
multi-source behaviour of the original WordPress plugin: any of the three site
languages may be the authored original, and the other two are generated from
it. Translation runs on a model shipped inside the pipeline image, so there is
no API key, no quota and no third-party request.
Loop prevention, which was structural back when only Spanish could be a source:
* a generated sibling carries `translated_from`, and a file carrying it is
never itself treated as a source;
* the bot's own commits carry [skip-translate] and are skipped outright.
Deletion is handled too, and has to be: removing a post in the CMS deletes one
file, the original, and the siblings this script wrote would otherwise stay on
the site as posts in a language with no original. Any sibling whose source has
disappeared is removed, unless it is frozen.
`manual_translation: true` means "hands off", on both sides:
* on an authored source — do not generate siblings for this post at all;
* on a generated sibling — never overwrite it again.
Failure behaviour: any translation error exits non-zero, the pipeline goes red,
and nothing partial is committed — no half-translated sets.
Environment:
MT_MODEL_DIR / MT_TOKENIZER / MT_COMPUTE_TYPE / MT_THREADS see translation/
CI_COMMIT_SHA / CI_PREV_COMMIT_SHA / CI_COMMIT_MESSAGE from Woodpecker
GITEA_PUSH_TOKEN bot push token
Dependencies: ctranslate2, transformers, sentencepiece, sentencex, pyyaml
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
from pathlib import Path
import yaml
from translation.markdown import translate_markdown, translate_text
from translation.provider import SITE_LANGS
def build_provider():
"""MT_PROVIDER=m2m100 falls back to the single-model engine."""
if os.environ.get("MT_PROVIDER", "opus") == "m2m100":
from translation.ctranslate_provider import CTranslate2Provider
return CTranslate2Provider()
from translation.opus_provider import OpusMTProvider
return OpusMTProvider()
REPO_ROOT = Path(__file__).resolve().parent.parent
CONTENT_DIR = REPO_ROOT / "content"
# Frontmatter strings translated alongside the body. Note `categories` is
# deliberately absent: the taxonomy terms stay Spanish in every language, or
# Hugo would fork the taxonomy per language.
TRANSLATED_KEYS = ("title", "description")
BOT_NAME = "vienalatina-translations"
BOT_EMAIL = "translations@vienalatina.com"
SKIP_MARKER = "[skip-translate]"
def run(*args: str, check: bool = True) -> str:
result = subprocess.run(args, cwd=REPO_ROOT, check=check, capture_output=True, text=True)
return result.stdout.strip()
def split_lang(path: Path) -> tuple[str, str] | None:
"""('mi-articulo', 'es') for mi-articulo.es.md, else None."""
if path.suffix != ".md":
return None
stem = path.name[: -len(".md")]
for lang in SITE_LANGS:
if stem.endswith(f".{lang}"):
return stem[: -(len(lang) + 1)], lang
return None
# Decap resolves a filename clash by appending a counter, turning
# `hola-mundo.es.md` into `hola-mundo.es-1.md`. That name no longer ends in a
# language, so Hugo stops pairing it with its siblings and split_lang() returns
# None: the post publishes in Spanish and is never translated, with nothing
# anywhere reporting it. Recognising the shape lets us fail loudly instead.
CLASH_SUFFIX = re.compile(r"\.(?:%s)-\d+$" % "|".join(re.escape(l) for l in SITE_LANGS))
def split_frontmatter(text: str) -> tuple[dict, str]:
match = re.match(r"\A---\n(.*?)\n---\n?(.*)\Z", text, re.DOTALL)
if not match:
return {}, text
return yaml.safe_load(match.group(1)) or {}, match.group(2)
def join_frontmatter(fm: dict, body: str) -> str:
front = yaml.safe_dump(fm, allow_unicode=True, sort_keys=False, default_flow_style=False)
return f"---\n{front}---\n\n{body.lstrip()}"
def read_frontmatter(path: Path) -> dict:
if not path.exists():
return {}
fm, _ = split_frontmatter(path.read_text(encoding="utf-8"))
return fm
def is_generated(fm: dict) -> bool:
return bool(fm.get("translated_from"))
def is_frozen(fm: dict) -> bool:
return bool(fm.get("manual_translation"))
def changed_markdown() -> list[Path]:
"""Content files touched by the pushed commits."""
head = os.environ.get("CI_COMMIT_SHA", "HEAD")
prev = os.environ.get("CI_PREV_COMMIT_SHA", "")
# --no-renames matters: git reports a renamed file as R, which --diff-filter=AM
# drops, so renaming a post made it invisible here and it silently kept the
# siblings of its old name — or, for a post that never had any, got none.
# Without rename detection the same change arrives as D + A, and the A is a
# translation source like any other.
if prev and not set(prev) <= {"0"}:
out = run("git", "diff", "--name-only", "--no-renames", "--diff-filter=AM", prev, head)
elif run("git", "rev-list", "--count", "HEAD") != "1":
out = run("git", "diff", "--name-only", "--no-renames", "--diff-filter=AM", "HEAD~1", "HEAD")
else: # first commit in the repo
out = run("git", "ls-files")
paths = []
for line in out.splitlines():
rel = Path(line.strip())
if rel.parts[:1] == ("content",) and (REPO_ROOT / rel).exists():
paths.append(REPO_ROOT / rel)
return paths
def authored_sources(paths: list[Path]) -> list[tuple[Path, str, str]]:
"""(path, basename, lang) for files that may act as a translation source."""
sources = []
for path in paths:
parsed = split_lang(path)
if not parsed:
rel = path.relative_to(REPO_ROOT)
if CLASH_SUFFIX.search(path.name[: -len(".md")] if path.suffix == ".md" else ""):
raise SystemExit(
f"{rel}: filename ends in a clash counter, so it is neither a "
f"translation source nor a sibling — it would publish untranslated.\n"
f"Rename it to <something-unique>.<lang>.md (the CMS produced this "
f"because another post already claimed the name)."
)
continue
basename, lang = parsed
fm = read_frontmatter(path)
if is_generated(fm):
continue # machine output is never a source
if is_frozen(fm):
print(f"{path.relative_to(REPO_ROOT)}: manual_translation=true — not translated")
continue
sources.append((path, basename, lang))
return sources
def missing_siblings() -> list[Path]:
"""Every authored source missing at least one sibling."""
incomplete = []
for path in sorted(CONTENT_DIR.rglob("*.md")):
parsed = split_lang(path)
if not parsed:
continue
basename, lang = parsed
if is_generated(read_frontmatter(path)):
continue
for target in SITE_LANGS:
if target != lang and not path.with_name(f"{basename}.{target}.md").exists():
incomplete.append(path)
break
return incomplete
def orphaned_siblings() -> list[Path]:
"""Generated siblings whose source no longer exists.
Deleting a post in the CMS removes one file — the Spanish original. The
German and Portuguese siblings were written by this script, not by the
author, so nothing else deletes them and they stay on the site as posts in
a language that has no original. A rename leaves the same debris, since it
is a delete plus an add.
This scans the whole content tree rather than the push diff, so it also
clears siblings orphaned by earlier runs that predate this check.
"""
orphans = []
for path in sorted(CONTENT_DIR.rglob("*.md")):
parsed = split_lang(path)
if not parsed:
continue
basename, lang = parsed
fm = read_frontmatter(path)
source_lang = fm.get("translated_from")
if not source_lang:
continue # authored by a human; only its author deletes it
if path.with_name(f"{basename}.{source_lang}.md").exists():
continue
rel = path.relative_to(REPO_ROOT)
if is_frozen(fm):
# Someone edited this translation by hand. Deleting it would throw
# that work away on the strength of an inference, so say so instead.
print(f" {rel}: source is gone but manual_translation=true — left in place; "
f"delete it by hand if the post is meant to disappear")
continue
orphans.append(path)
return orphans
def translate_file(source: Path, basename: str, src_lang: str, provider) -> list[Path]:
fm, body = split_frontmatter(source.read_text(encoding="utf-8"))
written: list[Path] = []
for tgt in SITE_LANGS:
if tgt == src_lang:
continue
target = source.with_name(f"{basename}.{tgt}.md")
target_fm = read_frontmatter(target)
if is_frozen(target_fm):
print(f" {target.relative_to(REPO_ROOT)}: manual_translation=true — skipped")
continue
new_fm = dict(fm)
for key in TRANSLATED_KEYS:
if fm.get(key):
new_fm[key] = translate_text(str(fm[key]), src_lang, tgt, provider)
new_fm["lang"] = tgt
new_fm["translated_from"] = src_lang
new_fm["manual_translation"] = False
# Never inherit the source's slug: that would move a migrated sibling's
# URL onto the source's and break inbound links. Keep the slug the
# target already had; otherwise let Hugo fall back to the filename.
new_fm.pop("slug", None)
if target_fm.get("slug"):
new_fm["slug"] = target_fm["slug"]
target.write_text(
join_frontmatter(new_fm, translate_markdown(body, src_lang, tgt, provider)),
encoding="utf-8",
)
written.append(target)
print(f" {target.relative_to(REPO_ROOT)}: written")
return written
def commit_and_push(files: list[Path], summary: str) -> None:
branch = os.environ.get("CI_COMMIT_BRANCH", "main")
run("git", "config", "user.name", BOT_NAME)
run("git", "config", "user.email", BOT_EMAIL)
token = os.environ.get("GITEA_PUSH_TOKEN", "")
repo = os.environ.get("CI_REPO", "pablo/vienalatina")
if token: # clone credentials are read-only; pushing needs the bot token
run("git", "remote", "set-url", "origin",
f"https://{BOT_NAME}:{token}@git.vienalatina.com/{repo}.git")
# --all so a path that no longer exists stages as a deletion rather than
# failing; reaped orphans arrive here alongside freshly written siblings.
run("git", "add", "--all", "--", *[str(f) for f in files])
if not run("git", "status", "--porcelain"):
print("Translations identical to committed siblings — nothing to push.")
return
run("git", "commit", "-m", f"translate: {summary} {SKIP_MARKER}")
run("git", "push", "origin", f"HEAD:{branch}")
print(f"Pushed sibling commit to {branch}.")
def main() -> None:
parser = argparse.ArgumentParser(description="Generate translated content siblings.")
parser.add_argument("--backfill", action="store_true",
help="translate every source missing siblings, not just changed files")
parser.add_argument("--no-push", action="store_true",
help="write siblings but do not commit or push (local testing)")
args = parser.parse_args()
if SKIP_MARKER in os.environ.get("CI_COMMIT_MESSAGE", ""):
print(f"{SKIP_MARKER} commit — nothing to translate.")
return
paths = missing_siblings() if args.backfill else changed_markdown()
sources = authored_sources(paths)
written: list[Path] = []
if sources:
provider = build_provider()
for source, basename, lang in sources:
print(f"Translating {source.relative_to(REPO_ROOT)} (from {lang}):")
written.extend(translate_file(source, basename, lang, provider))
else:
print("No authored content changed — nothing to translate.")
# Runs whether or not anything was translated: a push that only deletes
# posts reaches this point with no sources at all, and that is exactly the
# case where siblings are left stranded.
removed = []
for orphan in orphaned_siblings():
orphan.unlink()
removed.append(orphan)
print(f" {orphan.relative_to(REPO_ROOT)}: source deleted — removed")
touched = written + removed
if not touched:
print("Nothing to commit.")
return
if args.no_push:
print(f"--no-push: wrote {len(written)} and removed {len(removed)} files, "
f"leaving them uncommitted.")
return
parts = []
if written:
parts.append(f"update {len(written)} generated siblings")
if removed:
parts.append(f"remove {len(removed)} orphaned by a deleted source")
commit_and_push(touched, " and ".join(parts))
if __name__ == "__main__":
main()