Skip to content

Commit 51c5c5a

Browse files
committed
Add translation status script
1 parent a2c7a3b commit 51c5c5a

1 file changed

Lines changed: 170 additions & 0 deletions

File tree

scripts/translation_status.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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

Comments
 (0)