ddtp-translate 0.14.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU General Public License is a free, copyleft license for
11
+ software and other kinds of works.
12
+
13
+ The licenses for most software and other practical works are designed
14
+ to take away your freedom to share and change the works. By contrast,
15
+ the GNU General Public License is intended to guarantee your freedom to
16
+ share and change all versions of a program--to make sure it remains free
17
+ software for all its users. We, the Free Software Foundation, use the
18
+ GNU General Public License for most of our software; it applies also to
19
+ any other work released this way by its authors. You can apply it to
20
+ your programs, too.
21
+
22
+ For the full license text, see <https://www.gnu.org/licenses/gpl-3.0.txt>
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: ddtp-translate
3
+ Version: 0.14.4
4
+ Summary: Translate Debian package descriptions via DDTP
5
+ Author-email: Daniel Nylander <daniel@danielnylander.se>
6
+ License-Expression: GPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/yeager/ddtp-translate
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Software Development :: Localization
10
+ Classifier: Environment :: X11 Applications :: GTK
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Provides-Extra: translate
15
+ Requires-Dist: po-translate; extra == "translate"
16
+ Dynamic: license-file
17
+
18
+ # DDTP Translate [![Version](https://img.shields.io/badge/version-0.14.4-blue.svg)](https://github.com/yeager/ddtp-translate)
19
+
20
+ ## Screenshot
21
+
22
+ ![DDTP Translate](screenshots/main.png)
23
+
24
+ ## Description
25
+
26
+ DDTP Translate is a GTK4/Adwaita application for translating Debian package descriptions via the DDTP (Debian Description Translation Project). The application provides an intuitive interface for translators to contribute to Debian package description translations, helping make Debian more accessible to users worldwide.
27
+
28
+ The application streamlines the translation workflow by providing easy access to untranslated descriptions, translation memory features, and quality assurance tools for reviewing existing translations.
29
+
30
+ ## Features
31
+
32
+ - Translate Debian package descriptions
33
+ - Browse untranslated and pending descriptions
34
+ - Review and edit existing translations
35
+ - Translation memory and suggestions
36
+ - Quality assurance tools
37
+ - Modern GTK4/Adwaita interface
38
+ - Offline translation capabilities
39
+ - Batch translation support
40
+
41
+ ## Installation
42
+
43
+ ### PyPI / pipx (Debian/Ubuntu)
44
+
45
+ Install GTK4, libadwaita and the system PyGObject bindings first; pip cannot
46
+ install these native system libraries:
47
+
48
+ ```bash
49
+ sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 pipx
50
+ pipx install --system-site-packages ddtp-translate
51
+ ```
52
+
53
+
54
+ ### APT Repository (Debian/Ubuntu)
55
+
56
+ ```bash
57
+ echo "deb https://yeager.github.io/debian-repo stable main" | sudo tee /etc/apt/sources.list.d/yeager-l10n.list
58
+ sudo apt update
59
+ sudo apt install ddtp-translate
60
+ ```
61
+
62
+ ### Building from Source
63
+
64
+ ```bash
65
+ git clone https://github.com/yeager/ddtp-translate.git
66
+ cd ddtp-translate
67
+ pip install -e .
68
+ ```
69
+
70
+ ## Translation
71
+
72
+ Translations are maintained through GitHub pull requests. Copy the template
73
+ catalogue in [`po/ddtp-translate.pot`](po/ddtp-translate.pot) to a new locale
74
+ file such as `po/de.po`, translate it with a gettext editor, then open a PR.
75
+ Run `msgfmt --check po/<locale>.po` before submitting it. See
76
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the full review workflow.
77
+
78
+ ## License
79
+
80
+ GPL-3.0-or-later
81
+
82
+ ## Author
83
+
84
+ Daniel Nylander (daniel@danielnylander.se)
@@ -0,0 +1,67 @@
1
+ # DDTP Translate [![Version](https://img.shields.io/badge/version-0.14.4-blue.svg)](https://github.com/yeager/ddtp-translate)
2
+
3
+ ## Screenshot
4
+
5
+ ![DDTP Translate](screenshots/main.png)
6
+
7
+ ## Description
8
+
9
+ DDTP Translate is a GTK4/Adwaita application for translating Debian package descriptions via the DDTP (Debian Description Translation Project). The application provides an intuitive interface for translators to contribute to Debian package description translations, helping make Debian more accessible to users worldwide.
10
+
11
+ The application streamlines the translation workflow by providing easy access to untranslated descriptions, translation memory features, and quality assurance tools for reviewing existing translations.
12
+
13
+ ## Features
14
+
15
+ - Translate Debian package descriptions
16
+ - Browse untranslated and pending descriptions
17
+ - Review and edit existing translations
18
+ - Translation memory and suggestions
19
+ - Quality assurance tools
20
+ - Modern GTK4/Adwaita interface
21
+ - Offline translation capabilities
22
+ - Batch translation support
23
+
24
+ ## Installation
25
+
26
+ ### PyPI / pipx (Debian/Ubuntu)
27
+
28
+ Install GTK4, libadwaita and the system PyGObject bindings first; pip cannot
29
+ install these native system libraries:
30
+
31
+ ```bash
32
+ sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 pipx
33
+ pipx install --system-site-packages ddtp-translate
34
+ ```
35
+
36
+
37
+ ### APT Repository (Debian/Ubuntu)
38
+
39
+ ```bash
40
+ echo "deb https://yeager.github.io/debian-repo stable main" | sudo tee /etc/apt/sources.list.d/yeager-l10n.list
41
+ sudo apt update
42
+ sudo apt install ddtp-translate
43
+ ```
44
+
45
+ ### Building from Source
46
+
47
+ ```bash
48
+ git clone https://github.com/yeager/ddtp-translate.git
49
+ cd ddtp-translate
50
+ pip install -e .
51
+ ```
52
+
53
+ ## Translation
54
+
55
+ Translations are maintained through GitHub pull requests. Copy the template
56
+ catalogue in [`po/ddtp-translate.pot`](po/ddtp-translate.pot) to a new locale
57
+ file such as `po/de.po`, translate it with a gettext editor, then open a PR.
58
+ Run `msgfmt --check po/<locale>.po` before submitting it. See
59
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the full review workflow.
60
+
61
+ ## License
62
+
63
+ GPL-3.0-or-later
64
+
65
+ ## Author
66
+
67
+ Daniel Nylander (daniel@danielnylander.se)
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ddtp-translate"
7
+ version = "0.14.4"
8
+ description = "Translate Debian package descriptions via DDTP"
9
+ license = "GPL-3.0-or-later"
10
+ authors = [
11
+ {name = "Daniel Nylander", email = "daniel@danielnylander.se"},
12
+ ]
13
+ readme = "README.md"
14
+ requires-python = ">=3.9"
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Software Development :: Localization",
18
+ "Environment :: X11 Applications :: GTK",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ translate = ["po-translate"]
23
+
24
+ [project.scripts]
25
+ ddtp-translate = "ddtp_translate.main:main"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/yeager/ddtp-translate"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """DDTP Translate — Translate Debian package descriptions."""
2
+
3
+ __version__ = "0.14.4"
@@ -0,0 +1,3 @@
1
+ """Allow running with python -m."""
2
+ from .main import main
3
+ main()
@@ -0,0 +1,66 @@
1
+ """Accessibility features: zoom, high contrast, ATK."""
2
+ import gi
3
+ gi.require_version('Gtk', '4.0')
4
+ from gi.repository import Gtk, Gdk, Gio
5
+
6
+
7
+ class AccessibilityManager:
8
+ """Manages accessibility features for a GTK4 window."""
9
+
10
+ def __init__(self, window, app=None):
11
+ self._window = window
12
+ self._app = app or window.get_application()
13
+ self._font_scale = 1.0
14
+ self._high_contrast = False
15
+ self._css = Gtk.CssProvider()
16
+ Gtk.StyleContext.add_provider_for_display(
17
+ Gdk.Display.get_default(), self._css,
18
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + 1
19
+ )
20
+ self._setup_actions()
21
+
22
+ def _setup_actions(self):
23
+ if self._app is None:
24
+ return
25
+ actions = [
26
+ ('zoom-in', self._zoom_in, ['<Control>plus', '<Control>equal']),
27
+ ('zoom-out', self._zoom_out, ['<Control>minus']),
28
+ ('zoom-reset', self._zoom_reset, ['<Control>0']),
29
+ ('toggle-high-contrast', self._toggle_hc, ['<Control><Shift>h']),
30
+ ]
31
+ for name, cb, accels in actions:
32
+ if not self._app.lookup_action(name):
33
+ action = Gio.SimpleAction.new(name, None)
34
+ action.connect('activate', lambda a, p, c=cb: c())
35
+ self._app.add_action(action)
36
+ self._app.set_accels_for_action(f'app.{name}', accels)
37
+
38
+ def _apply_css(self):
39
+ css = f'window {{ font-size: {self._font_scale}em; }}'
40
+ if self._high_contrast:
41
+ css += """
42
+ window.high-contrast {
43
+ border: 2px solid @accent_color;
44
+ font-weight: bold;
45
+ }"""
46
+ self._css.load_from_string(css.encode())
47
+
48
+ def _zoom_in(self):
49
+ self._font_scale = min(self._font_scale + 0.1, 3.0)
50
+ self._apply_css()
51
+
52
+ def _zoom_out(self):
53
+ self._font_scale = max(self._font_scale - 0.1, 0.5)
54
+ self._apply_css()
55
+
56
+ def _zoom_reset(self):
57
+ self._font_scale = 1.0
58
+ self._apply_css()
59
+
60
+ def _toggle_hc(self):
61
+ self._high_contrast = not self._high_contrast
62
+ if self._high_contrast:
63
+ self._window.add_css_class('high-contrast')
64
+ else:
65
+ self._window.remove_css_class('high-contrast')
66
+ self._apply_css()
@@ -0,0 +1,356 @@
1
+ """DDTP HTTP API client — fetch untranslated Debian package descriptions."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import time
8
+ import urllib.request
9
+ from pathlib import Path
10
+
11
+ DDTP_BASE = "https://ddtp.debian.org/ddt.cgi"
12
+ # Fallback: fetch from Debian mirror i18n Translation files
13
+ MIRROR_BASE = "https://deb.debian.org/debian"
14
+ DISTS = ["sid", "trixie", "bookworm"]
15
+ CACHE_TTL = 86400 # 24 hours
16
+
17
+ # All DDTP-supported language codes
18
+ DDTP_LANGUAGES = [
19
+ ("ar", "Arabic"),
20
+ ("bg", "Bulgarian"),
21
+ ("ca", "Catalan"),
22
+ ("cs", "Czech"),
23
+ ("da", "Danish"),
24
+ ("de", "German"),
25
+ ("el", "Greek"),
26
+ ("eo", "Esperanto"),
27
+ ("es", "Spanish"),
28
+ ("eu", "Basque"),
29
+ ("fi", "Finnish"),
30
+ ("fr", "French"),
31
+ ("gl", "Galician"),
32
+ ("hu", "Hungarian"),
33
+ ("id", "Indonesian"),
34
+ ("it", "Italian"),
35
+ ("ja", "Japanese"),
36
+ ("km", "Khmer"),
37
+ ("ko", "Korean"),
38
+ ("lt", "Lithuanian"),
39
+ ("ml", "Malayalam"),
40
+ ("nb", "Norwegian Bokmål"),
41
+ ("nl", "Dutch"),
42
+ ("pl", "Polish"),
43
+ ("pt", "Portuguese"),
44
+ ("pt_BR", "Brazilian Portuguese"),
45
+ ("ro", "Romanian"),
46
+ ("ru", "Russian"),
47
+ ("sk", "Slovak"),
48
+ ("sl", "Slovenian"),
49
+ ("sr", "Serbian"),
50
+ ("sv", "Swedish"),
51
+ ("th", "Thai"),
52
+ ("tr", "Turkish"),
53
+ ("uk", "Ukrainian"),
54
+ ("vi", "Vietnamese"),
55
+ ("zh_CN", "Chinese (Simplified)"),
56
+ ("zh_TW", "Chinese (Traditional)"),
57
+ ]
58
+
59
+
60
+ def _cache_dir():
61
+ xdg = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
62
+ d = Path(xdg) / "ddtp-translate"
63
+ d.mkdir(parents=True, exist_ok=True)
64
+ return d
65
+
66
+
67
+ def _cache_path(lang):
68
+ return _cache_dir() / f"untranslated_{lang}.json"
69
+
70
+
71
+ def _is_cache_valid(path):
72
+ if not path.exists():
73
+ return False
74
+ return (time.time() - path.stat().st_mtime) < CACHE_TTL
75
+
76
+
77
+ def compute_description_hash(description):
78
+ """Compute MD5 hash of the description (used in DDTP email subject)."""
79
+ return hashlib.md5(description.encode("utf-8")).hexdigest()
80
+
81
+
82
+ def parse_ddtp_response(text):
83
+ """Parse DDTP ddt.cgi response into list of package dicts.
84
+
85
+ Response format (one per package, separated by blank lines):
86
+ Package: <name>
87
+ Description-md5: <hash>
88
+ Description-en: <short description>
89
+ <long description line 1>
90
+ <long description line 2>
91
+ .
92
+ """
93
+ packages = []
94
+ current = None
95
+
96
+ for line in text.splitlines():
97
+ if line.startswith("Package: "):
98
+ if current:
99
+ packages.append(current)
100
+ current = {
101
+ "package": line[9:].strip(),
102
+ "md5": "",
103
+ "short": "",
104
+ "long": "",
105
+ }
106
+ elif line.startswith("Description-md5: ") and current:
107
+ current["md5"] = line[17:].strip()
108
+ elif line.startswith("Description-en: ") and current:
109
+ current["short"] = line[16:].strip()
110
+ elif current and (line.startswith(" ") or line.startswith("\t")):
111
+ stripped = line.strip()
112
+ if stripped == ".":
113
+ current["long"] += "\n"
114
+ else:
115
+ if current["long"]:
116
+ current["long"] += "\n"
117
+ current["long"] += stripped
118
+
119
+ if current:
120
+ packages.append(current)
121
+
122
+ return packages
123
+
124
+
125
+ def _fetch_translation_file(dist, lang):
126
+ """Download and decompress Translation-XX.bz2 from Debian mirror."""
127
+ import bz2
128
+ url = f"{MIRROR_BASE}/dists/{dist}/main/i18n/Translation-{lang}.bz2"
129
+ try:
130
+ req = urllib.request.Request(url, headers={"User-Agent": "ddtp-translate/0.1"})
131
+ with urllib.request.urlopen(req, timeout=60) as resp:
132
+ compressed = resp.read()
133
+ return bz2.decompress(compressed).decode("utf-8", errors="replace")
134
+ except Exception:
135
+ return None
136
+
137
+
138
+ def _parse_translation_file(text):
139
+ """Parse Debian Translation file into dict of md5 -> {package, short, long}."""
140
+ entries = {}
141
+ current = {}
142
+ in_desc = False
143
+
144
+ for line in text.splitlines():
145
+ if line.startswith("Package: "):
146
+ if current.get("md5"):
147
+ entries[current["md5"]] = current
148
+ current = {"package": line[9:].strip(), "md5": "", "short": "", "long": ""}
149
+ in_desc = False
150
+ elif line.startswith("Description-md5: "):
151
+ current["md5"] = line[17:].strip()
152
+ elif line.startswith("Description-"):
153
+ current["short"] = line.split(": ", 1)[1] if ": " in line else ""
154
+ in_desc = True
155
+ elif in_desc and (line.startswith(" ") or line.startswith("\t")):
156
+ stripped = line.strip()
157
+ if stripped == ".":
158
+ current["long"] += "\n"
159
+ else:
160
+ if current["long"]:
161
+ current["long"] += "\n"
162
+ current["long"] += stripped
163
+ else:
164
+ in_desc = False
165
+
166
+ if current.get("md5"):
167
+ entries[current["md5"]] = current
168
+ return entries
169
+
170
+
171
+ def fetch_untranslated_from_mirror(lang, dist="sid"):
172
+ """Fetch untranslated descriptions by comparing en vs lang Translation files."""
173
+ cache = _cache_dir() / f"mirror_untranslated_{lang}_{dist}.json"
174
+
175
+ if _is_cache_valid(cache):
176
+ with open(cache, "r", encoding="utf-8") as f:
177
+ return json.load(f)
178
+
179
+ en_text = _fetch_translation_file(dist, "en")
180
+ if not en_text:
181
+ raise RuntimeError(f"Failed to download Translation-en for {dist}")
182
+
183
+ lang_text = _fetch_translation_file(dist, lang)
184
+
185
+ en_entries = _parse_translation_file(en_text)
186
+ lang_entries = _parse_translation_file(lang_text) if lang_text else {}
187
+
188
+ # Untranslated = in en but not in lang
189
+ untranslated = []
190
+ for md5, entry in en_entries.items():
191
+ if md5 not in lang_entries:
192
+ untranslated.append(entry)
193
+
194
+ # Sort by package name
195
+ untranslated.sort(key=lambda x: x.get("package", ""))
196
+
197
+ with open(cache, "w", encoding="utf-8") as f:
198
+ json.dump(untranslated, f, ensure_ascii=False, indent=2)
199
+
200
+ return untranslated
201
+
202
+
203
+ def fetch_untranslated(lang, force_refresh=False):
204
+ """Fetch untranslated descriptions for a language. Returns list of dicts.
205
+
206
+ Tries ddtp.debian.org first, falls back to Debian mirror comparison.
207
+ """
208
+ cache = _cache_path(lang)
209
+
210
+ if not force_refresh and _is_cache_valid(cache):
211
+ with open(cache, "r", encoding="utf-8") as f:
212
+ return json.load(f)
213
+
214
+ # Try ddtp.debian.org first
215
+ url = f"{DDTP_BASE}?lcode={lang}&getuntranslated=1"
216
+ try:
217
+ req = urllib.request.Request(url, headers={"User-Agent": "ddtp-translate/0.1"})
218
+ with urllib.request.urlopen(req, timeout=15) as resp:
219
+ text = resp.read().decode("utf-8", errors="replace")
220
+ packages = parse_ddtp_response(text)
221
+ if packages:
222
+ with open(cache, "w", encoding="utf-8") as f:
223
+ json.dump(packages, f, ensure_ascii=False, indent=2)
224
+ return packages
225
+ except Exception:
226
+ pass
227
+
228
+ # Fallback: Debian mirror comparison
229
+ try:
230
+ packages = fetch_untranslated_from_mirror(lang, "sid")
231
+ if packages:
232
+ with open(cache, "w", encoding="utf-8") as f:
233
+ json.dump(packages, f, ensure_ascii=False, indent=2)
234
+ return packages
235
+ except Exception:
236
+ pass
237
+
238
+ # Last resort: stale cache
239
+ if cache.exists():
240
+ with open(cache, "r", encoding="utf-8") as f:
241
+ return json.load(f)
242
+
243
+ raise RuntimeError(
244
+ "Could not fetch DDTP data. Both ddtp.debian.org and "
245
+ "the Debian mirror (deb.debian.org) are unreachable. "
246
+ "Please check your internet connection."
247
+ )
248
+
249
+
250
+ def fetch_popcon_data(force_refresh=False):
251
+ """Fetch Debian popcon (popularity contest) install counts.
252
+
253
+ Returns dict mapping package_name -> install_count.
254
+ Data from https://popcon.debian.org/by_inst.gz
255
+ """
256
+ import gzip
257
+ cache = _cache_dir() / "popcon_by_inst.json"
258
+
259
+ if not force_refresh and _is_cache_valid(cache):
260
+ with open(cache, "r", encoding="utf-8") as f:
261
+ return json.load(f)
262
+
263
+ url = "https://popcon.debian.org/by_inst.gz"
264
+ try:
265
+ req = urllib.request.Request(url, headers={"User-Agent": "ddtp-translate/0.8"})
266
+ with urllib.request.urlopen(req, timeout=30) as resp:
267
+ compressed = resp.read()
268
+ text = gzip.decompress(compressed).decode("utf-8", errors="replace")
269
+ except Exception as exc:
270
+ # Try stale cache
271
+ if cache.exists():
272
+ with open(cache, "r", encoding="utf-8") as f:
273
+ return json.load(f)
274
+ return {}
275
+
276
+ popcon = {}
277
+ for line in text.splitlines():
278
+ if line.startswith("#") or not line.strip():
279
+ continue
280
+ parts = line.split()
281
+ if len(parts) >= 3:
282
+ try:
283
+ # Format: rank name inst vote old recent no-files
284
+ # or: name inst vote old recent no-files (without rank column)
285
+ # The by_inst format: rank name inst vote old recent no-files
286
+ rank = parts[0]
287
+ if rank.isdigit():
288
+ pkg_name = parts[1]
289
+ inst_count = int(parts[2])
290
+ else:
291
+ pkg_name = parts[0]
292
+ inst_count = int(parts[1])
293
+ popcon[pkg_name] = inst_count
294
+ except (ValueError, IndexError):
295
+ continue
296
+
297
+ with open(cache, "w", encoding="utf-8") as f:
298
+ json.dump(popcon, f, ensure_ascii=False)
299
+
300
+ return popcon
301
+
302
+
303
+ def fetch_ddtp_stats():
304
+ """Fetch translation statistics from ddtp.debian.org main page.
305
+
306
+ Returns dict mapping lang_code -> {
307
+ 'active_translations': int,
308
+ 'previously_translated': int,
309
+ 'total_translations': int,
310
+ }
311
+ Also returns total_packages and active_packages as top-level keys.
312
+ """
313
+ cache = _cache_dir() / "ddtp_stats.json"
314
+ if _is_cache_valid(cache):
315
+ with open(cache, "r", encoding="utf-8") as f:
316
+ return json.load(f)
317
+
318
+ url = "https://ddtp.debian.org/"
319
+ try:
320
+ req = urllib.request.Request(url, headers={"User-Agent": "ddtp-translate/0.1"})
321
+ with urllib.request.urlopen(req, timeout=15) as resp:
322
+ text = resp.read().decode("utf-8", errors="replace")
323
+ except Exception as exc:
324
+ # Try stale cache
325
+ if cache.exists():
326
+ with open(cache, "r", encoding="utf-8") as f:
327
+ return json.load(f)
328
+ raise RuntimeError(f"Could not fetch DDTP statistics: {exc}")
329
+
330
+ stats = {"languages": {}, "total_packages": 0, "active_packages": 0}
331
+
332
+ # Parse total/active packages
333
+ m = re.search(r"(\d+) package descriptions;", text)
334
+ if m:
335
+ stats["total_packages"] = int(m.group(1))
336
+ m = re.search(r"(\d+) package descriptions are active;", text)
337
+ if m:
338
+ stats["active_packages"] = int(m.group(1))
339
+
340
+ # Parse per-language stats
341
+ # Pattern: lang XX has N (<a ...>M</a>) active translations from T translations;
342
+ for m in re.finditer(
343
+ r'lang (\S+) has (\d+) \(<a[^>]*>(\d+)</a>\) active translations from (\d+) translations;',
344
+ text,
345
+ ):
346
+ lang_code = m.group(1)
347
+ stats["languages"][lang_code] = {
348
+ "active_translations": int(m.group(2)),
349
+ "previously_translated": int(m.group(3)),
350
+ "total_translations": int(m.group(4)),
351
+ }
352
+
353
+ with open(cache, "w", encoding="utf-8") as f:
354
+ json.dump(stats, f, ensure_ascii=False, indent=2)
355
+
356
+ return stats