Forge
pythondeeb25a2
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3"""Translate docs/adr/*.md to docs/en/adr/*.md (Russian -> English).
4
5Uses deep-translator (Google). Run: pip install deep-translator && python tools/translate_adrs_to_en.py
6Options: --only 0021,0100 | --from 50 | --force | --incomplete-only | --dry-run
7"""
8
9from __future__ import annotations
10
11import argparse
12import re
13import time
14from pathlib import Path
15
16ROOT = Path(__file__).resolve().parents[1]
17SRC = ROOT / "docs" / "adr"
18DST = ROOT / "docs" / "en" / "adr"
19
20META_FILES = ("README.md", "status-lifecycle.md")
21SKIP = set() # numbered ADRs only in main loop; META via --meta
22
23# Do not translate: code paths, ADR ids in links, HTML anchors, fenced blocks
24FENCE_RE = re.compile(r"^(```+|~~~+)")
25
26LINK_RE = re.compile(r"(\[.*?\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*)")
27CYRILLIC_RE = re.compile(r"[А-Яа-яЁё]")
28MAX_RETRIES = 4
29RETRY_SLEEP = (1.0, 2.5, 5.0, 8.0)
30
31
32def rewrite_links(text: str) -> str:
33 """Adjust relative paths from docs/adr/ to docs/en/adr/."""
34 text = text.replace("](../ui-ux/", "](../ui-ux/")
35 text = text.replace("](ui-ux/", "](../ui-ux/")
36 text = text.replace("](../design/", "](../../design/")
37 text = text.replace("](../architecture/", "](../../architecture/")
38 text = text.replace("](../architecture-policy.md", "](../../architecture-policy.md")
39 text = text.replace("](../MCP-PROTOCOL.md", "](../../MCP-PROTOCOL.md")
40 text = text.replace("](../LANGUAGE-SERVICES-PLAN.md", "](../../LANGUAGE-SERVICES-PLAN.md")
41 text = text.replace("](../intent-melody-language-v1.md", "](../../intent-melody-language-v1.md")
42 text = text.replace("](../git-and-submodules-v1.md", "](../../git-and-submodules-v1.md")
43 text = text.replace("](../COMMERCIAL-NOTICE.md", "](../../COMMERCIAL-NOTICE.md")
44 text = text.replace("](../en/", "](../") # fix double en
45 # Header status line
46 text = re.sub(
47 r"^\*\*Статус:\*\*",
48 "**Status:**",
49 text,
50 count=1,
51 flags=re.MULTILINE,
52 )
53 text = re.sub(
54 r"^\*\*Дата:\*\*",
55 "**Date:**",
56 text,
57 count=1,
58 flags=re.MULTILINE,
59 )
60 text = re.sub(
61 r"^\*\*Обновлено:\*\*",
62 "**Updated:**",
63 text,
64 count=1,
65 flags=re.MULTILINE,
66 )
67 # Remove duplicate Summary (EN) if source already had one from partial work
68 return text
69
70
71def split_translate_blocks(text: str, translate_fn) -> str:
72 """Translate prose; keep fenced code blocks unchanged."""
73 lines = text.splitlines(keepends=True)
74 out: list[str] = []
75 buf: list[str] = []
76 in_fence = False
77 fence_mark = ""
78
79 def flush():
80 nonlocal buf
81 if not buf:
82 return
83 chunk = "".join(buf)
84 if chunk.strip():
85 try:
86 out.append(translate_fn(chunk))
87 except Exception as e:
88 print(f" WARN translate chunk failed: {e}")
89 out.append(chunk)
90 else:
91 out.append(chunk)
92 buf = []
93
94 for line in lines:
95 m = FENCE_RE.match(line.strip())
96 if m:
97 flush()
98 mark = m.group(1)
99 if not in_fence:
100 in_fence = True
101 fence_mark = mark
102 out.append(line)
103 elif line.strip().startswith(fence_mark):
104 in_fence = False
105 fence_mark = ""
106 out.append(line)
107 else:
108 out.append(line)
109 continue
110 if in_fence:
111 out.append(line)
112 else:
113 buf.append(line)
114 flush()
115 return "".join(out)
116
117
118def translate_file(src: Path, dst: Path, translator, *, force: bool) -> bool:
119 if dst.exists() and not force:
120 return False
121 raw = src.read_text(encoding="utf-8")
122 # Skip if already has English-only header and no Cyrillic (re-run safe)
123 if dst.exists() and not force:
124 existing = dst.read_text(encoding="utf-8")
125 if "**Status:**" in existing[:500] and not re.search(r"[А-Яа-яЁё]", existing[:2000]):
126 return False
127
128 def translate_piece(piece: str) -> str:
129 last_err: Exception | None = None
130 for attempt, delay in enumerate(RETRY_SLEEP):
131 try:
132 return translator.translate(piece)
133 except Exception as e:
134 last_err = e
135 print(f" WARN translate retry {attempt + 1}/{MAX_RETRIES}: {e}", flush=True)
136 time.sleep(delay)
137 if last_err:
138 raise last_err
139 return piece
140
141 def translate_fn(chunk: str) -> str:
142 chunk = chunk.strip("\n")
143 if not chunk.strip():
144 return chunk
145 if not CYRILLIC_RE.search(chunk):
146 return chunk
147 # Google limit ~5000 chars
148 parts: list[str] = []
149 remaining = chunk
150 while remaining:
151 piece = remaining[:4500]
152 if len(remaining) > 4500:
153 cut = piece.rfind("\n\n")
154 if cut < 1000:
155 cut = piece.rfind("\n")
156 if cut < 1000:
157 cut = 4500
158 piece = remaining[:cut]
159 parts.append(translate_piece(piece))
160 remaining = remaining[len(piece) :]
161 if remaining:
162 time.sleep(0.2)
163 return "\n".join(parts) if len(parts) > 1 else parts[0]
164
165 translated = split_translate_blocks(raw, translate_fn)
166 translated = rewrite_links(translated)
167 # Drop redundant Summary (EN) duplicate section if machine translated doubled
168 translated = re.sub(
169 r"<a id=\"summary-en\"></a>\s*\n## Summary \(EN\)\s*\n.*?\n(?=## (?!Summary))",
170 "",
171 translated,
172 count=1,
173 flags=re.DOTALL,
174 )
175 dst.parent.mkdir(parents=True, exist_ok=True)
176 note = (
177 "<!-- English translation of "
178 + src.as_posix().split("docs/", 1)[-1]
179 + ". Canonical Russian: ../../adr/"
180 + src.name
181 + " -->\n\n"
182 )
183 dst.write_text(note + translated, encoding="utf-8")
184 return True
185
186
187def main() -> None:
188 parser = argparse.ArgumentParser()
189 parser.add_argument("--only", nargs="*", help="ADR numbers or filename stems")
190 parser.add_argument("--from", dest="from_num", type=int, default=0, help="Min ADR number")
191 parser.add_argument("--force", action="store_true")
192 parser.add_argument(
193 "--incomplete-only",
194 action="store_true",
195 help="Re-translate only EN files that still contain Cyrillic",
196 )
197 parser.add_argument("--dry-run", action="store_true")
198 parser.add_argument("--meta", action="store_true", help="Translate README.md and status-lifecycle.md")
199 args = parser.parse_args()
200
201 try:
202 from deep_translator import GoogleTranslator
203 except ImportError:
204 raise SystemExit("Install: pip install deep-translator")
205
206 translator = GoogleTranslator(source="ru", target="en")
207 done = 0
208 if args.meta:
209 for name in META_FILES:
210 path = SRC / name
211 if not path.is_file():
212 continue
213 dst = DST / name
214 if args.dry_run:
215 print(f"would translate {name}")
216 continue
217 print(f"translate {name}...", flush=True)
218 if translate_file(path, dst, translator, force=args.force):
219 done += 1
220 time.sleep(0.5)
221
222 files = sorted(SRC.glob("*.md"))
223 for path in files:
224 if path.name in META_FILES:
225 continue
226 m = re.match(r"^(\d+)-", path.name)
227 if not m:
228 continue
229 num = int(m.group(1))
230 if num < args.from_num:
231 continue
232 if args.only:
233 stems = {o.replace(".md", "") for o in args.only}
234 if path.stem not in stems and str(num) not in args.only:
235 continue
236 dst = DST / path.name
237 if args.incomplete_only:
238 if not dst.is_file() or not CYRILLIC_RE.search(dst.read_text(encoding="utf-8")):
239 continue
240 if args.dry_run:
241 print(f"would translate {path.name}")
242 continue
243 print(f"translate {path.name}...", flush=True)
244 force = args.force or args.incomplete_only
245 if translate_file(path, dst, translator, force=force):
246 done += 1
247 time.sleep(0.35)
248 print(f"OK: {done} files written to {DST.relative_to(ROOT)}")
249
250
251if __name__ == "__main__":
252 main()
253
View only · write via MCP/CIDE