From e16f52ce39610be89b6c50157b46db1359eda2e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:34:34 +0000 Subject: [PATCH 1/3] Initial plan From c46bdee840e67b1d0916fde1b0544bb52c06e706 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:41:54 +0000 Subject: [PATCH 2/3] feat: add scripts/sync_i18n.py for automated i18n translation sync Co-authored-by: arnaud4d <3355051+arnaud4d@users.noreply.github.com> --- scripts/.env.example | 13 + scripts/README_sync_i18n.md | 162 ++++ scripts/__pycache__/sync_i18n.cpython-312.pyc | Bin 0 -> 30537 bytes scripts/requirements_sync_i18n.txt | 3 + scripts/sync_i18n.py | 763 ++++++++++++++++++ 5 files changed, 941 insertions(+) create mode 100644 scripts/.env.example create mode 100644 scripts/README_sync_i18n.md create mode 100644 scripts/__pycache__/sync_i18n.cpython-312.pyc create mode 100644 scripts/requirements_sync_i18n.txt create mode 100644 scripts/sync_i18n.py diff --git a/scripts/.env.example b/scripts/.env.example new file mode 100644 index 00000000000000..8bb9d012ad1a06 --- /dev/null +++ b/scripts/.env.example @@ -0,0 +1,13 @@ +# Copier ce fichier en .env et renseigner les valeurs + +# Token GitHub avec droits lecture et écriture sur le dépôt +GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Clé API OpenAI +OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Dépôt cible (optionnel, peut être passé en argument --repo) +GITHUB_REPO=doc4d/docs + +# Branche cible (optionnel, peut être passée en argument --branch) +GITHUB_BRANCH=main diff --git a/scripts/README_sync_i18n.md b/scripts/README_sync_i18n.md new file mode 100644 index 00000000000000..7714cfe239b068 --- /dev/null +++ b/scripts/README_sync_i18n.md @@ -0,0 +1,162 @@ +# sync_i18n.py — Synchronisation automatique des traductions i18n + +Outil Python autonome qui, à partir d'un SHA de commit GitHub, identifie les fichiers Markdown anglais modifiés et propage automatiquement les modifications dans toutes les langues cibles du dossier `i18n/`. + +## Prérequis + +- Python 3.9 ou supérieur +- Un token GitHub avec droits de lecture et d'écriture sur le dépôt +- Une clé API OpenAI + +## Installation + +```bash +cd scripts/ +pip install -r requirements_sync_i18n.txt +``` + +## Configuration + +Copier le fichier `.env.example` en `.env` et renseigner les valeurs : + +```bash +cp .env.example .env +# Éditer .env avec vos valeurs +``` + +Variables d'environnement : + +| Variable | Description | Obligatoire | +|---|---|---| +| `GITHUB_TOKEN` | Token GitHub (lecture + écriture) | ✅ | +| `OPENAI_API_KEY` | Clé API OpenAI | ✅ | +| `GITHUB_REPO` | Dépôt au format `owner/repo` | Non (défaut : `doc4d/docs`) | +| `GITHUB_BRANCH` | Branche cible du commit i18n | Non (défaut : `main`) | + +## Utilisation + +### Commande de base + +```bash +python sync_i18n.py +``` + +### Options disponibles + +``` +python sync_i18n.py --help + +usage: sync_i18n.py [-h] [--repo OWNER/REPO] [--branch BRANCH] + [--dry-run] [--langs LANG [LANG ...]] [--env FILE] + sha + +Arguments positionnels : + sha SHA du commit à synchroniser + +Options : + --repo OWNER/REPO Dépôt GitHub (défaut : doc4d/docs) + --branch BRANCH Branche cible (défaut : main) + --dry-run Simule l'exécution sans créer de commit + --langs fr es ja pt Langues cibles (défaut : fr es ja pt) + --env FILE Chemin vers le fichier .env +``` + +### Exemples + +```bash +# Synchroniser un commit sur la branche main +python sync_i18n.py 881fdd7479b057cf619159e6b5677b38a9df7815 + +# Simulation sans commit (dry run) +python sync_i18n.py 881fdd7479b057cf619159e6b5677b38a9df7815 --dry-run + +# Seulement le français et l'espagnol +python sync_i18n.py 881fdd7479b057cf619159e6b5677b38a9df7815 --langs fr es + +# Sur une branche de feature +python sync_i18n.py abc123def456 --branch feature/my-branch + +# Dépôt différent +python sync_i18n.py abc123def456 --repo myorg/myrepo --branch develop +``` + +## Fonctionnement + +### 1. Récupération du commit + +Le script appelle `GET /repos/{owner}/{repo}/commits/{sha}` pour obtenir la liste des fichiers modifiés et leurs diffs (patch unifié). + +### 2. Filtrage + +Seuls les fichiers `.md` dont le chemin commence par `docs/` ou `versioned_docs/` et dont le statut est `modified` ou `added` sont traités. + +### 3. Mapping des chemins + +| Source EN | Fichier i18n cible | +|---|---| +| `docs/foo/bar.md` | `i18n/{lang}/docusaurus-plugin-content-docs/current/foo/bar.md` | +| `versioned_docs/version-21/foo/bar.md` | `i18n/{lang}/docusaurus-plugin-content-docs/version-21/foo/bar.md` | +| `versioned_docs/version-21-R2/foo/bar.md` | `i18n/{lang}/docusaurus-plugin-content-docs/version-21-R2/foo/bar.md` | + +### 4. Traduction + +Pour chaque fichier modifié et chaque langue cible : + +- **Fichier existant** : le diff est analysé pour extraire les blocs `old_text → new_text`. Seuls les passages modifiés sont re-traduits, en conservant le style et la terminologie existants. +- **Nouveau fichier** : l'intégralité du contenu EN est traduit. + +La traduction est effectuée via l'API OpenAI (modèle `gpt-4o`). Les éléments suivants sont **préservés** : +- Frontmatter YAML (`--- ... ---`) +- Blocs de code (` ``` `) +- Commentaires HTML (``) +- Balises HTML et attributs +- Noms de commandes 4D en gras ou en code inline +- URLs dans les liens (seul le texte visible est traduit) + +### 5. Commit groupé + +Toutes les modifications sont regroupées en un seul commit via l'API Git de GitHub (création d'un arbre + commit + mise à jour de la référence de branche). + +Message de commit : `i18n sync: {message_original} ({sha[:8]})` + +### 6. Rapport + +En fin d'exécution, un résumé est affiché : + +``` +============================================================ + RAPPORT DE SYNCHRONISATION I18N +============================================================ + Commit traité : 881fdd7479b0 + Fichiers EN : 6 + Traductions : 24 + Fichiers créés : 0 + Fichiers mis à j.: 24 + Erreurs : aucune +============================================================ +``` + +## Codes de retour + +| Code | Signification | +|---|---| +| `0` | Succès total | +| `1` | Erreur fatale (token manquant, commit introuvable, etc.) | +| `2` | Succès partiel (au moins une erreur non fatale) | + +## Gestion des erreurs + +- **Fichier traduit introuvable** : le fichier est traduit en intégralité et créé. +- **Passage à remplacer introuvable** : un avertissement est loggé, le reste du fichier est traité normalement. +- **Rate limiting OpenAI** : retente automatiquement en respectant le délai `Retry-After`. +- **Erreur réseau** : 3 tentatives avec délai exponentiel. + +## Structure du projet + +``` +scripts/ +├── sync_i18n.py ← Script principal +├── requirements_sync_i18n.txt ← Dépendances Python +├── .env.example ← Template de configuration +└── README_sync_i18n.md ← Cette documentation +``` diff --git a/scripts/__pycache__/sync_i18n.cpython-312.pyc b/scripts/__pycache__/sync_i18n.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c0bc73c80a91201b2ab583d5c7517cf3d3f8e60 GIT binary patch literal 30537 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 geM41>cTDg5#-Xnqx_ILEpZS0=2.31.0 +openai>=1.0.0 +python-dotenv>=1.0.0 diff --git a/scripts/sync_i18n.py b/scripts/sync_i18n.py new file mode 100644 index 00000000000000..1c0ae673e96bcd --- /dev/null +++ b/scripts/sync_i18n.py @@ -0,0 +1,763 @@ +#!/usr/bin/env python3 +""" +sync_i18n.py — Synchronisation automatique des traductions i18n à partir d'un commit GitHub. + +Usage: + python sync_i18n.py [--repo owner/repo] [--branch branch] [--dry-run] + +Variables d'environnement requises (voir .env.example) : + GITHUB_TOKEN — Token GitHub avec droits lecture/écriture + OPENAI_API_KEY — Clé API OpenAI +""" + +from __future__ import annotations + +import argparse +import base64 +import logging +import os +import re +import sys +import time +from dataclasses import dataclass, field +from typing import Optional + +import requests +from dotenv import load_dotenv + +# --------------------------------------------------------------------------- +# Configuration du logging +# --------------------------------------------------------------------------- + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constantes +# --------------------------------------------------------------------------- + +TARGET_LANGS = ["fr", "es", "ja", "pt"] +DOCS_PREFIX = "docs/" +VERSIONED_PREFIX = "versioned_docs/" +I18N_BASE = "i18n/{lang}/docusaurus-plugin-content-docs" +OPENAI_MODEL = "gpt-4o" +MAX_RETRIES = 3 +RETRY_DELAY = 5 # secondes + +# --------------------------------------------------------------------------- +# Structures de données +# --------------------------------------------------------------------------- + + +@dataclass +class FilePatch: + """Représente un fichier modifié dans un commit.""" + + filename: str + status: str # 'modified', 'added', 'removed' + patch: str = "" + raw_url: str = "" + + +@dataclass +class TranslationChange: + """Un bloc de modification à traduire.""" + + old_text: str + new_text: str + + +@dataclass +class FileUpdate: + """Mise à jour à appliquer à un fichier traduit.""" + + path: str + content: str + sha: Optional[str] = None # SHA du fichier existant (None = nouveau fichier) + + +@dataclass +class SyncReport: + """Rapport d'exécution.""" + + commit_sha: str + files_processed: int = 0 + translations_applied: int = 0 + files_created: int = 0 + files_updated: int = 0 + errors: list[str] = field(default_factory=list) + + def print_summary(self) -> None: + print("\n" + "=" * 60) + print(" RAPPORT DE SYNCHRONISATION I18N") + print("=" * 60) + print(f" Commit traité : {self.commit_sha[:12]}") + print(f" Fichiers EN : {self.files_processed}") + print(f" Traductions : {self.translations_applied}") + print(f" Fichiers créés : {self.files_created}") + print(f" Fichiers mis à j.: {self.files_updated}") + if self.errors: + print(f" Erreurs : {len(self.errors)}") + for err in self.errors: + print(f" • {err}") + else: + print(" Erreurs : aucune") + print("=" * 60 + "\n") + + +# --------------------------------------------------------------------------- +# Client GitHub +# --------------------------------------------------------------------------- + + +class GitHubClient: + """Client pour l'API GitHub REST.""" + + def __init__(self, token: str, repo: str) -> None: + self.token = token + self.repo = repo + self.session = requests.Session() + self.session.headers.update( + { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28", + } + ) + self.base_url = f"https://api.github.com/repos/{repo}" + + def _get(self, path: str, **kwargs) -> dict | list: + url = f"{self.base_url}{path}" + for attempt in range(1, MAX_RETRIES + 1): + try: + resp = self.session.get(url, **kwargs) + if resp.status_code == 404: + return {} + resp.raise_for_status() + return resp.json() + except requests.RequestException as exc: + if attempt == MAX_RETRIES: + raise + log.warning("Tentative %d/%d échouée (%s). Retente dans %ds…", + attempt, MAX_RETRIES, exc, RETRY_DELAY) + time.sleep(RETRY_DELAY) + return {} # unreachable + + def _post(self, path: str, payload: dict) -> dict: + url = f"{self.base_url}{path}" + for attempt in range(1, MAX_RETRIES + 1): + try: + resp = self.session.post(url, json=payload) + resp.raise_for_status() + return resp.json() + except requests.RequestException as exc: + if attempt == MAX_RETRIES: + raise + log.warning("Tentative %d/%d échouée (%s). Retente dans %ds…", + attempt, MAX_RETRIES, exc, RETRY_DELAY) + time.sleep(RETRY_DELAY) + return {} + + def _patch(self, path: str, payload: dict) -> dict: + url = f"{self.base_url}{path}" + resp = self.session.patch(url, json=payload) + resp.raise_for_status() + return resp.json() + + def get_commit(self, sha: str) -> dict: + """Récupère les détails d'un commit.""" + return self._get(f"/commits/{sha}") + + def get_file(self, path: str, ref: str = "") -> dict: + """Récupère le contenu d'un fichier (base64 encodé).""" + params = {"ref": ref} if ref else {} + return self._get(f"/contents/{path}", params=params) + + def get_branch(self, branch: str) -> dict: + """Récupère les infos d'une branche.""" + return self._get(f"/branches/{branch}") + + def get_raw_content(self, url: str) -> str: + """Télécharge le contenu brut d'un fichier depuis une URL raw.""" + for attempt in range(1, MAX_RETRIES + 1): + try: + resp = self.session.get(url) + resp.raise_for_status() + return resp.text + except requests.RequestException as exc: + if attempt == MAX_RETRIES: + raise + time.sleep(RETRY_DELAY) + return "" + + def decode_file_content(self, file_data: dict) -> str: + """Décode le contenu base64 d'un fichier GitHub.""" + if not file_data or "content" not in file_data: + return "" + return base64.b64decode(file_data["content"]).decode("utf-8") + + def create_tree(self, base_tree_sha: str, updates: list[FileUpdate]) -> str: + """Crée un nouvel arbre Git avec les fichiers modifiés.""" + tree_items = [ + { + "path": u.path, + "mode": "100644", + "type": "blob", + "content": u.content, + } + for u in updates + ] + result = self._post( + "/git/trees", + {"base_tree": base_tree_sha, "tree": tree_items}, + ) + return result["sha"] + + def create_commit( + self, message: str, tree_sha: str, parent_sha: str + ) -> str: + """Crée un commit Git.""" + result = self._post( + "/git/commits", + { + "message": message, + "tree": tree_sha, + "parents": [parent_sha], + }, + ) + return result["sha"] + + def update_ref(self, branch: str, commit_sha: str) -> None: + """Met à jour la référence d'une branche.""" + self._patch( + f"/git/refs/heads/{branch}", + {"sha": commit_sha, "force": False}, + ) + + +# --------------------------------------------------------------------------- +# Client OpenAI +# --------------------------------------------------------------------------- + + +class TranslationClient: + """Client pour l'API OpenAI (traduction).""" + + LANG_NAMES = { + "fr": "French", + "es": "Spanish", + "ja": "Japanese", + "pt": "Portuguese (Brazilian)", + } + + def __init__(self, api_key: str) -> None: + self.api_key = api_key + self.session = requests.Session() + self.session.headers.update( + { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + ) + + def translate( + self, + text: str, + target_lang: str, + old_text_en: str = "", + old_text_translated: str = "", + ) -> str: + """ + Traduit `text` (EN) vers `target_lang` via OpenAI. + + Fournit le contexte de l'ancienne traduction pour maintenir la + cohérence du style et de la terminologie. + """ + if not text.strip(): + return text + + lang_name = self.LANG_NAMES.get(target_lang, target_lang) + context_block = "" + if old_text_en and old_text_translated: + context_block = ( + f"\n\nFor reference, the previous English text was:\n" + f"```\n{old_text_en}\n```\n" + f"And its existing {lang_name} translation was:\n" + f"```\n{old_text_translated}\n```\n" + f"Please maintain the same terminology and style." + ) + + system_prompt = ( + f"You are a professional technical documentation translator. " + f"Translate from English to {lang_name}.\n\n" + "Rules:\n" + "- Translate only the prose/text content\n" + "- Preserve all Markdown formatting (**, *, ##, ###, etc.)\n" + "- Preserve all code blocks (``` ... ```) and inline code (`...`) unchanged\n" + "- Preserve all YAML frontmatter (--- ... ---) unchanged\n" + "- Preserve all HTML tags, attributes, and HTML comments unchanged\n" + "- Preserve all URLs in links; only translate visible link text\n" + "- Preserve 4D command names in bold (**CMD**) or inline code (`CMD`) unchanged\n" + "- Output only the translated content, no extra commentary" + ) + user_prompt = ( + f"Translate the following English text to {lang_name}:{context_block}\n\n" + f"```\n{text}\n```" + ) + + payload = { + "model": OPENAI_MODEL, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": 0.2, + } + + for attempt in range(1, MAX_RETRIES + 1): + try: + resp = self.session.post( + "https://api.openai.com/v1/chat/completions", + json=payload, + timeout=120, + ) + if resp.status_code == 429: + raw_retry = resp.headers.get("Retry-After", "") + try: + wait = int(raw_retry) + except (ValueError, TypeError): + # Fallback si la valeur est une date HTTP ou absente + wait = RETRY_DELAY * attempt + log.warning("Rate limit OpenAI. Attente %ds…", wait) + time.sleep(wait) + continue + resp.raise_for_status() + result = resp.json() + translated = result["choices"][0]["message"]["content"].strip() + # Retire les balises de bloc de code éventuellement ajoutées + translated = re.sub(r"^```[^\n]*\n", "", translated) + translated = re.sub(r"\n```$", "", translated) + return translated + except requests.RequestException as exc: + if attempt == MAX_RETRIES: + raise + log.warning("Erreur OpenAI tentative %d/%d : %s", attempt, MAX_RETRIES, exc) + time.sleep(RETRY_DELAY * attempt) + return text # fallback : retourne le texte original + + +# --------------------------------------------------------------------------- +# Utilitaires de mapping des chemins +# --------------------------------------------------------------------------- + + +def map_source_to_i18n(source_path: str, lang: str) -> Optional[str]: + """ + Calcule le chemin i18n cible à partir d'un chemin source EN. + + docs/foo/bar.md + → i18n/{lang}/docusaurus-plugin-content-docs/current/foo/bar.md + + versioned_docs/version-21/foo/bar.md + → i18n/{lang}/docusaurus-plugin-content-docs/version-21/foo/bar.md + """ + base = I18N_BASE.format(lang=lang) + + if source_path.startswith(DOCS_PREFIX): + relative = source_path[len(DOCS_PREFIX):] + return f"{base}/current/{relative}" + + if source_path.startswith(VERSIONED_PREFIX): + # versioned_docs/version-XXX/rest → version-XXX/rest + without_prefix = source_path[len(VERSIONED_PREFIX):] + # without_prefix = "version-XXX/foo/bar.md" + return f"{base}/{without_prefix}" + + return None # fichier non concerné + + +# --------------------------------------------------------------------------- +# Parsing du diff +# --------------------------------------------------------------------------- + + +def parse_diff_chunks(patch: str) -> list[TranslationChange]: + """ + Extrait les blocs de modification (old_text → new_text) depuis un diff unifié. + Retourne une liste de TranslationChange. + """ + if not patch: + return [] + + changes: list[TranslationChange] = [] + old_lines: list[str] = [] + new_lines: list[str] = [] + + def flush(): + nonlocal old_lines, new_lines + if old_lines or new_lines: + changes.append( + TranslationChange( + old_text="\n".join(old_lines), + new_text="\n".join(new_lines), + ) + ) + old_lines = [] + new_lines = [] + + in_hunk = False + for line in patch.splitlines(): + if line.startswith("@@"): + flush() + in_hunk = True + continue + if not in_hunk: + continue + if line.startswith("-"): + old_lines.append(line[1:]) + elif line.startswith("+"): + new_lines.append(line[1:]) + else: + # Ligne de contexte — sépare les chunks + if old_lines or new_lines: + flush() + + flush() + return [c for c in changes if c.old_text.strip() or c.new_text.strip()] + + +# --------------------------------------------------------------------------- +# Application des modifications dans le fichier traduit +# --------------------------------------------------------------------------- + + +def apply_translation( + translated_file: str, + old_text: str, + new_translated: str, +) -> str: + """ + Remplace `old_text` (texte original traduit) par `new_translated` + dans `translated_file`. + + Stratégie : recherche exacte, puis recherche normalisée (espaces/sauts de ligne). + """ + if not old_text.strip(): + # Ajout pur : on ajoute à la fin du fichier + return translated_file.rstrip("\n") + "\n" + new_translated + "\n" + + # 1. Recherche exacte + if old_text in translated_file: + return translated_file.replace(old_text, new_translated, 1) + + # 2. Normalisation légère : collapse des espaces multiples et retours de ligne + def normalize(s: str) -> str: + return re.sub(r"\s+", " ", s).strip() + + norm_old = normalize(old_text) + # Tentative de remplacement ligne à ligne approximatif + lines = translated_file.splitlines(keepends=True) + old_search_lines = [ln.strip() for ln in old_text.splitlines() if ln.strip()] + + if not old_search_lines: + return translated_file + + # Cherche le bloc complet dans le fichier en comparant la version normalisée + for i, line in enumerate(lines): + end = i + len(old_search_lines) + candidate_lines = lines[i:end] + if len(candidate_lines) < len(old_search_lines): + continue + candidate = "".join(candidate_lines) + if normalize(candidate) == norm_old: + before = "".join(lines[:i]) + after = "".join(lines[end:]) + return before + new_translated + "\n" + after + + # 3. En dernier recours : on logue un avertissement et on retourne le fichier intact + log.warning( + "Impossible de localiser le passage à remplacer dans le fichier traduit.\n" + " Recherché : %s…", + old_text[:120].replace("\n", "↵"), + ) + return translated_file + + +# --------------------------------------------------------------------------- +# Logique principale de synchronisation +# --------------------------------------------------------------------------- + + +class I18nSyncer: + """Orchestre la synchronisation i18n à partir d'un commit.""" + + def __init__( + self, + github: GitHubClient, + translator: TranslationClient, + dry_run: bool = False, + langs: list[str] | None = None, + ) -> None: + self.gh = github + self.tr = translator + self.dry_run = dry_run + self.langs = langs if langs is not None else list(TARGET_LANGS) + + def sync(self, sha: str, branch: str) -> SyncReport: + report = SyncReport(commit_sha=sha) + + # 1. Récupération du commit + log.info("Récupération du commit %s…", sha) + commit_data = self.gh.get_commit(sha) + if not commit_data: + raise ValueError(f"Commit introuvable : {sha}") + + commit_message = commit_data.get("commit", {}).get("message", sha[:8]) + tree_sha = commit_data["commit"]["tree"]["sha"] + files = commit_data.get("files", []) + + # 2. Filtrage des fichiers .md pertinents + md_files = [ + FilePatch( + filename=f["filename"], + status=f["status"], + patch=f.get("patch", ""), + raw_url=f.get("raw_url", ""), + ) + for f in files + if f["filename"].endswith(".md") + and ( + f["filename"].startswith(DOCS_PREFIX) + or f["filename"].startswith(VERSIONED_PREFIX) + ) + and f["status"] in ("modified", "added") + ] + + if not md_files: + log.info("Aucun fichier .md pertinent dans ce commit.") + report.print_summary() + return report + + log.info("%d fichier(s) .md à traiter.", len(md_files)) + report.files_processed = len(md_files) + + # 3. Pour chaque fichier, chaque langue + all_updates: list[FileUpdate] = [] + + for fp in md_files: + log.info("→ %s [%s]", fp.filename, fp.status) + + # Récupère le contenu EN après le commit + en_file_data = self.gh.get_file(fp.filename, ref=sha) + en_content = self.gh.decode_file_content(en_file_data) + if not en_content and fp.raw_url: + en_content = self.gh.get_raw_content(fp.raw_url) + + if not en_content: + log.warning(" Contenu EN introuvable, fichier ignoré.") + report.errors.append(f"Contenu EN manquant : {fp.filename}") + continue + + # Parse les chunks de diff + diff_chunks = parse_diff_chunks(fp.patch) + + for lang in self.langs: + i18n_path = map_source_to_i18n(fp.filename, lang) + if not i18n_path: + continue + + log.info(" [%s] %s", lang, i18n_path) + + # Récupère le fichier traduit existant + existing_data = self.gh.get_file(i18n_path) + existing_content = self.gh.decode_file_content(existing_data) + existing_sha = existing_data.get("sha") if existing_data else None + + if not existing_content or fp.status == "added": + # Fichier inexistant ou nouveau → traduction complète + log.info(" Traduction complète du fichier…") + try: + translated_full = self.tr.translate(en_content, lang) + all_updates.append( + FileUpdate( + path=i18n_path, + content=translated_full, + sha=existing_sha, + ) + ) + report.files_created += 1 + report.translations_applied += 1 + except Exception as exc: + msg = f"Erreur traduction complète {i18n_path}: {exc}" + log.error(" %s", msg) + report.errors.append(msg) + continue + + # Fichier existant : appliquer les chunks de diff + updated_content = existing_content + changed = False + + for chunk in diff_chunks: + if not chunk.new_text.strip(): + continue + + # Traduit old_text en langue cible pour le localiser dans le fichier + try: + old_translated = "" + if chunk.old_text.strip(): + old_translated = self.tr.translate( + chunk.old_text, + lang, + old_text_en=chunk.old_text, + old_text_translated="", + ) + + new_translated = self.tr.translate( + chunk.new_text, + lang, + old_text_en=chunk.old_text, + old_text_translated=old_translated, + ) + except Exception as exc: + msg = f"Erreur traduction chunk {i18n_path}: {exc}" + log.error(" %s", msg) + report.errors.append(msg) + continue + + before = updated_content + # Essaie d'abord avec old_translated exact, sinon avec old_text anglais + updated_content = apply_translation( + updated_content, old_translated, new_translated + ) + if updated_content == before and chunk.old_text.strip(): + updated_content = apply_translation( + updated_content, chunk.old_text, new_translated + ) + if updated_content != before: + changed = True + report.translations_applied += 1 + log.info(" ✓ chunk appliqué") + else: + log.warning(" ⚠ chunk non appliqué (passage introuvable)") + + if changed: + all_updates.append( + FileUpdate( + path=i18n_path, + content=updated_content, + sha=existing_sha, + ) + ) + report.files_updated += 1 + + if not all_updates: + log.info("Aucune modification à committer.") + report.print_summary() + return report + + if self.dry_run: + log.info("[DRY-RUN] %d fichier(s) auraient été commités.", len(all_updates)) + for u in all_updates: + log.info(" • %s", u.path) + report.print_summary() + return report + + # 4. Commit groupé via l'API Git + short_msg = commit_message.splitlines()[0] + commit_msg = f"i18n sync: {short_msg} ({sha[:8]})" + log.info("Création du commit : %s", commit_msg) + + # Récupère le SHA HEAD de la branche cible comme parent du nouveau commit + branch_data = self.gh.get_branch(branch) + head_sha = branch_data["commit"]["sha"] + + new_tree_sha = self.gh.create_tree(tree_sha, all_updates) + new_commit_sha = self.gh.create_commit(commit_msg, new_tree_sha, head_sha) + self.gh.update_ref(branch, new_commit_sha) + + log.info("✅ Commit créé : %s", new_commit_sha) + + # 5. Rapport + report.print_summary() + return report + + +# --------------------------------------------------------------------------- +# Point d'entrée +# --------------------------------------------------------------------------- + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Synchronise les traductions i18n à partir d'un commit GitHub.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("sha", help="SHA du commit à synchroniser") + parser.add_argument( + "--repo", + default=os.getenv("GITHUB_REPO", "doc4d/docs"), + help="Dépôt GitHub au format owner/repo (défaut : doc4d/docs)", + ) + parser.add_argument( + "--branch", + default=os.getenv("GITHUB_BRANCH", "main"), + help="Branche cible pour le commit i18n (défaut : main)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Simule l'exécution sans créer de commit", + ) + parser.add_argument( + "--langs", + nargs="+", + default=TARGET_LANGS, + help=f"Langues cibles (défaut : {' '.join(TARGET_LANGS)})", + ) + parser.add_argument( + "--env", + default=os.path.join(os.path.dirname(__file__), ".env"), + help="Chemin vers le fichier .env", + ) + return parser + + +def main() -> int: + parser = build_arg_parser() + args = parser.parse_args() + + # Chargement des variables d'environnement + if os.path.isfile(args.env): + load_dotenv(args.env) + else: + load_dotenv() # Cherche un .env dans le répertoire courant + + github_token = os.getenv("GITHUB_TOKEN") + openai_key = os.getenv("OPENAI_API_KEY") + + if not github_token: + log.error("Variable d'environnement GITHUB_TOKEN manquante.") + return 1 + if not openai_key: + log.error("Variable d'environnement OPENAI_API_KEY manquante.") + return 1 + + gh = GitHubClient(token=github_token, repo=args.repo) + tr = TranslationClient(api_key=openai_key) + syncer = I18nSyncer(github=gh, translator=tr, dry_run=args.dry_run, langs=args.langs) + + try: + report = syncer.sync(sha=args.sha, branch=args.branch) + return 0 if not report.errors else 2 + except Exception as exc: + log.exception("Erreur fatale : %s", exc) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From ee955dec60375d2034503ae84c5948f34123efa2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:42:13 +0000 Subject: [PATCH 3/3] chore: add Python cache to .gitignore and remove pycache from tracking Co-authored-by: arnaud4d <3355051+arnaud4d@users.noreply.github.com> --- .gitignore | 8 +++++--- scripts/__pycache__/sync_i18n.cpython-312.pyc | Bin 30537 -> 0 bytes 2 files changed, 5 insertions(+), 3 deletions(-) delete mode 100644 scripts/__pycache__/sync_i18n.cpython-312.pyc diff --git a/.gitignore b/.gitignore index 3d4ee07c4dd6b5..451e2c8c658314 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,11 @@ .env.test.local .env.production.local -npm-debug.log* -yarn-debug.log* -yarn-error.log* +# Python +__pycache__/ +*.py[cod] +scripts/.env + docsPostProcessed/ commandList.json diff --git a/scripts/__pycache__/sync_i18n.cpython-312.pyc b/scripts/__pycache__/sync_i18n.cpython-312.pyc deleted file mode 100644 index 0c0bc73c80a91201b2ab583d5c7517cf3d3f8e60..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30537 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 geM41>cTDg5#-Xnqx_ILEpZS0