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

256 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-26 07:07 +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( # type: ignore[override] 

69 self, 

70 context: str, 

71 source_text: str, 

72 _disambiguation: str | None = None, 

73 _n: int = -1, 

74 ) -> str | None: 

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

76 

77 PySide6 maps a ``None`` return value to a null ``QString``, which tells 

78 Qt to continue querying the next installed translator and ultimately to 

79 fall back to the source text. Returning ``""`` (an empty but non-null 

80 ``QString``) would be interpreted as "the translation is an empty 

81 string", which is incorrect for strings not yet translated. 

82 

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

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

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

86 

87 Args: 

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

89 source_text: The English source string to translate. 

90 _disambiguation: Optional disambiguation hint (unused). 

91 _n: Plural form selector (unused). 

92 

93 Returns: 

94 The translated string if already cached, ``None`` otherwise. 

95 """ 

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

97 return None 

98 

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

100 if cached: 

101 return cached 

102 

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

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

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

106 # • cache hit → returns cached value immediately 

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

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

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

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

111 self._manager.auto_translation_enabled 

112 and self._manager.auto_translator.enabled 

113 ): 

114 result = self._manager.auto_translator.translate( 

115 source_text, DEFAULT_LANGUAGE, self._manager.current_language 

116 ) 

117 if result is not None: 

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

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

120 self._manager._ts_translations[source_text] = result 

121 self._manager._persist_translation(source_text, result) 

122 return result 

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

124 self._manager._increment_pending() 

125 

126 # Return None so Qt falls back to the .qm translator or source text. 

127 return None 

128 

129 

130class TranslationManager(QObject): 

131 """Core translation engine for EzQt_App. 

132 

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

134 and automatic widget retranslation on language change. 

135 """ 

136 

137 languageChanged = Signal(str) 

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

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

140 translation_started = Signal() 

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

142 # Consumers use this to hide the progress indicator. 

143 translation_finished = Signal() 

144 

145 def __init__(self) -> None: 

146 super().__init__() 

147 self.translator = QTranslator() 

148 self.current_language = DEFAULT_LANGUAGE 

149 

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

151 

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

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

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

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

156 # it transitions 1 → 0 we emit translation_finished. 

157 self._pending_auto_translations: int = 0 

158 

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

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

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

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

163 # swapped on each language change. 

164 self._ez_translator = EzTranslator(self) 

165 

166 self.auto_translator = get_auto_translator() 

167 self.auto_translation_enabled = False 

168 self.auto_save_translations = False 

169 

170 try: 

171 translation_cfg = ( 

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

173 ) 

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

175 self.auto_translation_enabled = _parse_bool( 

176 translation_cfg.get("auto_translation_enabled", False) 

177 ) 

178 self.auto_save_translations = _parse_bool( 

179 translation_cfg.get("save_to_ts_files", False) 

180 ) 

181 except Exception as e: 

182 warn_tech( 

183 code="translation.manager.config_load_failed", 

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

185 error=e, 

186 ) 

187 

188 self.auto_translator.enabled = self.auto_translation_enabled 

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

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

191 

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

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

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

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

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

197 _qapp = QCoreApplication.instance() 

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

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

200 QCoreApplication.installTranslator(self._ez_translator) 

201 

202 # Resolve translations directory 

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

204 self.translations_dir: Path | None = ( 

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

206 / "ezqt_app" 

207 / "resources" 

208 / "translations" 

209 ) 

210 else: 

211 possible_paths = [ 

212 get_bin_path() / "translations", 

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

214 ] 

215 try: 

216 pkg_dir = self._get_package_translations_dir() 

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

218 possible_paths.append(pkg_dir) 

219 except Exception as e: 

220 warn_tech( 

221 code="translation.manager.package_dir_resolution_failed", 

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

223 error=e, 

224 ) 

225 

226 self.translations_dir = next( 

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

228 ) 

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

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

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

232 

233 def _get_package_translations_dir(self) -> Path: 

234 try: 

235 import pkg_resources # type: ignore[import-untyped] 

236 

237 return Path( 

238 pkg_resources.resource_filename("ezqt_app", "resources/translations") 

239 ) 

240 except Exception: 

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

242 

243 # ------------------------------------------------------------------ 

244 # .qm compilation helpers 

245 # ------------------------------------------------------------------ 

246 

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

248 """Locate the pyside6-lrelease executable. 

249 

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

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

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

253 """ 

254 import shutil 

255 

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

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

258 return Path(which) 

259 

260 try: 

261 import PySide6 

262 

263 pyside6_dir = Path(PySide6.__file__).parent 

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

265 candidate = pyside6_dir / name 

266 if candidate.exists(): 

267 return candidate 

268 except Exception as e: 

269 warn_tech( 

270 code="translation.manager.pyside6_lrelease_lookup_failed", 

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

272 error=e, 

273 ) 

274 

275 return None 

276 

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

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

279 

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

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

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

283 """ 

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

285 return True 

286 

287 lrelease = self._find_lrelease() 

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

289 warn_tech( 

290 code="translation.manager.lrelease_not_found", 

291 message=( 

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

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

294 ), 

295 ) 

296 return qm_path.exists() 

297 

298 try: 

299 import subprocess 

300 

301 result = subprocess.run( 

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

303 capture_output=True, 

304 text=True, 

305 timeout=30, 

306 ) 

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

308 warn_tech( 

309 code="translation.manager.lrelease_failed", 

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

311 ) 

312 except Exception as e: 

313 warn_tech( 

314 code="translation.manager.qm_compile_failed", 

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

316 error=e, 

317 ) 

318 

319 return qm_path.exists() 

320 

321 # ------------------------------------------------------------------ 

322 

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

324 try: 

325 import xml.etree.ElementTree as ET 

326 

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

328 return False 

329 

330 root = ET.parse(ts_file_path).getroot() # noqa: S314 

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

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

333 source = message.find("source") 

334 translation = message.find("translation") 

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

336 source is not None 

337 and translation is not None 

338 and source.text 

339 and translation.text 

340 ): 

341 translations[source.text] = translation.text 

342 

343 self._ts_translations.update(translations) 

344 return True 

345 except Exception as e: 

346 warn_tech( 

347 code="translation.manager.load_ts_failed", 

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

349 error=e, 

350 ) 

351 return False 

352 

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

354 name_to_code = { 

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

356 } 

357 if language_name in name_to_code: 

358 return self.load_language_by_code(name_to_code[language_name]) 

359 return False 

360 

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

362 if language_code not in SUPPORTED_LANGUAGES: 

363 warn_user( 

364 code="translation.manager.unsupported_language", 

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

366 ) 

367 return False 

368 

369 self._ts_translations.clear() 

370 

371 app = QCoreApplication.instance() 

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

373 try: 

374 QCoreApplication.removeTranslator(self.translator) 

375 except Exception as e: 

376 warn_tech( 

377 code="translation.manager.remove_translator_failed", 

378 message="Error removing translator", 

379 error=e, 

380 ) 

381 

382 self.translator = QTranslator() 

383 language_info = SUPPORTED_LANGUAGES[language_code] 

384 self.current_language = language_code 

385 

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

387 warn_tech( 

388 code="translation.manager.translations_dir_unavailable", 

389 message=( 

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

391 f"for language '{language_code}'" 

392 ), 

393 ) 

394 self.languageChanged.emit(language_code) 

395 return True 

396 

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

398 

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

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

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

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

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

404 ts_file_path, qm_path 

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

406 warn_tech( 

407 code="translation.manager.load_qm_failed", 

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

409 ) 

410 get_printer().debug_msg( 

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

412 ) 

413 elif language_code != DEFAULT_LANGUAGE: 

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

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

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

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

418 warn_user( 

419 code="translation.manager.load_language_failed", 

420 user_message=( 

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

422 "auto-translation will populate it progressively." 

423 ), 

424 ) 

425 

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

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

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

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

430 try: 

431 QCoreApplication.installTranslator(self.translator) 

432 except Exception as e: 

433 warn_tech( 

434 code="translation.manager.install_translator_failed", 

435 message="Error installing translator", 

436 error=e, 

437 ) 

438 

439 self.languageChanged.emit(language_code) 

440 return True 

441 

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

443 return list(SUPPORTED_LANGUAGES.keys()) 

444 

445 def get_current_language_name(self) -> str: 

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

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

448 return "Unknown" 

449 

450 def get_current_language_code(self) -> str: 

451 return self.current_language 

452 

453 @property 

454 def translation_count(self) -> int: 

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

456 return len(self._ts_translations) 

457 

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

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

460 return self._ts_translations[text] 

461 

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

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

464 return translated 

465 

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

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

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

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

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

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

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

473 # will emit translation_ready later. 

474 if result is None: 

475 self._increment_pending() 

476 

477 return text 

478 

479 # ------------------------------------------------------------------ 

480 # Pending-count helpers 

481 # ------------------------------------------------------------------ 

482 

483 def _increment_pending(self) -> None: 

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

485 

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

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

488 """ 

489 self._pending_auto_translations += 1 

490 if self._pending_auto_translations == 1: 

491 self.translation_started.emit() 

492 

493 def _decrement_pending(self) -> None: 

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

495 

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

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

498 """ 

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

500 if self._pending_auto_translations == 0: 

501 self.translation_finished.emit() 

502 

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

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

505 

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

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

508 

509 Args: 

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

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

512 """ 

513 self._decrement_pending() 

514 

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

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

517 

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

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

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

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

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

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

524 freshly reloaded ``.qm`` file. 

525 

526 Args: 

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

528 translated: The translated string in the current language. 

529 """ 

530 self._decrement_pending() 

531 self._ts_translations[original] = translated 

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

533 # _save_auto_translation_to_ts reloads the .qm translator, which 

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

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

536 self._save_auto_translation_to_ts(original, translated) 

537 else: 

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

539 # _ez_translator to post LanguageChange so widgets call 

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

541 app = QCoreApplication.instance() 

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

543 try: 

544 QCoreApplication.installTranslator(self._ez_translator) 

545 except Exception as e: 

546 warn_tech( 

547 code="translation.manager.ez_translator_reinstall_failed", 

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

549 error=e, 

550 ) 

551 

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

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

554 

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

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

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

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

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

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

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

562 

563 Args: 

564 original: The source (English) string. 

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

566 source language — identity mapping). 

567 """ 

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

569 return 

570 if self.current_language not in SUPPORTED_LANGUAGES: 

571 return 

572 language_info = SUPPORTED_LANGUAGES[self.current_language] 

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

574 self.auto_translator.save_translation_to_ts( 

575 original, translated, self.current_language, ts_file_path 

576 ) 

577 

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

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

580 

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

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

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

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

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

586 across application restarts. 

587 

588 Args: 

589 original: The source (English) string. 

590 translated: The translated string to persist. 

591 """ 

592 try: 

593 if self.current_language not in SUPPORTED_LANGUAGES: 

594 return 

595 if self.translations_dir is None: 

596 return 

597 

598 language_info = SUPPORTED_LANGUAGES[self.current_language] 

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

600 self.auto_translator.save_translation_to_ts( 

601 original, translated, self.current_language, ts_file_path 

602 ) 

603 self._ts_translations[original] = translated 

604 

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

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

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

608 if self._ensure_qm_compiled(ts_file_path, qm_path): 

609 app = QCoreApplication.instance() 

610 if app is not None: 

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

612 # one, load the updated binary, reinstall. 

613 try: 

614 QCoreApplication.removeTranslator(self.translator) 

615 self.translator = QTranslator() 

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

617 warn_tech( 

618 code="translation.manager.reload_qm_failed", 

619 message=( 

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

621 f"for {qm_path.name}" 

622 ), 

623 ) 

624 # Install first so it is consulted before EzTranslator 

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

626 QCoreApplication.installTranslator(self.translator) 

627 except Exception as e: 

628 warn_tech( 

629 code="translation.manager.reload_qm_install_failed", 

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

631 error=e, 

632 ) 

633 except Exception as e: 

634 warn_tech( 

635 code="translation.manager.auto_translation_save_failed", 

636 message="Error saving automatic translation", 

637 error=e, 

638 ) 

639 

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

641 self.auto_translation_enabled = enabled 

642 if self.auto_translator: 

643 self.auto_translator.enabled = enabled 

644 get_printer().action( 

645 "[TranslationService] Automatic translation " 

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

647 ) 

648 

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

650 if self.auto_translator: 

651 return self.auto_translator.get_cache_stats() 

652 return {} 

653 

654 def clear_auto_translation_cache(self) -> None: 

655 if self.auto_translator: 

656 self.auto_translator.clear_cache() 

657 

658 

659# /////////////////////////////////////////////////////////////// 

660# FUNCTIONS 

661# /////////////////////////////////////////////////////////////// 

662def get_translation_manager() -> TranslationManager: 

663 """Return the global TranslationManager singleton.""" 

664 from .._registry import ServiceRegistry 

665 

666 return ServiceRegistry.get(TranslationManager, TranslationManager)