1+ #!/usr/bin/env python3
2+ """
3+ fix_term_refs.py
4+
5+ Converts plain Sphinx `:term:`English`` references inside the *translated*
6+ (msgstr) strings of a gettext .po file into the explicit-title form:
7+
8+ :term:`Persian translation <English>`
9+
10+ so that the reader sees translated text while the link still resolves to the
11+ correct (English) glossary entry ID.
12+
13+ The English msgid lines are left completely untouched. Only msgstr strings
14+ are modified, and only occurrences of :term:`X` where X has no `<...>`
15+ already (i.e. plain references, not ones that already specify a custom title).
16+
17+ Persian translations are looked up in two places, in priority order:
18+ 1. This .po file's own entries — i.e. if somewhere in the file there's a
19+ msgid "duck-typing" / msgstr "نوعدهی اردکی", that translation is used.
20+ 2. A fallback dictionary (FALLBACK_MAP below) for terms that aren't
21+ separately defined in the .po file itself. Edit/extend this dict for
22+ your own glossary as needed.
23+
24+ If a term can't be resolved through either source, the reference is left
25+ unchanged and reported at the end so you can add it to FALLBACK_MAP or fix
26+ the source file.
27+
28+ Usage:
29+ python3 fix_term_refs.py input.po output.po
30+
31+ If output.po is omitted, writes to input.fixed.po next to the input file.
32+ """
33+
34+ import re
35+ import sys
36+
37+ # ---------------------------------------------------------------------------
38+ # Fallback English -> target-language translations, used only when a term
39+ # isn't itself defined as a msgid/msgstr pair inside the .po file.
40+ # Extend this for your own glossary/language as needed.
41+ # ---------------------------------------------------------------------------
42+ FALLBACK_MAP = {
43+ "accessibility" : "دسترسیپذیری" , "await" : "await" , "argument" : "آرگومان" ,
44+ "async" : "ناهمگام، غیرهمگام" , "API" : "API" , "attribute" : "ویژگی، صفت، شاخصه" ,
45+ "boolean" : "بولی" , "built-in" : "توکار، درونی، درونساخته" ,
46+ "callback" : "کالبک، فراخوانی بازگشتی" , "character" : "نویسه" ,
47+ "context management" : "مدیریت زمینه" , "class" : "کلاس" , "cache" : "نهانگاه" ,
48+ "coroutine" : "همروال" , "command line" : "خط فرمان" , "community" : "کامیونیتی" ,
49+ "component" : "کامپوننت" , "custom" : "سفارشی، اختصاصی" ,
50+ "decorator" : "دکوراتور، آراینده" , "debugging" : "اشکالزدایی، دیباگ کردن" ,
51+ "decoding" : "کدگشایی" , "deprecated" : "منسوخ، از رده خارج شده" ,
52+ "dependency" : "وابستگی" , "dictionary" : "دیکشنری" , "directory" : "پوشه" ,
53+ "duck-typing" : "نوعدهی اردکی" , "DOM" : "DOM" , "element" : "المان، عنصر" ,
54+ "endpoint" : "پایانه" , "escape" : "خنثی کردن" , "encoding" : "کدگذاری" ,
55+ "ecosystem" : "اکوسیستم" , "event" : "رویداد" , "exception" : "استثنا" ,
56+ "expression" : "عبارت" , "function" : "تابع" , "f-string" : "افاسترینگ" ,
57+ "generator" : "تولیدگر" , "global" : "سراسری" , "garbage collection" : "زبالهروبی" ,
58+ "generic function" : "تابع عام، تابع عمومی" , "hexadecimal" : "مبنای شانزده" ,
59+ "immortal" : "نامیرا" , "import" : "ایمپورت" , "immutable" : "تغییرناپذیر" ,
60+ "index" : "اندیس، شماره" , "instance" : "نمونه" , "integer" : "عدد صحیح" ,
61+ "interface" : "رابط" , "interpreter" : "مفسر" , "item" : "آیتم" ,
62+ "iterable" : "تکرارپذیر" , "keyword" : "کلیدواژه" ,
63+ "keyword argument" : "آرگومان کلیدواژهای" , "list" : "فهرست" ,
64+ "list comprehension" : "درک فهرستی" , "load" : "بارگذاری" , "loader" : "بارگذار" ,
65+ "local" : "محلی" , "loop" : "حلقه" , "method" : "متد" , "metaclass" : "فراکلاس" ,
66+ "mock" : "ماک" , "module" : "ماژول" , "mutable" : "تغییرپذیر" ,
67+ "namespace" : "نامفضا" , "object" : "شیء" , "operator" : "عملگر" ,
68+ "package" : "بسته" , "parameter" : "پارامتر" , "positional" : "جایگاهی" ,
69+ "property" : "ویژگی، پراپرتی، خصوصیت" , "parallelism" : "موازیسازی" ,
70+ "quotation" : "علامت نقلقول" , "raise" : "پرتاب" , "return" : "بازگشت، برگرداندن" ,
71+ "runtime" : "رانتایم" , "race" : "رقابت" , "scope" : "محدوده" ,
72+ "shadowing" : "پوشاندن" , "stack traceback" : "ردگیری پشته" , "statement" : "دستور" ,
73+ "string" : "رشته" , "syntax" : "سینتکس، نحو" , "shell" : "پوسته" ,
74+ "syntactic sugar" : "قند نحوی" , "tracking" : "پیگیری" ,
75+ "type" : "نوع، نوع داده، تایپ" , "thread" : "نخ" , "unit test" : "یونیت تست" ,
76+ "unpacking" : "واگشایی" , "value" : "مقدار" , "variable" : "متغیر" ,
77+ "wrapper" : "پوششی، دربرگیرنده" , "iterator" : "تکرارگر" ,
78+ }
79+
80+ # Matches BOTH forms so both get (re)normalized to :term:`Persian <target>`:
81+ # :term:`X` -- plain ref, target/display are both X
82+ # :term:`Something <X>` -- already has explicit title (target is X);
83+ # "Something" may be stale English display
84+ # text left over from the source file.
85+ # Group 'target' is always the real glossary-entry id to link to.
86+ TERM_RE = re .compile (r':term:`(?:(?P<target_only>[^`<>]+)|[^`<>]*<(?P<target_bracketed>[^`<>]+)>)`' )
87+
88+
89+ def po_unescape (s ):
90+ return s .replace ('\\ n' , '\n ' ).replace ('\\ "' , '"' ).replace ('\\ \\ ' , '\\ ' )
91+
92+
93+ def po_escape (s ):
94+ return s .replace ('\\ ' , '\\ \\ ' ).replace ('"' , '\\ "' ).replace ('\n ' , '\\ n' )
95+
96+
97+ def parse_po_string_block (lines , i , n ):
98+ """Parse a 'msgid "..."' or 'msgstr "..."' plus any continuation quoted
99+ lines. Returns (keyword, parts, next_index)."""
100+ m = re .match (r'^(msgid|msgstr)\s+"(.*)"\s*$' , lines [i ])
101+ keyword = m .group (1 )
102+ parts = [m .group (2 )]
103+ j = i + 1
104+ while j < n and re .match (r'^\s*"(.*)"\s*$' , lines [j ]):
105+ parts .append (re .match (r'^\s*"(.*)"\s*$' , lines [j ]).group (1 ))
106+ j += 1
107+ return keyword , parts , j
108+
109+
110+ def parse_po (text ):
111+ """Parse .po text into a list of entries: pairs (msgid/msgstr) or other
112+ raw lines (comments, headers, blanks), preserving order."""
113+ lines = text .split ("\n " )
114+ n = len (lines )
115+ entries = []
116+ i = 0
117+ while i < n :
118+ if re .match (r'^msgid\s+"' , lines [i ]):
119+ _ , id_parts , j1 = parse_po_string_block (lines , i , n )
120+ if j1 < n and re .match (r'^msgstr\s+"' , lines [j1 ]):
121+ _ , str_parts , j2 = parse_po_string_block (lines , j1 , n )
122+ entries .append ({"type" : "pair" , "msgid_parts" : id_parts ,
123+ "msgstr_parts" : str_parts })
124+ i = j2
125+ else :
126+ entries .append ({"type" : "other" , "raw" : [lines [i ]]})
127+ i = i + 1 # fall back to line-by-line if malformed
128+ else :
129+ entries .append ({"type" : "other" , "raw" : [lines [i ]]})
130+ i += 1
131+ return entries
132+
133+
134+ def join_parts_decoded (parts ):
135+ return "" .join (po_unescape (p ) for p in parts )
136+
137+
138+ def build_term_map (entries ):
139+ """Build English-term -> translation dict from the .po file's own short,
140+ markup-free msgid/msgstr pairs (these are glossary entry definitions)."""
141+ term_map = {}
142+ for e in entries :
143+ if e ["type" ] != "pair" :
144+ continue
145+ msgid_full = join_parts_decoded (e ["msgid_parts" ]).strip ()
146+ msgstr_full = join_parts_decoded (e ["msgstr_parts" ]).strip ()
147+ if not msgid_full or not msgstr_full :
148+ continue
149+ if re .search (r'[`:]' , msgid_full ) or len (msgid_full ) > 60 :
150+ continue
151+ term_map [msgid_full ] = msgstr_full
152+ return term_map
153+
154+
155+ def fix_po (text ):
156+ """Run the conversion. Returns (new_text, changed_count, missing_terms)."""
157+ entries = parse_po (text )
158+ term_map = build_term_map (entries )
159+ missing = set ()
160+
161+ def translate (term ):
162+ if term in term_map :
163+ return term_map [term ]
164+ if term in FALLBACK_MAP :
165+ return FALLBACK_MAP [term ]
166+ return None
167+
168+ def replace_in_text (s ):
169+ def repl (m ):
170+ term = m .group ('target_only' ) or m .group ('target_bracketed' )
171+ translated = translate (term )
172+ if translated is None :
173+ missing .add (term )
174+ return m .group (0 ) # leave completely unchanged
175+ return f':term:`{ translated } <{ term } >`'
176+ return TERM_RE .sub (repl , s )
177+
178+ changed_count = 0
179+ for e in entries :
180+ if e ["type" ] != "pair" :
181+ continue
182+ msgstr_full = join_parts_decoded (e ["msgstr_parts" ])
183+ if ':term:`' not in msgstr_full :
184+ continue
185+ new_full = replace_in_text (msgstr_full )
186+ if new_full == msgstr_full :
187+ continue
188+ changed_count += 1
189+ if len (e ["msgstr_parts" ]) > 1 :
190+ segs = new_full .split ("\n " )
191+ new_parts = ["" ]
192+ for k , seg in enumerate (segs ):
193+ suffix = "\\ n" if k < len (segs ) - 1 else ""
194+ new_parts .append (po_escape (seg ) + suffix )
195+ e ["msgstr_parts" ] = new_parts
196+ else :
197+ e ["msgstr_parts" ] = [po_escape (new_full )]
198+
199+ out_lines = []
200+ for e in entries :
201+ if e ["type" ] == "other" :
202+ out_lines .extend (e ["raw" ])
203+ else :
204+ out_lines .append (f'msgid "{ e ["msgid_parts" ][0 ]} "' )
205+ for p in e ["msgid_parts" ][1 :]:
206+ out_lines .append (f'"{ p } "' )
207+ out_lines .append (f'msgstr "{ e ["msgstr_parts" ][0 ]} "' )
208+ for p in e ["msgstr_parts" ][1 :]:
209+ out_lines .append (f'"{ p } "' )
210+
211+ return "\n " .join (out_lines ), changed_count , missing
212+
213+
214+ def main ():
215+ if len (sys .argv ) < 2 :
216+ print ("Usage: python3 fix_term_refs.py input.po [output.po]" )
217+ sys .exit (1 )
218+
219+ in_path = sys .argv [1 ]
220+ out_path = sys .argv [2 ] if len (sys .argv ) > 2 else re .sub (
221+ r'\.po$' , '.fixed.po' , in_path )
222+
223+ with open (in_path , encoding = "utf-8" ) as f :
224+ text = f .read ()
225+
226+ new_text , changed_count , missing = fix_po (text )
227+
228+ with open (out_path , "w" , encoding = "utf-8" ) as f :
229+ f .write (new_text )
230+
231+ print (f"Wrote: { out_path } " )
232+ print (f"Changed msgstr entries: { changed_count } " )
233+ if missing :
234+ print (f"Terms left unchanged (no translation found, { len (missing )} ):" )
235+ for t in sorted (missing ):
236+ print (f" - { t } " )
237+ else :
238+ print ("All :term: references resolved." )
239+
240+
241+ if __name__ == "__main__" :
242+ main ()
0 commit comments