Coverage for src / ezqt_app / services / translation / manager.py: 58.81%

258 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 13:12 +0000

1# /////////////////////////////////////////////////////////////// 

2# SERVICES.TRANSLATION.MANAGER - Translation engine 

3# Project: ezqt_app 

4# /////////////////////////////////////////////////////////////// 

5 

6"""Core translation manager — QTranslator wrapper with Qt-native language change propagation.""" 

7 

8from __future__ import annotations 

9 

10# /////////////////////////////////////////////////////////////// 

11# IMPORTS 

12# /////////////////////////////////////////////////////////////// 

13# Standard library imports 

14import sys 

15from pathlib import Path 

16from typing import Any 

17 

18# Third-party imports 

19from PySide6.QtCore import QCoreApplication, QObject, QTranslator, Signal 

20 

21# Local imports 

22from ...domain.models.translation import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES 

23from ...services.config import get_config_service 

24from ...utils.diagnostics import warn_tech, warn_user 

25from ...utils.printer import get_printer 

26from ...utils.runtime_paths import get_bin_path 

27from .auto_translator import get_auto_translator 

28 

29 

30# /////////////////////////////////////////////////////////////// 

31# HELPERS 

32# /////////////////////////////////////////////////////////////// 

33def _parse_bool(raw: object) -> bool: 

34 """Parse a config value as bool, supporting string representations.""" 

35 if isinstance(raw, str): 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true

36 return raw.strip().lower() in {"1", "true", "yes", "on"} 

37 return bool(raw) 

38 

39 

40# /////////////////////////////////////////////////////////////// 

41# CLASSES 

42# /////////////////////////////////////////////////////////////// 

43class EzTranslator(QTranslator): 

44 """Qt translator that intercepts unknown strings for auto-translation. 

45 

46 This translator is installed alongside the compiled ``.qm`` translator. 

47 Qt calls ``translate()`` on every installed translator in LIFO order until 

48 one returns a non-empty string. When *source_text* is not yet known, 

49 ``EzTranslator`` fires the async auto-translation pipeline and returns an 

50 empty string so Qt falls back to the source text for this render cycle. 

51 On the next ``LanguageChange`` (triggered once the translation arrives) the 

52 string is found in ``_ts_translations`` and returned immediately. 

53 

54 Args: 

55 manager: The owning :class:`TranslationManager` instance. 

56 """ 

57 

58 _CONTEXT = "EzQt_App" 

59 

60 def __init__(self, manager: TranslationManager) -> None: 

61 super().__init__() 

62 self._manager = manager 

63 

64 # ------------------------------------------------------------------ 

65 # QTranslator virtual override 

66 # ------------------------------------------------------------------ 

67 

68 def translate( 

69 self, 

70 context: str, 

71 source_text: str, 

72 disambiguation: str | None = None, # noqa: ARG002 

73 n: int = -1, # noqa: ARG002 

74 ) -> str | None: # type: ignore[override] # PySide6 maps None → null QString → Qt falls back 

75 """Return the translation for *source_text*, or ``None`` if unknown. 

76 

77 Returning ``None`` signals Qt (via null QString) that this translator 

78 did not handle the string, so Qt continues querying the next installed 

79 translator and ultimately falls back to the source text. 

80 

81 Returning ``""`` would mean "found, but translation is empty", which 

82 would incorrectly replace every untranslated string with an empty label. 

83 

84 When the string is absent from the in-memory cache and auto-translation 

85 is active, an async translation request is fired so the string will be 

86 available on the next ``LanguageChange`` cycle. 

87 

88 Args: 

89 context: Qt translation context (only ``"EzQt_App"`` is handled). 

90 source_text: The English source string to translate. 

91 disambiguation: Optional disambiguation hint (unused). 

92 n: Plural form selector (unused). 

93 

94 Returns: 

95 The translated string if cached, ``None`` otherwise. 

96 """ 

97 if context != self._CONTEXT or not source_text: 

98 return None 

99 

100 cached = self._manager._ts_translations.get(source_text) 

101 if cached: 

102 return cached 

103 

104 # String is unknown — fire auto-translation if enabled. 

105 # auto_translator.translate() handles all cases uniformly: 

106 # • source == target → returns text immediately (identity, no HTTP) 

107 # • cache hit → returns cached value immediately 

108 # • cache miss → spawns a daemon thread, returns None 

109 # All languages including English are processed so that every .ts file 

110 # is populated with real entries and compiled to a valid .qm. 

111 if ( 111 ↛ 115line 111 didn't jump to line 115 because the condition on line 111 was never true

112 self._manager.auto_translation_enabled 

113 and self._manager.auto_translator.enabled 

114 ): 

115 result = self._manager.auto_translator.translate( 

116 source_text, DEFAULT_LANGUAGE, self._manager.current_language 

117 ) 

118 if result is not None: 

119 # Immediate result (identity or cache hit): populate in-memory 

120 # cache and persist to .ts so the string survives the next load. 

121 self._manager._ts_translations[source_text] = result 

122 self._manager._persist_translation(source_text, result) 

123 return result 

124 # Async request dispatched — a thread is running the HTTP round-trip. 

125 self._manager._increment_pending() 

126 

127 # Null return → Qt falls back to the .qm translator or source text. 

128 return None 

129 

130 

131class TranslationManager(QObject): 

132 """Core translation engine for EzQt_App. 

133 

134 Handles .ts file loading, language switching, Qt translator installation 

135 and automatic widget retranslation on language change. 

136 """ 

137 

138 languageChanged = Signal(str) 

139 # Emitted when the first pending auto-translation is enqueued (count > 0). 

140 # Consumers (e.g. BottomBar) use this to show a progress indicator. 

141 translation_started = Signal() 

142 # Emitted when all pending auto-translations have been resolved (count == 0). 

143 # Consumers use this to hide the progress indicator. 

144 translation_finished = Signal() 

145 

146 def __init__(self) -> None: 

147 super().__init__() 

148 self.translator = QTranslator() 

149 self.current_language = DEFAULT_LANGUAGE 

150 

151 self._ts_translations: dict[str, str] = {} 

152 

153 # Count of async auto-translation requests that have been fired but 

154 # for which no result (ready or error) has arrived yet. Incremented 

155 # each time a new request is dispatched; decremented on every outcome. 

156 # When the count transitions 0 → 1 we emit translation_started; when 

157 # it transitions 1 → 0 we emit translation_finished. 

158 self._pending_auto_translations: int = 0 

159 

160 # EzTranslator intercepts QCoreApplication.translate("EzQt_App", text) 

161 # calls for strings absent from the compiled .qm file (e.g. developer 

162 # custom strings not yet added to the .ts). It is installed once and 

163 # kept across language switches; only the .qm-backed translator is 

164 # swapped on each language change. 

165 self._ez_translator = EzTranslator(self) 

166 

167 self.auto_translator = get_auto_translator() 

168 self.auto_translation_enabled = False 

169 self.auto_save_translations = False 

170 

171 try: 

172 translation_cfg = ( 

173 get_config_service().load_config("translation").get("translation", {}) 

174 ) 

175 if isinstance(translation_cfg, dict): 175 ↛ 189line 175 didn't jump to line 189 because the condition on line 175 was always true

176 self.auto_translation_enabled = _parse_bool( 

177 translation_cfg.get("auto_translation_enabled", False) 

178 ) 

179 self.auto_save_translations = _parse_bool( 

180 translation_cfg.get("save_to_ts_files", False) 

181 ) 

182 except Exception as e: 

183 warn_tech( 

184 code="translation.manager.config_load_failed", 

185 message="Could not load translation config; using defaults (disabled)", 

186 error=e, 

187 ) 

188 

189 self.auto_translator.enabled = self.auto_translation_enabled 

190 self.auto_translator.translation_ready.connect(self._on_auto_translation_ready) 

191 self.auto_translator.translation_error.connect(self._on_auto_translation_error) 

192 

193 # Register cleanup on application exit: stop the worker thread and purge 

194 # the expired cache entries. Guard against QCoreApplication not yet existing 

195 # (e.g., when the manager is instantiated in a test context without a Qt app). 

196 # Also install EzTranslator so it begins intercepting QCoreApplication.translate() 

197 # calls immediately — even before the first explicit language switch. 

198 _qapp = QCoreApplication.instance() 

199 if _qapp is not None: 199 ↛ 204line 199 didn't jump to line 204 because the condition on line 199 was always true

200 _qapp.aboutToQuit.connect(self.auto_translator.cleanup) 

201 QCoreApplication.installTranslator(self._ez_translator) 

202 

203 # Resolve translations directory 

204 if hasattr(sys, "_MEIPASS"): 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true

205 self.translations_dir: Path | None = ( 

206 Path(sys._MEIPASS) # pyright: ignore[reportAttributeAccessIssue] 

207 / "ezqt_app" 

208 / "resources" 

209 / "translations" 

210 ) 

211 else: 

212 possible_paths = [ 

213 get_bin_path() / "translations", 

214 Path(__file__).parent.parent.parent / "resources" / "translations", 

215 ] 

216 try: 

217 pkg_dir = self._get_package_translations_dir() 

218 if pkg_dir.exists(): 218 ↛ 227line 218 didn't jump to line 227 because the condition on line 218 was always true

219 possible_paths.append(pkg_dir) 

220 except Exception as e: 

221 warn_tech( 

222 code="translation.manager.package_dir_resolution_failed", 

223 message="Could not resolve package translations dir", 

224 error=e, 

225 ) 

226 

227 self.translations_dir = next( 

228 (p for p in possible_paths if p.exists()), None 

229 ) 

230 if self.translations_dir is None: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true

231 self.translations_dir = get_bin_path() / "translations" 

232 self.translations_dir.mkdir(parents=True, exist_ok=True) 

233 

234 def _get_package_translations_dir(self) -> Path: 

235 try: 

236 from importlib.resources import files 

237 

238 return Path(str(files("ezqt_app").joinpath("resources/translations"))) 

239 except Exception: 

240 return Path(__file__).parent.parent.parent / "resources" / "translations" 

241 

242 # ------------------------------------------------------------------ 

243 # .qm compilation helpers 

244 # ------------------------------------------------------------------ 

245 

246 def _find_lrelease(self) -> Path | None: 

247 """Locate the pyside6-lrelease executable. 

248 

249 Searches PATH first (the ``pyside6-lrelease`` wrapper script), then 

250 falls back to the ``lrelease[.exe]`` binary inside the PySide6 package 

251 directory. Returns ``None`` if the tool cannot be found. 

252 """ 

253 import shutil 

254 

255 which = shutil.which("pyside6-lrelease") 

256 if which: 256 ↛ 259line 256 didn't jump to line 259 because the condition on line 256 was always true

257 return Path(which) 

258 

259 try: 

260 import PySide6 

261 

262 pyside6_dir = Path(PySide6.__file__).parent 

263 for name in ("lrelease.exe", "lrelease"): 

264 candidate = pyside6_dir / name 

265 if candidate.exists(): 

266 return candidate 

267 except Exception as e: 

268 warn_tech( 

269 code="translation.manager.pyside6_lrelease_lookup_failed", 

270 message="Could not locate lrelease in PySide6 package directory", 

271 error=e, 

272 ) 

273 

274 return None 

275 

276 def _ensure_qm_compiled(self, ts_path: Path, qm_path: Path) -> bool: 

277 """Compile *ts_path* → *qm_path* using ``pyside6-lrelease`` if needed. 

278 

279 Skips recompilation when *qm_path* is already up-to-date (mtime ≥ 

280 *ts_path* mtime). Returns ``True`` if *qm_path* exists after the 

281 method completes (either freshly compiled or already current). 

282 """ 

283 if qm_path.exists() and qm_path.stat().st_mtime >= ts_path.stat().st_mtime: 

284 return True 

285 

286 lrelease = self._find_lrelease() 

287 if lrelease is None: 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true

288 warn_tech( 

289 code="translation.manager.lrelease_not_found", 

290 message=( 

291 "pyside6-lrelease not found; .qm file will not be generated. " 

292 "Install PySide6 tools or ensure pyside6-lrelease is on PATH." 

293 ), 

294 ) 

295 return qm_path.exists() 

296 

297 try: 

298 import subprocess # nosec B404 

299 

300 result = subprocess.run( # nosec B603 

301 [str(lrelease), str(ts_path), "-qm", str(qm_path)], 

302 capture_output=True, 

303 text=True, 

304 timeout=30, 

305 ) 

306 if result.returncode != 0: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true

307 warn_tech( 

308 code="translation.manager.lrelease_failed", 

309 message=f"lrelease exited with code {result.returncode}: {result.stderr.strip()}", 

310 ) 

311 except Exception as e: 

312 warn_tech( 

313 code="translation.manager.qm_compile_failed", 

314 message=f"Failed to compile {ts_path.name} to .qm", 

315 error=e, 

316 ) 

317 

318 return qm_path.exists() 

319 

320 # ------------------------------------------------------------------ 

321 

322 def _load_ts_file(self, ts_file_path: Path) -> bool: 

323 try: 

324 import defusedxml.ElementTree as ET # type: ignore[import-untyped] 

325 

326 if not ts_file_path.exists(): 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true

327 return False 

328 

329 root = ET.parse(ts_file_path).getroot() 

330 if root is None: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true

331 return False 

332 translations: dict[str, str] = {} 

333 for message in root.findall(".//message"): 

334 source = message.find("source") 

335 translation = message.find("translation") 

336 if ( 336 ↛ 333line 336 didn't jump to line 333 because the condition on line 336 was always true

337 source is not None 

338 and translation is not None 

339 and source.text 

340 and translation.text 

341 ): 

342 translations[source.text] = translation.text 

343 

344 self._ts_translations.update(translations) 

345 return True 

346 except Exception as e: 

347 warn_tech( 

348 code="translation.manager.load_ts_failed", 

349 message=f"Error loading .ts file {ts_file_path}", 

350 error=e, 

351 ) 

352 return False 

353 

354 def load_language(self, language_name: str) -> bool: 

355 name_to_code = { 

356 info["name"]: code for code, info in SUPPORTED_LANGUAGES.items() 

357 } 

358 if language_name in name_to_code: 

359 return self.load_language_by_code(name_to_code[language_name]) 

360 return False 

361 

362 def load_language_by_code(self, language_code: str) -> bool: 

363 if language_code not in SUPPORTED_LANGUAGES: 

364 warn_user( 

365 code="translation.manager.unsupported_language", 

366 user_message=f"Unsupported language: {language_code}", 

367 ) 

368 return False 

369 

370 self._ts_translations.clear() 

371 

372 app = QCoreApplication.instance() 

373 if app is not None: 373 ↛ 383line 373 didn't jump to line 383 because the condition on line 373 was always true

374 try: 

375 QCoreApplication.removeTranslator(self.translator) 

376 except Exception as e: 

377 warn_tech( 

378 code="translation.manager.remove_translator_failed", 

379 message="Error removing translator", 

380 error=e, 

381 ) 

382 

383 self.translator = QTranslator() 

384 language_info = SUPPORTED_LANGUAGES[language_code] 

385 self.current_language = language_code 

386 

387 if self.translations_dir is None: 387 ↛ 388line 387 didn't jump to line 388 because the condition on line 387 was never true

388 warn_tech( 

389 code="translation.manager.translations_dir_unavailable", 

390 message=( 

391 "Translations directory not resolved; .ts file load skipped " 

392 f"for language '{language_code}'" 

393 ), 

394 ) 

395 self.languageChanged.emit(language_code) 

396 return True 

397 

398 ts_file_path = self.translations_dir / language_info["file"] 

399 

400 if self._load_ts_file(ts_file_path): 400 ↛ 414line 400 didn't jump to line 414 because the condition on line 400 was always true

401 # Compile .ts → .qm (skipped when .qm is already up to date) and 

402 # load into the QTranslator so QCoreApplication.translate() works. 

403 qm_path = ts_file_path.with_suffix(".qm") 

404 if self._ensure_qm_compiled( 404 ↛ 407line 404 didn't jump to line 407 because the condition on line 404 was never true

405 ts_file_path, qm_path 

406 ) and not self.translator.load(str(qm_path)): 

407 warn_tech( 

408 code="translation.manager.load_qm_failed", 

409 message=f"QTranslator.load() failed for {qm_path.name}", 

410 ) 

411 get_printer().debug_msg( 

412 f"[TranslationService] Language switched to {language_info['name']}" 

413 ) 

414 elif language_code != DEFAULT_LANGUAGE: 

415 # No .ts file yet for this language. Warn but continue: installing an 

416 # empty translator still fires Qt's LanguageChange event, which causes 

417 # all widgets to call retranslate_ui() → QCoreApplication.translate() 

418 # → EzTranslator.translate() → auto-translation for every string. 

419 warn_user( 

420 code="translation.manager.load_language_failed", 

421 user_message=( 

422 f"No translation file found for {language_info['name']} — " 

423 "auto-translation will populate it progressively." 

424 ), 

425 ) 

426 

427 # Always install the translator (empty or .qm-backed) so Qt posts a 

428 # LanguageChange event. Without this call, widgets never call 

429 # retranslate_ui() and EzTranslator is never invoked for the new language. 

430 if app is not None: 430 ↛ 440line 430 didn't jump to line 440 because the condition on line 430 was always true

431 try: 

432 QCoreApplication.installTranslator(self.translator) 

433 except Exception as e: 

434 warn_tech( 

435 code="translation.manager.install_translator_failed", 

436 message="Error installing translator", 

437 error=e, 

438 ) 

439 

440 self.languageChanged.emit(language_code) 

441 return True 

442 

443 def get_available_languages(self) -> list[str]: 

444 return list(SUPPORTED_LANGUAGES.keys()) 

445 

446 def get_current_language_name(self) -> str: 

447 if self.current_language in SUPPORTED_LANGUAGES: 447 ↛ 449line 447 didn't jump to line 449 because the condition on line 447 was always true

448 return SUPPORTED_LANGUAGES[self.current_language]["name"] 

449 return "Unknown" 

450 

451 def get_current_language_code(self) -> str: 

452 return self.current_language 

453 

454 @property 

455 def translation_count(self) -> int: 

456 """Return the number of cached translations.""" 

457 return len(self._ts_translations) 

458 

459 def translate(self, text: str) -> str: 

460 if text in self._ts_translations: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true

461 return self._ts_translations[text] 

462 

463 translated = self.translator.translate("", text) 

464 if translated and translated != text: 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true

465 return translated 

466 

467 # Fire async auto-translation — widgets update via _on_auto_translation_ready 

468 # when the result arrives. Never call translate_sync() from the UI thread. 

469 if self.auto_translation_enabled and self.auto_translator.enabled: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true

470 result = self.auto_translator.translate(text, "en", self.current_language) 

471 # Increment the pending counter only when a real async request was 

472 # dispatched (translate() returns None in that case). A non-None 

473 # result means the cache was hit synchronously and no worker task 

474 # will emit translation_ready later. 

475 if result is None: 

476 self._increment_pending() 

477 

478 return text 

479 

480 # ------------------------------------------------------------------ 

481 # Pending-count helpers 

482 # ------------------------------------------------------------------ 

483 

484 def _increment_pending(self) -> None: 

485 """Increment the pending auto-translation counter. 

486 

487 Emits :attr:`translation_started` when the count transitions from 0 

488 to 1, signalling that at least one async translation is in flight. 

489 """ 

490 self._pending_auto_translations += 1 

491 if self._pending_auto_translations == 1: 

492 self.translation_started.emit() 

493 

494 def _decrement_pending(self) -> None: 

495 """Decrement the pending auto-translation counter (floor: 0). 

496 

497 Emits :attr:`translation_finished` when the count reaches zero, 

498 signalling that all in-flight translations have been resolved. 

499 """ 

500 self._pending_auto_translations = max(0, self._pending_auto_translations - 1) 

501 if self._pending_auto_translations == 0: 

502 self.translation_finished.emit() 

503 

504 def _on_auto_translation_error(self, _original: str, _error: str) -> None: 

505 """Slot called when an async auto-translation fails. 

506 

507 Decrements the pending counter so the UI indicator is hidden even on 

508 failure. Error reporting is handled by :class:`AutoTranslator` itself. 

509 

510 Args: 

511 _original: The source string that could not be translated (unused here). 

512 _error: The error message reported by the provider (unused here). 

513 """ 

514 self._decrement_pending() 

515 

516 def _on_auto_translation_ready(self, original: str, translated: str) -> None: 

517 """Slot called when an async auto-translation completes. 

518 

519 Caches the result, optionally persists it to the .ts file (and 

520 recompiles the .qm), then triggers a ``QEvent::LanguageChange`` on all 

521 widgets by reinstalling ``_ez_translator``. This causes every widget's 

522 ``changeEvent`` / ``retranslate_ui`` to run, at which point 

523 ``QCoreApplication.translate("EzQt_App", original)`` finds the new 

524 entry either in ``_ts_translations`` (via ``EzTranslator``) or in the 

525 freshly reloaded ``.qm`` file. 

526 

527 Args: 

528 original: The source (English) string that was translated. 

529 translated: The translated string in the current language. 

530 """ 

531 self._decrement_pending() 

532 self._ts_translations[original] = translated 

533 if self.auto_save_translations: 533 ↛ 537line 533 didn't jump to line 537 because the condition on line 533 was never true

534 # _save_auto_translation_to_ts reloads the .qm translator, which 

535 # already calls installTranslator() and thus posts LanguageChange. 

536 # No need to reinstall _ez_translator separately in that case. 

537 self._save_auto_translation_to_ts(original, translated) 

538 else: 

539 # Without .ts persistence the .qm is not reloaded. Reinstall 

540 # _ez_translator to post LanguageChange so widgets call 

541 # retranslate_ui() and pick up the new entry from _ts_translations. 

542 app = QCoreApplication.instance() 

543 if app is not None: 543 ↛ exitline 543 didn't return from function '_on_auto_translation_ready' because the condition on line 543 was always true

544 try: 

545 QCoreApplication.installTranslator(self._ez_translator) 

546 except Exception as e: 

547 warn_tech( 

548 code="translation.manager.ez_translator_reinstall_failed", 

549 message="Could not reinstall EzTranslator after auto-translation", 

550 error=e, 

551 ) 

552 

553 def _persist_translation(self, original: str, translated: str) -> None: 

554 """Persist an immediately-resolved translation to the current .ts file. 

555 

556 Called by :class:`EzTranslator` when ``auto_translator.translate()`` 

557 returns a result synchronously (identity translation or cache hit). 

558 Unlike :meth:`_save_auto_translation_to_ts` this method does **not** 

559 recompile the .qm or reinstall the translator — the text is already 

560 correct in the UI, so no ``LanguageChange`` event is needed. The .qm 

561 will be recompiled on the next :meth:`load_language_by_code` call (the 

562 mtime check in :meth:`_ensure_qm_compiled` detects the stale .qm). 

563 

564 Args: 

565 original: The source (English) string. 

566 translated: The translation to persist (may equal *original* for the 

567 source language — identity mapping). 

568 """ 

569 if not self.auto_save_translations or self.translations_dir is None: 

570 return 

571 if self.current_language not in SUPPORTED_LANGUAGES: 

572 return 

573 language_info = SUPPORTED_LANGUAGES[self.current_language] 

574 ts_file_path = self.translations_dir / language_info["file"] 

575 self.auto_translator.save_translation_to_ts( 

576 original, translated, self.current_language, ts_file_path 

577 ) 

578 

579 def _save_auto_translation_to_ts(self, original: str, translated: str) -> None: 

580 """Persist *translated* to the active .ts file and reload the .qm translator. 

581 

582 After writing the new entry to the .ts file the method recompiles it to 

583 a fresh .qm and reloads ``self.translator``. Subsequent calls to 

584 ``QCoreApplication.translate("EzQt_App", original)`` will therefore 

585 find the string in the .qm-backed translator (in addition to 

586 ``EzTranslator``'s in-memory cache), making the translation durable 

587 across application restarts. 

588 

589 Args: 

590 original: The source (English) string. 

591 translated: The translated string to persist. 

592 """ 

593 try: 

594 if self.current_language not in SUPPORTED_LANGUAGES: 

595 return 

596 if self.translations_dir is None: 

597 return 

598 

599 language_info = SUPPORTED_LANGUAGES[self.current_language] 

600 ts_file_path = self.translations_dir / language_info["file"] 

601 self.auto_translator.save_translation_to_ts( 

602 original, translated, self.current_language, ts_file_path 

603 ) 

604 self._ts_translations[original] = translated 

605 

606 # Recompile .ts → .qm so the entry is available via the native 

607 # QTranslator on the next language load (and on app restart). 

608 qm_path = ts_file_path.with_suffix(".qm") 

609 if self._ensure_qm_compiled(ts_file_path, qm_path): 

610 app = QCoreApplication.instance() 

611 if app is not None: 

612 # Swap out the .qm translator atomically: remove the old 

613 # one, load the updated binary, reinstall. 

614 try: 

615 QCoreApplication.removeTranslator(self.translator) 

616 self.translator = QTranslator() 

617 if not self.translator.load(str(qm_path)): 

618 warn_tech( 

619 code="translation.manager.reload_qm_failed", 

620 message=( 

621 f"QTranslator.load() failed after auto-save " 

622 f"for {qm_path.name}" 

623 ), 

624 ) 

625 # Install first so it is consulted before EzTranslator 

626 # (LIFO order: last installed = first consulted). 

627 QCoreApplication.installTranslator(self.translator) 

628 except Exception as e: 

629 warn_tech( 

630 code="translation.manager.reload_qm_install_failed", 

631 message="Error reloading .qm translator after auto-save", 

632 error=e, 

633 ) 

634 except Exception as e: 

635 warn_tech( 

636 code="translation.manager.auto_translation_save_failed", 

637 message="Error saving automatic translation", 

638 error=e, 

639 ) 

640 

641 def enable_auto_translation(self, enabled: bool = True) -> None: 

642 self.auto_translation_enabled = enabled 

643 if self.auto_translator: 

644 self.auto_translator.enabled = enabled 

645 get_printer().action( 

646 "[TranslationService] Automatic translation " 

647 f"{'enabled' if enabled else 'disabled'}" 

648 ) 

649 

650 def get_auto_translation_stats(self) -> dict[str, Any]: 

651 if self.auto_translator: 

652 return self.auto_translator.get_cache_stats() 

653 return {} 

654 

655 def clear_auto_translation_cache(self) -> None: 

656 if self.auto_translator: 

657 self.auto_translator.clear_cache() 

658 

659 

660# /////////////////////////////////////////////////////////////// 

661# FUNCTIONS 

662# /////////////////////////////////////////////////////////////// 

663def get_translation_manager() -> TranslationManager: 

664 """Return the global TranslationManager singleton.""" 

665 from .._registry import ServiceRegistry 

666 

667 return ServiceRegistry.get(TranslationManager, TranslationManager)