1+ #!/usr/bin/env python3
2+ """
3+ scripts/translation_status.py
4+
5+ Report translation progress across .po files: how many strings are
6+ translated, fuzzy, or untranslated per file, plus overall totals. Meant to
7+ help a contributor quickly find files that need work.
8+
9+ Usage:
10+ python3 scripts/translation_status.py # whole repo, least-translated first
11+ python3 scripts/translation_status.py tutorial/ # just one directory
12+ python3 scripts/translation_status.py --only-incomplete # hide fully-done files
13+ python3 scripts/translation_status.py --sort name
14+ python3 scripts/translation_status.py --format markdown > STATUS.md
15+ """
16+ import argparse
17+ import sys
18+ from pathlib import Path
19+
20+
21+ def unescape (raw : str ) -> str :
22+ """Undo PO string escaping (raw includes the surrounding quotes)."""
23+ inner = raw [1 :- 1 ]
24+ out = []
25+ i = 0
26+ while i < len (inner ):
27+ c = inner [i ]
28+ if c == "\\ " and i + 1 < len (inner ):
29+ nxt = inner [i + 1 ]
30+ out .append ({"n" : "\n " , "t" : "\t " , '"' : '"' , "\\ " : "\\ " }.get (nxt , nxt ))
31+ i += 2
32+ else :
33+ out .append (c )
34+ i += 1
35+ return "" .join (out )
36+
37+
38+ def parse_po_entries (path : Path ):
39+ """Yield (flags, msgid, msgstrs) for each entry in a .po file."""
40+ lines = path .read_text (encoding = "utf-8" ).splitlines ()
41+ i , n = 0 , len (lines )
42+
43+ def read_block (keyword_line ):
44+ nonlocal i
45+ parts = [unescape (keyword_line .split (" " , 1 )[1 ].strip ())]
46+ i += 1
47+ while i < n and lines [i ].strip ().startswith ('"' ):
48+ parts .append (unescape (lines [i ].strip ()))
49+ i += 1
50+ return "" .join (parts )
51+
52+ while i < n :
53+ flags = []
54+ while i < n and lines [i ].startswith ("#" ):
55+ if lines [i ].startswith ("#," ):
56+ flags = [f .strip () for f in lines [i ][2 :].split ("," )]
57+ i += 1
58+ if i >= n or not lines [i ].startswith ("msgid" ):
59+ i += 1
60+ continue
61+
62+ msgid = read_block (lines [i ])
63+ if i < n and lines [i ].startswith ("msgid_plural" ):
64+ read_block (lines [i ]) # plural source not needed for counting
65+
66+ msgstrs = []
67+ if i < n and lines [i ].startswith ("msgstr[" ):
68+ while i < n and lines [i ].startswith ("msgstr[" ):
69+ msgstrs .append (read_block (lines [i ]))
70+ elif i < n and lines [i ].startswith ("msgstr" ):
71+ msgstrs .append (read_block (lines [i ]))
72+
73+ yield flags , msgid , msgstrs
74+
75+
76+ def file_stats (path : Path ):
77+ translated = fuzzy = untranslated = 0
78+ for flags , msgid , msgstrs in parse_po_entries (path ):
79+ if not msgid :
80+ continue # header entry
81+ if "fuzzy" in flags :
82+ fuzzy += 1
83+ elif any (m .strip () for m in msgstrs ):
84+ translated += 1
85+ else :
86+ untranslated += 1
87+ return translated , fuzzy , untranslated
88+
89+
90+ def collect_files (paths ):
91+ files = []
92+ for arg in paths :
93+ p = Path (arg )
94+ if p .is_dir ():
95+ files .extend (sorted (p .rglob ("*.po" )))
96+ elif p .suffix == ".po" :
97+ files .append (p )
98+ return files
99+
100+
101+ def main ():
102+ parser = argparse .ArgumentParser (
103+ description = __doc__ , formatter_class = argparse .RawDescriptionHelpFormatter
104+ )
105+ parser .add_argument ("paths" , nargs = "*" , default = ["." ],
106+ help = "Files or directories to scan (default: whole repo)" )
107+ parser .add_argument ("--sort" , choices = ["percent" , "untranslated" , "name" ], default = "percent" ,
108+ help = "Sort order (default: percent, least-translated first)" )
109+ parser .add_argument ("--only-incomplete" , action = "store_true" ,
110+ help = "Hide files that are already fully translated" )
111+ parser .add_argument ("--format" , choices = ["text" , "markdown" , "csv" ], default = "text" )
112+ args = parser .parse_args ()
113+
114+ files = collect_files (args .paths )
115+ if not files :
116+ print ("No .po files found." )
117+ sys .exit (1 )
118+
119+ rows = []
120+ for f in files :
121+ t , fz , u = file_stats (f )
122+ total = t + fz + u
123+ percent = (t / total * 100 ) if total else 100.0
124+ rows .append ((str (f ), t , fz , u , total , percent ))
125+
126+ if args .only_incomplete :
127+ rows = [r for r in rows if r [2 ] > 0 or r [3 ] > 0 ]
128+
129+ if args .sort == "percent" :
130+ rows .sort (key = lambda r : r [5 ])
131+ elif args .sort == "untranslated" :
132+ rows .sort (key = lambda r : - r [3 ])
133+ else :
134+ rows .sort (key = lambda r : r [0 ])
135+
136+ total_t = sum (r [1 ] for r in rows )
137+ total_fz = sum (r [2 ] for r in rows )
138+ total_u = sum (r [3 ] for r in rows )
139+ total_all = total_t + total_fz + total_u
140+ total_percent = (total_t / total_all * 100 ) if total_all else 100.0
141+
142+ if args .format == "csv" :
143+ print ("file,translated,fuzzy,untranslated,total,percent" )
144+ for path , t , fz , u , total , percent in rows :
145+ print (f"{ path } ,{ t } ,{ fz } ,{ u } ,{ total } ,{ percent :.1f} " )
146+ print (f"TOTAL,{ total_t } ,{ total_fz } ,{ total_u } ,{ total_all } ,{ total_percent :.1f} " )
147+ return
148+
149+ if args .format == "markdown" :
150+ print ("| File | Translated | Fuzzy | Untranslated | % done |" )
151+ print ("|---|---:|---:|---:|---:|" )
152+ for path , t , fz , u , total , percent in rows :
153+ print (f"| `{ path } ` | { t } | { fz } | { u } | { percent :.1f} % |" )
154+ print (f"| **TOTAL** | **{ total_t } ** | **{ total_fz } ** | **{ total_u } ** | **{ total_percent :.1f} %** |" )
155+ return
156+
157+ name_width = max ((len (r [0 ]) for r in rows ), default = 4 )
158+ header = f"{ 'File' :<{name_width }} { 'Translated' :>10} { 'Fuzzy' :>6} { 'Untranslated' :>12} { '% done' :>7} "
159+ print (header )
160+ print ("-" * len (header ))
161+ for path , t , fz , u , total , percent in rows :
162+ print (f"{ path :<{name_width }} { t :>10} { fz :>6} { u :>12} { percent :>6.1f} %" )
163+ print ("-" * len (header ))
164+ print (f"{ 'TOTAL' :<{name_width }} { total_t :>10} { total_fz :>6} { total_u :>12} { total_percent :>6.1f} %" )
165+ print (f"\n { len (rows )} file(s) shown. "
166+ f"{ sum (1 for r in rows if r [5 ] < 100 )} file(s) not fully translated." )
167+
168+
169+ if __name__ == "__main__" :
170+ main ()
0 commit comments