Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions assets/i18n/en.json
Original file line number Diff line number Diff line change
@@ -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}"
}
52 changes: 52 additions & 0 deletions assets/i18n/fr.json
Original file line number Diff line number Diff line change
@@ -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}"
}
129 changes: 129 additions & 0 deletions src/common/i18n.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion src/kivy_view/teambridge_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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):
"""
Expand Down
Loading