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.
- ddtp_translate/__init__.py +3 -0
- ddtp_translate/__main__.py +3 -0
- ddtp_translate/accessibility.py +66 -0
- ddtp_translate/ddtp_api.py +356 -0
- ddtp_translate/ddtss_client.py +646 -0
- ddtp_translate/main.py +3550 -0
- ddtp_translate/settings.py +95 -0
- ddtp_translate-0.14.4.dist-info/METADATA +84 -0
- ddtp_translate-0.14.4.dist-info/RECORD +13 -0
- ddtp_translate-0.14.4.dist-info/WHEEL +5 -0
- ddtp_translate-0.14.4.dist-info/entry_points.txt +2 -0
- ddtp_translate-0.14.4.dist-info/licenses/LICENSE +22 -0
- ddtp_translate-0.14.4.dist-info/top_level.txt +1 -0
|
@@ -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
|