diff --git a/assets/i18n/en.json b/assets/i18n/en.json new file mode 100644 index 0000000..f1fcba4 --- /dev/null +++ b/assets/i18n/en.json @@ -0,0 +1,52 @@ +{ + "scanner.offline.title": "Out of service", + "scanner.offline.opening": "Starting barcode scanner...", + "scanner.wait_badge": "Please present your badge", + "scanner.mode.clock": "Clock mode", + "scanner.mode.consultation": "Consultation mode", + + "clock.success.title.in": "Clock-in recorded", + "clock.success.title.out": "Clock-out recorded", + "clock.success.greeting.morning": "Good morning {firstname}", + "clock.success.greeting.evening": "Good evening {firstname}", + "clock.success.greeting.bye": "Goodbye {firstname}", + + "consultation.error.blocked.line1": "Errors prevent the information from being displayed correctly.", + "consultation.error.blocked.line2": "Please contact the office.", + "consultation.error.list_title": "\u26a0 Issue{suffix}:", + "consultation.error.unknown_date": " \u2022 unknown date: {description}", + + "consultation.summary.present": "\u2022 Present: {value}", + "consultation.summary.prev_total_balance": "\u2022 Total balance as of yesterday: {value}", + "consultation.summary.prev_month_balance": "\u2022 Monthly balance as of yesterday: {value}", + "consultation.summary.day_balance": "\u2022 Today's balance: {day_balance} ({worked} / {scheduled})", + "consultation.summary.month_vacation": "\u2022 Vacation this month: {value}", + "consultation.summary.remaining_vacation": "\u2022 Vacation to plan: {value}", + "consultation.summary.out_of_range": " \u26a0 Out of the allowed range{range}, please adjust promptly \u26a0", + "consultation.summary.warning_title": "\u26a0 Issue{suffix}:", + "consultation.summary.warning_item": " \u2022 {date}: {description}", + + "attendance_list.title": "Attendance list", + "attendance_list.empty": "Nobody is listed.", + "attendance_list.more_names": "+ {count} hidden...", + + "error.state.title": "An error occurred", + "error.state.subtitle": "Please contact the office", + "error.state.retry_later": "Please try again later", + + "services.smtp.offline": "\u26a0 SMTP server \u26a0", + + "common.yes": "yes", + "common.no": "no", + "common.unavailable": "unavailable", + + "plural.s": "s", + + "duration.minutes": "{sign}{value} minute{plural}{warn}", + "duration.hours": "{sign}{hours}h{warn}", + "duration.hours_minutes": "{sign}{hours}h{minutes}{warn}", + "duration.min_warning": " (\u26a0 min {value} \u26a0)", + "duration.max_warning": " (\u26a0 max {value} \u26a0)", + "duration.day_suffix": "d", + "duration.day_zero": "0{unit}" +} diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json new file mode 100644 index 0000000..ac86035 --- /dev/null +++ b/assets/i18n/fr.json @@ -0,0 +1,52 @@ +{ + "scanner.offline.title": "Hors service", + "scanner.offline.opening": "Ouverture du scanner...", + "scanner.wait_badge": "Veuillez présenter votre badge", + "scanner.mode.clock": "Mode de timbrage", + "scanner.mode.consultation": "Mode de consultation", + + "clock.success.title.in": "Entrée enregistrée", + "clock.success.title.out": "Sortie enregistrée", + "clock.success.greeting.morning": "Bonjour {firstname}", + "clock.success.greeting.evening": "Bonsoir {firstname}", + "clock.success.greeting.bye": "Au revoir {firstname}", + + "consultation.error.blocked.line1": "Des erreurs empêchent l'affichage correct des informations.", + "consultation.error.blocked.line2": "Veuillez vous adresser au secrétariat.", + "consultation.error.list_title": "\u26a0 Erreur{suffix}:", + "consultation.error.unknown_date": " \u2022 date inconnue: {description}", + + "consultation.summary.present": "\u2022 Présent: {value}", + "consultation.summary.prev_total_balance": "\u2022 Balance totale au jour précédent: {value}", + "consultation.summary.prev_month_balance": "\u2022 Balance du mois au jour précédent: {value}", + "consultation.summary.day_balance": "\u2022 Balance du jour: {day_balance} ({worked} / {scheduled})", + "consultation.summary.month_vacation": "\u2022 Vacances ce mois: {value}", + "consultation.summary.remaining_vacation": "\u2022 Vacances à planifier: {value}", + "consultation.summary.out_of_range": " \u26a0 Hors de la plage autorisée{range}, veuillez régulariser rapidement \u26a0", + "consultation.summary.warning_title": "\u26a0 Problème{suffix}:", + "consultation.summary.warning_item": " \u2022 {date}: {description}", + + "attendance_list.title": "Liste des présences", + "attendance_list.empty": "Il n'y a personne.", + "attendance_list.more_names": "+ {count} cachés...", + + "error.state.title": "Une erreur est survenue", + "error.state.subtitle": "Veuillez vous adresser au secrétariat", + "error.state.retry_later": "Veuillez réessayer ultérieurement", + + "services.smtp.offline": "\u26a0 Serveur SMTP \u26a0", + + "common.yes": "oui", + "common.no": "non", + "common.unavailable": "indisponible", + + "plural.s": "s", + + "duration.minutes": "{sign}{value} minute{plural}{warn}", + "duration.hours": "{sign}{hours}h{warn}", + "duration.hours_minutes": "{sign}{hours}h{minutes}{warn}", + "duration.min_warning": " (\u26a0 min. {value} \u26a0)", + "duration.max_warning": " (\u26a0 max. {value} \u26a0)", + "duration.day_suffix": "j", + "duration.day_zero": "0{unit}" +} diff --git a/src/common/i18n.py b/src/common/i18n.py new file mode 100644 index 0000000..fbc0438 --- /dev/null +++ b/src/common/i18n.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Internationalization helpers.""" + +from __future__ import annotations + +# Standard libraries +import json +import logging +from os.path import join +from typing import Any, Mapping, Optional + +# Internal libraries +from .singleton_register import SingletonRegister +from common.config_parser import ConfigError +from local_config import LocalConfig + +logger = logging.getLogger(__name__) + +_DEFAULT_LANGUAGE = "en" +_I18N_DIR = join("assets", "i18n") + +try: + _LOCAL_CONFIG = LocalConfig() +except ConfigError: + logger.exception("Unable to initialize LocalConfig while setting up i18n.") + _LOCAL_CONFIG = None + + +def _normalize_language(value: Optional[str]) -> str: + if not value: + return _DEFAULT_LANGUAGE + + normalized = value.replace("-", "_") + parts = normalized.split("_") + if parts and parts[0]: + return parts[0].lower() + return _DEFAULT_LANGUAGE + + +class Translator(SingletonRegister): + """Load translation catalogs and provide lookup helpers.""" + + def _setup( + self, + language: Optional[str] = None, + translations_path: str = _I18N_DIR, + fallback_language: str = _DEFAULT_LANGUAGE, + ) -> None: + self._translations_path = translations_path + self._fallback_language = fallback_language + + if language is None: + language = _DEFAULT_LANGUAGE + if _LOCAL_CONFIG is not None: + try: + language = _normalize_language( + _LOCAL_CONFIG.section("general")["locale"] + ) + except (KeyError, ConfigError): + logger.warning("Invalid or missing locale in local configuration. Defaulting to '%s'.", _DEFAULT_LANGUAGE) + + self._language = language or _DEFAULT_LANGUAGE + self._catalog = self._load_catalog(self._language) + + if not self._catalog and self._language != fallback_language: + logger.warning( + "No translations found for language '%s'. Falling back to '%s'.", + self._language, + fallback_language, + ) + self._language = fallback_language + self._catalog = self._load_catalog(self._language) + + self._fallback_catalog: Mapping[str, str] + if self._language == fallback_language: + self._fallback_catalog = {} + else: + self._fallback_catalog = self._load_catalog(fallback_language) + + def _load_catalog(self, language: str) -> Mapping[str, str]: + path = join(self._translations_path, f"{language}.json") + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except FileNotFoundError: + logger.warning("Translation file for language '%s' not found at '%s'.", language, path) + return {} + except json.JSONDecodeError as exc: + logger.error("Failed to parse translation file '%s': %s", path, exc) + return {} + + if not isinstance(data, dict): + logger.error("Invalid translation catalog '%s': expected a JSON object.", path) + return {} + + return {str(key): str(value) for key, value in data.items()} + + @property + def language(self) -> str: + return self._language + + def translate(self, key: str, **kwargs: Any) -> str: + template = self._catalog.get(key) + if template is None: + template = self._fallback_catalog.get(key) + if template is None: + logger.warning("Missing translation key '%s' for language '%s'.", key, self._language) + return key + + try: + return template.format(**kwargs) + except KeyError as exc: + logger.error("Missing placeholder %s for translation key '%s'.", exc, key) + return template + + def plural_suffix(self, count: int) -> str: + return "" if count == 1 else self.translate("plural.s") + + +def translate(key: str, **kwargs: Any) -> str: + """Translate a key using the configured translator.""" + + return Translator().translate(key, **kwargs) + + +def plural_suffix(count: int) -> str: + """Return the plural suffix for the active language.""" + + return Translator().plural_suffix(count) diff --git a/src/kivy_view/teambridge_view.py b/src/kivy_view/teambridge_view.py index c1c8470..25c3bec 100644 --- a/src/kivy_view/teambridge_view.py +++ b/src/kivy_view/teambridge_view.py @@ -64,6 +64,7 @@ from platform_io.sleep_manager import SleepManager from .view_theme import * from common.reporter import ReportingService, ReportSeverity, Report +from common.i18n import translate as _ # Set Kivy window icon Window.set_icon("assets/images/company_logo_small.png") @@ -357,7 +358,7 @@ def _upd_leave_time(self, value: Optional[float]): self.timeout_bar.program_timeout(value) def _upd_reporting_service_sts(self, status: bool): - self.services_panel_text = "" if status else "\u26a0 Serveur SMTP \u26a0" + self.services_panel_text = "" if status else _("services.smtp.offline") def _on_state_change(self, state: str): """ diff --git a/src/viewmodel/teambridge_viewmodel.py b/src/viewmodel/teambridge_viewmodel.py index 44cf576..2654a34 100644 --- a/src/viewmodel/teambridge_viewmodel.py +++ b/src/viewmodel/teambridge_viewmodel.py @@ -30,6 +30,7 @@ from common.state_machine import * from common.live_data import LiveData # For view communication from common.reporter import ReportingService, ReportSeverity, Report, EmployeeReport +from common.i18n import translate as _, plural_suffix from model import * # Task scheduling from platform_io.barcode_scanner import BarcodeScanner # Employee ID detection from core.time_tracker import ClockAction # Domain model enums @@ -305,8 +306,8 @@ def entry(self): Initial state entry: the barcode scanner is configured and opened. """ - self.fsm.main_title_text.value = "Hors service" - self.fsm.panel_title_text.value = "Ouverture du scanner..." + self.fsm.main_title_text.value = _("scanner.offline.title") + self.fsm.panel_title_text.value = _("scanner.offline.opening") self.fsm.scanner.configure( regex=_scanner_conf["regex"], @@ -398,8 +399,8 @@ class _WaitClockActionState(_ScanningState): def entry(self): super().entry() - self.fsm.main_title_text.value = "Veuillez présenter votre badge" - self.fsm.main_subtitle_text.value = "Mode de timbrage" + self.fsm.main_title_text.value = _("scanner.wait_badge") + self.fsm.main_subtitle_text.value = _("scanner.mode.clock") self.fsm.next_action = ViewModelAction.CLOCK_ACTION def do(self) -> Optional[IStateBehavior]: @@ -429,8 +430,8 @@ class _WaitConsultationActionState(_ScanningState): def entry(self): super().entry() - self.fsm.main_title_text.value = "Veuillez présenter votre badge" - self.fsm.main_subtitle_text.value = "Mode de consultation" + self.fsm.main_title_text.value = _("scanner.wait_badge") + self.fsm.main_subtitle_text.value = _("scanner.mode.consultation") self.fsm.next_action = ViewModelAction.CONSULTATION def do(self) -> Optional[IStateBehavior]: @@ -524,33 +525,20 @@ def exit(self): self.fsm.main_subtitle_text.value = "" def __main_title_text(self): - # Format text according to event - text = "" - # Greetings if self._evt.clock_evt.action == ClockAction.CLOCK_IN: - text = "Entrée " - else: - text = "Sortie " - # Clock action - text += "enregistrée" - # Return formatted text - return text + return _("clock.success.title.in") + return _("clock.success.title.out") def __main_subtitle_text(self): - # Format text according to event - text = "" - # Greetings if self._evt.clock_evt.action == ClockAction.CLOCK_IN: if self._evt.clock_evt.time.hour < 16: - text += "Bonjour" - else: - text += "Bonsoir" - else: - text += "Au revoir" - # Employee's firstname - text += f" {self._evt.firstname}" - # Return formatted text - return text + return _( + "clock.success.greeting.morning", firstname=self._evt.firstname + ) + return _( + "clock.success.greeting.evening", firstname=self._evt.firstname + ) + return _("clock.success.greeting.bye", firstname=self._evt.firstname) class _ConsultationActionState(_IViewModelState): @@ -685,8 +673,8 @@ def __panel_content_text(self): if data.dominant_error.status == AttendanceErrorStatus.ERROR: # Show the error panel lines = [ - "Des erreurs empêchent l'affichage correct des informations. ", - "Veuillez vous adresser au secrétariat.", + _("consultation.error.blocked.line1"), + _("consultation.error.blocked.line2"), "", ] @@ -697,10 +685,15 @@ def __panel_content_text(self): if error.status is AttendanceErrorStatus.ERROR } - lines.append(f"\u26a0 Erreur{"" if len(data.date_errors) == 1 else "s"}:") + suffix = plural_suffix(len(self._data.date_errors)) + lines.append(_("consultation.error.list_title", suffix=suffix)) lines.extend( [ - f" \u2022 {self._fmt_date(date)}: {err.description}" + _( + "consultation.summary.warning_item", + date=self._fmt_date(date), + description=err.description, + ) for date, err in self._data.date_errors.items() if err.status is AttendanceErrorStatus.ERROR ] @@ -709,7 +702,10 @@ def __panel_content_text(self): # The dominant error may not be in the scanned range if len(errors) == 0: lines.append( - f" \u2022 date inconnue: {data.dominant_error.description}" + _( + "consultation.error.unknown_date", + description=data.dominant_error.description, + ) ) else: # Extract and format @@ -725,8 +721,14 @@ def __panel_content_text(self): # Normal information panel lines = [ - f"\u2022 Présent: {'oui' if data.clocked_in else 'non'}", - f"\u2022 Balance totale au jour précédent: {yty_bal}", + _( + "consultation.summary.present", + value=_("common.yes") if data.clocked_in else _("common.no"), + ), + _( + "consultation.summary.prev_total_balance", + value=yty_bal, + ), ] # Add a warning line if balance is out of range @@ -744,27 +746,50 @@ def __panel_content_text(self): ) lines.append( - f" \u26a0 Hors de la plage autorisée{rng}, " - "veuillez régulariser rapidement \u26a0" + _( + "consultation.summary.out_of_range", + range=rng, + ) ) lines.extend( [ - f"\u2022 Balance du mois au jour précédent: {mty_bal}", - f"\u2022 Balance du jour: {day_bal} ({day_wtm} / {day_stm})", - f"\u2022 Vacances ce mois: {mth_vac}", - f"\u2022 Vacances à planifier: {rem_vac}", + _( + "consultation.summary.prev_month_balance", + value=mty_bal, + ), + _( + "consultation.summary.day_balance", + day_balance=day_bal, + worked=day_wtm, + scheduled=day_stm, + ), + _( + "consultation.summary.month_vacation", + value=mth_vac, + ), + _( + "consultation.summary.remaining_vacation", + value=rem_vac, + ), ] ) # Add errors if any if data.date_errors: lines.append( - f"\u26a0 Problème{"" if len(data.date_errors) == 1 else "s"}:" + _( + "consultation.summary.warning_title", + suffix=plural_suffix(len(data.date_errors)), + ) ) lines.extend( [ - f" \u2022 {self._fmt_date(date)}: {err.description}" + _( + "consultation.summary.warning_item", + date=self._fmt_date(date), + description=err.description, + ) for date, err in data.date_errors.items() ] ) @@ -778,14 +803,14 @@ def _fmt_dt( td_max: Optional[dt.timedelta] = None, ): if td is None: - return "indisponible" + return _("common.unavailable") # Check if a warning must be shown warn = "" if td_min and td < td_min: - warn = f" (\u26a0 min. {self._fmt_dt(td_min)} \u26a0)" + warn = _("duration.min_warning", value=self._fmt_dt(td_min)) if td_max and td > td_max: - warn = f" (\u26a0 max. {self._fmt_dt(td_max)} \u26a0)" + warn = _("duration.max_warning", value=self._fmt_dt(td_max)) total_minutes = int(td.total_seconds() // 60) sign = "-" if total_minutes < 0 else "" @@ -793,10 +818,25 @@ def _fmt_dt( hours, minutes = divmod(abs_minutes, 60) if hours == 0: - return f"{sign}{minutes} minute{"s" if minutes > 1 else ""}{warn}" + plural = "" if minutes == 1 else _("plural.s") + return _( + "duration.minutes", + sign=sign, + value=minutes, + plural=plural, + warn=warn, + ) elif minutes == 0: - return f"{sign}{hours}h{warn}" - return f"{sign}{hours}h{minutes:02}{warn}" + return _("duration.hours", sign=sign, hours=hours, warn=warn) + + minutes_str = f"{minutes:02}" + return _( + "duration.hours_minutes", + sign=sign, + hours=hours, + minutes=minutes_str, + warn=warn, + ) def _fmt_date(self, date: dt.date): return dt.date.strftime(date, "%d.%m.%Y") @@ -808,7 +848,9 @@ def _fmt_days(self, days: Optional[float]) -> str: E.g., 1.26 → '1j ¼', 0.48 → '½j', 1.93 → '2j' """ if days is None: - return "indisponible" + return _("common.unavailable") + + unit = _("duration.day_suffix") # Manual thresholds thresholds = [ @@ -831,13 +873,13 @@ def _fmt_days(self, days: Optional[float]) -> str: parts = [] if integer > 0: - parts.append(f"{integer}j") + parts.append(f"{integer}{unit}") if fraction_symbol: parts.append(fraction_symbol) elif fraction_symbol: - parts.append(f"{fraction_symbol}j") + parts.append(f"{fraction_symbol}{unit}") else: - parts.append("0j") + parts.append(_("duration.day_zero", unit=unit)) return " ".join(parts) @@ -894,7 +936,7 @@ def __init__(self, result: AttendanceList): self._font = ImageFont.truetype(ATTENDANCE_LIST_FONT, size=32) def entry(self): - self.fsm.panel_title_text.value = "Liste des présences" + self.fsm.panel_title_text.value = _("attendance_list.title") self.fsm.panel_content_text.value = self.__panel_content_text() self._timeout = time.time() + SHOW_ATTENDANCE_LIST_TIMEOUT @@ -951,12 +993,14 @@ def __panel_content_text(self): names = [self.__truncate(name, MAX_NAME_LENGTH) for name in names] if len(names) == 0: - names = ["Il n'y a personne."] + names = [_("attendance_list.empty")] max_names = MAX_COL_ENTRIES * MAX_COL_NUMBER if len(names) > max_names: # Replace the last name to show the list continues - names[max_names - 1] = f"+ {len(names) - max_names + 1} cachés..." + names[max_names - 1] = _( + "attendance_list.more_names", count=len(names) - max_names + 1 + ) ncols = min(ceil(len(names) / MAX_COL_ENTRIES), MAX_COL_NUMBER) @@ -1021,8 +1065,8 @@ def __init__(self, msg: str, error: Optional[ModelError] = None): self._timeout = None def entry(self): - self.fsm.main_title_text.value = "Une erreur est survenue" - self.fsm.main_subtitle_text.value = "Veuillez vous adresser au secrétariat" + self.fsm.main_title_text.value = _("error.state.title") + self.fsm.main_subtitle_text.value = _("error.state.subtitle") # Reset next action to prevent wrong acknowledgment self.fsm.next_action = ViewModelAction.DEFAULT_ACTION @@ -1095,7 +1139,7 @@ def do(self) -> Optional[IStateBehavior]: # Program the state timeout once a report has been sent # successfully self._timeout = time.time() + SHOW_ERROR_STATE_TIMEOUT - self.fsm.main_subtitle_text.value = "Veuillez réessayer ultérieurement" + self.fsm.main_subtitle_text.value = _("error.state.retry_later") self.fsm.leave_time.value = self._timeout if self._timeout and time.time() > self._timeout: