ddtp-translate 0.14.4__py3-none-any.whl

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,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