From 978c15993ebe4e0d989402fb45fa99328f1525d5 Mon Sep 17 00:00:00 2001 From: phranck Date: Sat, 14 Feb 2026 18:15:33 +0100 Subject: [PATCH] Feat: Phase 1 i18n Infrastructure - LocalizationService and translation files - Add LocalizationService singleton for managing language selection - Implement XDG-compatible persistent language storage (macOS/Linux) - Create translation JSON files for 5 languages (EN, DE, FR, IT, ES) - Add LocalizationService to EnvironmentValues - Support dot-notation keys for string resolution - Implement fallback to English for missing keys - Update Package.swift to include translation resources - All 1069 tests pass --- Package.swift | 6 +- .../Environment/ServiceEnvironment.swift | 18 ++ .../Localization/LocalizationService.swift | 238 ++++++++++++++++++ .../TUIkit/Localization/translations/de.json | 60 +++++ .../TUIkit/Localization/translations/en.json | 60 +++++ .../TUIkit/Localization/translations/es.json | 60 +++++ .../TUIkit/Localization/translations/fr.json | 60 +++++ .../TUIkit/Localization/translations/it.json | 60 +++++ 8 files changed, 561 insertions(+), 1 deletion(-) create mode 100644 Sources/TUIkit/Localization/LocalizationService.swift create mode 100644 Sources/TUIkit/Localization/translations/de.json create mode 100644 Sources/TUIkit/Localization/translations/en.json create mode 100644 Sources/TUIkit/Localization/translations/es.json create mode 100644 Sources/TUIkit/Localization/translations/fr.json create mode 100644 Sources/TUIkit/Localization/translations/it.json diff --git a/Package.swift b/Package.swift index 0717cf40..6de41a2a 100644 --- a/Package.swift +++ b/Package.swift @@ -41,7 +41,11 @@ let package = Package( .target(name: "TUIkitImage", dependencies: ["CSTBImage", "TUIkitStyling"]), // ── High-level (aggregates all) ───────────────────────────────────────────────────────────────── - .target(name: "TUIkit", dependencies: ["TUIkitCore", "TUIkitStyling", "TUIkitImage", "TUIkitView"]), + .target( + name: "TUIkit", + dependencies: ["TUIkitCore", "TUIkitStyling", "TUIkitImage", "TUIkitView"], + resources: [.copy("Localization/translations")] + ), // ── App & Tests ───────────────────────────────────────────────────────────────────────────────── .executableTarget( diff --git a/Sources/TUIkit/Environment/ServiceEnvironment.swift b/Sources/TUIkit/Environment/ServiceEnvironment.swift index 896b8627..78f55d8d 100644 --- a/Sources/TUIkit/Environment/ServiceEnvironment.swift +++ b/Sources/TUIkit/Environment/ServiceEnvironment.swift @@ -4,6 +4,13 @@ // Created by LAYERED.work // License: MIT +// MARK: - Localization Service + +/// EnvironmentKey for the localization service. +private struct LocalizationServiceKey: EnvironmentKey { + static let defaultValue: LocalizationService = LocalizationService.shared +} + // MARK: - Lifecycle Manager /// EnvironmentKey for view lifecycle tracking (appear/disappear/task). @@ -57,6 +64,17 @@ private struct ActiveFocusSectionKey: EnvironmentKey { extension EnvironmentValues { + /// The localization service for retrieving translated strings. + var localizationService: LocalizationService { + get { self[LocalizationServiceKey.self] } + set { self[LocalizationServiceKey.self] = newValue } + } + + /// The currently active language. + var currentLanguage: LocalizationService.Language { + localizationService.currentLanguage + } + /// View lifecycle tracking (appear, disappear, task management). var lifecycle: LifecycleManager? { get { self[LifecycleKey.self] } diff --git a/Sources/TUIkit/Localization/LocalizationService.swift b/Sources/TUIkit/Localization/LocalizationService.swift new file mode 100644 index 00000000..6fbb58e7 --- /dev/null +++ b/Sources/TUIkit/Localization/LocalizationService.swift @@ -0,0 +1,238 @@ +// 🖥️ TUIKit — Terminal UI Kit for Swift +// LocalizationService.swift +// +// Created by LAYERED.work +// License: MIT + +import Foundation + +// MARK: - Localization Service + +/// Manages language selection and string localization. +/// +/// Loads translations from JSON files, resolves localized strings using +/// dot-notation keys, and persists language preference to disk. +public final class LocalizationService: @unchecked Sendable { + /// Supported languages + public enum Language: String, Codable { + case english = "en" + case german = "de" + case french = "fr" + case italian = "it" + case spanish = "es" + + /// Human-readable name + public var displayName: String { + switch self { + case .english: "English" + case .german: "Deutsch" + case .french: "Français" + case .italian: "Italiano" + case .spanish: "Español" + } + } + } + + /// The shared localization service instance. + public static let shared = LocalizationService() + + /// Currently active language + private(set) public var currentLanguage: Language { + didSet { + saveLanguagePreference(currentLanguage) + AppState.shared.setNeedsRender() + } + } + + /// Cached translations: [languageCode: [dotPath: localizedString]] + private var translationCache: [String: [String: String]] = [:] + + /// Lock for thread-safe access + private let lock = NSLock() + + /// Creates and initializes the localization service. + /// + /// Loads the stored language preference, falling back to system locale + /// or English if unavailable. + public init() { + self.currentLanguage = .english + + // Try to load stored preference + if let stored = Self.loadLanguagePreference() { + self.currentLanguage = stored + } else if let systemLocale = Self.systemPreferredLanguage() { + self.currentLanguage = systemLocale + } + + // Preload current language translations + _ = translations(for: currentLanguage) + } + + /// Changes the active language and persists the preference. + /// + /// - Parameter language: The new language to activate. + public func setLanguage(_ language: Language) { + lock.lock() + defer { lock.unlock() } + currentLanguage = language + } + + /// Resolves a localized string using a dot-notation key. + /// + /// Falls back to English if the key is missing in the current language, + /// then to the key itself if not found in English. + /// + /// - Parameter key: Dot-notation path (e.g., "button.ok", "error.invalid_input") + /// - Returns: The localized string, or the key if not found. + public func string(for key: String) -> String { + lock.lock() + defer { lock.unlock() } + + // Try current language + if let value = translationValue(key, in: currentLanguage) { + return value + } + + // Fall back to English + if currentLanguage != .english, + let value = translationValue(key, in: .english) { + return value + } + + // Return key as last resort + return key + } + + // MARK: - Private Helpers + + /// Gets translations dictionary for a language, loading from JSON if needed. + private func translations(for language: Language) -> [String: String] { + lock.lock() + defer { lock.unlock() } + + let code = language.rawValue + + // Return cached if available + if let cached = translationCache[code] { + return cached + } + + // Load from bundled JSON + if let loaded = loadTranslationsFromBundle(language: code) { + translationCache[code] = loaded + return loaded + } + + // No translations available, return empty + return [:] + } + + /// Retrieves a value from translations using dot-notation path. + private func translationValue(_ key: String, in language: Language) -> String? { + let trans = translations(for: language) + return trans[key] + } + + /// Loads translations from bundled JSON file. + private func loadTranslationsFromBundle(language: String) -> [String: String]? { + guard let url = Bundle.module.url( + forResource: language, + withExtension: "json", + subdirectory: "Localization/translations" + ) else { + return nil + } + + do { + let data = try Data(contentsOf: url) + let dict = try JSONSerialization.jsonObject( + with: data, + options: .fragmentsAllowed + ) as? [String: String] + return dict + } catch { + return nil + } + } + + /// Returns the system-preferred language if supported. + private static func systemPreferredLanguage() -> Language? { + let preferredLanguages = NSLocale.preferredLanguages + for langCode in preferredLanguages { + let base = langCode.prefix(2).lowercased() + if let language = Language(rawValue: base) { + return language + } + } + return nil + } + + /// Loads stored language preference from config file. + private static func loadLanguagePreference() -> Language? { + let path = configFilePath() + guard FileManager.default.fileExists(atPath: path) else { + return nil + } + + do { + let content = try String(contentsOfFile: path, encoding: .utf8).trimmingCharacters( + in: .whitespacesAndNewlines + ) + return Language(rawValue: content) + } catch { + return nil + } + } + + /// Saves language preference to config file. + private func saveLanguagePreference(_ language: Language) { + let path = Self.configFilePath() + let dirPath = (path as NSString).deletingLastPathComponent + + // Create directory if needed + do { + try FileManager.default.createDirectory( + atPath: dirPath, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + // Ignore creation errors, will fail on write anyway + } + + // Write language code + do { + try language.rawValue.write( + toFile: path, + atomically: true, + encoding: .utf8 + ) + } catch { + // Silently fail if write unsuccessful + } + } + + /// Returns the XDG-compatible config file path for language preference. + private static func configFilePath() -> String { + #if os(macOS) + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first?.path ?? NSHomeDirectory() + return (appSupport as NSString).appendingPathComponent("tuikit/language") + #else + let configHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"] + ?? ((NSHomeDirectory() as NSString).appendingPathComponent(".config")) + return (configHome as NSString).appendingPathComponent("tuikit/language") + #endif + } +} + +// MARK: - Convenience + +/// A localized string retrieved using a dot-notation key. +/// +/// Example: `LocalizedString("button.ok")` +public func LocalizedString(_ key: String) -> String { + LocalizationService.shared.string(for: key) +} diff --git a/Sources/TUIkit/Localization/translations/de.json b/Sources/TUIkit/Localization/translations/de.json new file mode 100644 index 00000000..993e73c2 --- /dev/null +++ b/Sources/TUIkit/Localization/translations/de.json @@ -0,0 +1,60 @@ +{ + "button.ok": "OK", + "button.cancel": "Abbrechen", + "button.yes": "Ja", + "button.no": "Nein", + "button.save": "Speichern", + "button.delete": "Löschen", + "button.close": "Schließen", + "button.apply": "Anwenden", + "button.reset": "Zurücksetzen", + "button.submit": "Absenden", + "button.search": "Suchen", + "button.clear": "Löschen", + "button.add": "Hinzufügen", + "button.remove": "Entfernen", + "button.edit": "Bearbeiten", + "button.done": "Fertig", + "button.next": "Weiter", + "button.previous": "Zurück", + "button.back": "Zurück", + "button.forward": "Vorwärts", + "button.refresh": "Aktualisieren", + + "label.search": "Suche", + "label.name": "Name", + "label.description": "Beschreibung", + "label.value": "Wert", + "label.status": "Status", + "label.error": "Fehler", + "label.warning": "Warnung", + "label.info": "Information", + "label.loading": "Lädt", + "label.empty": "Leer", + "label.none": "Keine", + + "error.invalid_input": "Ungültige Eingabe", + "error.required_field": "Dieses Feld ist erforderlich", + "error.not_found": "Nicht gefunden", + "error.access_denied": "Zugriff verweigert", + "error.network_error": "Netzwerkfehler", + "error.unknown": "Unbekannter Fehler", + "error.invalid_format": "Ungültiges Format", + "error.operation_failed": "Operation fehlgeschlagen", + + "placeholder.search": "Suchen...", + "placeholder.enter_text": "Text eingeben...", + "placeholder.enter_value": "Wert eingeben...", + "placeholder.select_option": "Option auswählen...", + "placeholder.enter_name": "Name eingeben...", + + "menu.file": "Datei", + "menu.edit": "Bearbeiten", + "menu.view": "Ansicht", + "menu.help": "Hilfe", + + "dialog.confirm": "Bestätigung", + "dialog.delete_confirmation": "Sind Sie sicher, dass Sie das löschen möchten?", + "dialog.unsaved_changes": "Sie haben ungespeicherte Änderungen. Möchten Sie diese speichern?", + "dialog.overwrite_confirmation": "Diese Datei existiert bereits. Möchten Sie sie überschreiben?" +} diff --git a/Sources/TUIkit/Localization/translations/en.json b/Sources/TUIkit/Localization/translations/en.json new file mode 100644 index 00000000..977fcbce --- /dev/null +++ b/Sources/TUIkit/Localization/translations/en.json @@ -0,0 +1,60 @@ +{ + "button.ok": "OK", + "button.cancel": "Cancel", + "button.yes": "Yes", + "button.no": "No", + "button.save": "Save", + "button.delete": "Delete", + "button.close": "Close", + "button.apply": "Apply", + "button.reset": "Reset", + "button.submit": "Submit", + "button.search": "Search", + "button.clear": "Clear", + "button.add": "Add", + "button.remove": "Remove", + "button.edit": "Edit", + "button.done": "Done", + "button.next": "Next", + "button.previous": "Previous", + "button.back": "Back", + "button.forward": "Forward", + "button.refresh": "Refresh", + + "label.search": "Search", + "label.name": "Name", + "label.description": "Description", + "label.value": "Value", + "label.status": "Status", + "label.error": "Error", + "label.warning": "Warning", + "label.info": "Info", + "label.loading": "Loading", + "label.empty": "Empty", + "label.none": "None", + + "error.invalid_input": "Invalid input", + "error.required_field": "This field is required", + "error.not_found": "Not found", + "error.access_denied": "Access denied", + "error.network_error": "Network error", + "error.unknown": "Unknown error", + "error.invalid_format": "Invalid format", + "error.operation_failed": "Operation failed", + + "placeholder.search": "Search...", + "placeholder.enter_text": "Enter text...", + "placeholder.enter_value": "Enter value...", + "placeholder.select_option": "Select an option...", + "placeholder.enter_name": "Enter name...", + + "menu.file": "File", + "menu.edit": "Edit", + "menu.view": "View", + "menu.help": "Help", + + "dialog.confirm": "Confirm", + "dialog.delete_confirmation": "Are you sure you want to delete this?", + "dialog.unsaved_changes": "You have unsaved changes. Do you want to save them?", + "dialog.overwrite_confirmation": "This file already exists. Do you want to overwrite it?" +} diff --git a/Sources/TUIkit/Localization/translations/es.json b/Sources/TUIkit/Localization/translations/es.json new file mode 100644 index 00000000..5aa9395b --- /dev/null +++ b/Sources/TUIkit/Localization/translations/es.json @@ -0,0 +1,60 @@ +{ + "button.ok": "OK", + "button.cancel": "Cancelar", + "button.yes": "Sí", + "button.no": "No", + "button.save": "Guardar", + "button.delete": "Eliminar", + "button.close": "Cerrar", + "button.apply": "Aplicar", + "button.reset": "Restablecer", + "button.submit": "Enviar", + "button.search": "Buscar", + "button.clear": "Limpiar", + "button.add": "Añadir", + "button.remove": "Eliminar", + "button.edit": "Editar", + "button.done": "Hecho", + "button.next": "Siguiente", + "button.previous": "Anterior", + "button.back": "Atrás", + "button.forward": "Adelante", + "button.refresh": "Actualizar", + + "label.search": "Buscar", + "label.name": "Nombre", + "label.description": "Descripción", + "label.value": "Valor", + "label.status": "Estado", + "label.error": "Error", + "label.warning": "Advertencia", + "label.info": "Información", + "label.loading": "Cargando", + "label.empty": "Vacío", + "label.none": "Ninguno", + + "error.invalid_input": "Entrada no válida", + "error.required_field": "Este campo es obligatorio", + "error.not_found": "No encontrado", + "error.access_denied": "Acceso denegado", + "error.network_error": "Error de red", + "error.unknown": "Error desconocido", + "error.invalid_format": "Formato no válido", + "error.operation_failed": "Operación fallida", + + "placeholder.search": "Buscar...", + "placeholder.enter_text": "Ingrese texto...", + "placeholder.enter_value": "Ingrese valor...", + "placeholder.select_option": "Seleccione una opción...", + "placeholder.enter_name": "Ingrese nombre...", + + "menu.file": "Archivo", + "menu.edit": "Editar", + "menu.view": "Ver", + "menu.help": "Ayuda", + + "dialog.confirm": "Confirmar", + "dialog.delete_confirmation": "¿Está seguro de que desea eliminar esto?", + "dialog.unsaved_changes": "Tiene cambios sin guardar. ¿Desea guardarlos?", + "dialog.overwrite_confirmation": "Este archivo ya existe. ¿Desea sobrescribirlo?" +} diff --git a/Sources/TUIkit/Localization/translations/fr.json b/Sources/TUIkit/Localization/translations/fr.json new file mode 100644 index 00000000..d0373742 --- /dev/null +++ b/Sources/TUIkit/Localization/translations/fr.json @@ -0,0 +1,60 @@ +{ + "button.ok": "OK", + "button.cancel": "Annuler", + "button.yes": "Oui", + "button.no": "Non", + "button.save": "Enregistrer", + "button.delete": "Supprimer", + "button.close": "Fermer", + "button.apply": "Appliquer", + "button.reset": "Réinitialiser", + "button.submit": "Soumettre", + "button.search": "Rechercher", + "button.clear": "Effacer", + "button.add": "Ajouter", + "button.remove": "Supprimer", + "button.edit": "Modifier", + "button.done": "Fait", + "button.next": "Suivant", + "button.previous": "Précédent", + "button.back": "Retour", + "button.forward": "Avancer", + "button.refresh": "Actualiser", + + "label.search": "Recherche", + "label.name": "Nom", + "label.description": "Description", + "label.value": "Valeur", + "label.status": "Statut", + "label.error": "Erreur", + "label.warning": "Avertissement", + "label.info": "Information", + "label.loading": "Chargement", + "label.empty": "Vide", + "label.none": "Aucun", + + "error.invalid_input": "Entrée invalide", + "error.required_field": "Ce champ est obligatoire", + "error.not_found": "Non trouvé", + "error.access_denied": "Accès refusé", + "error.network_error": "Erreur réseau", + "error.unknown": "Erreur inconnue", + "error.invalid_format": "Format invalide", + "error.operation_failed": "Opération échouée", + + "placeholder.search": "Rechercher...", + "placeholder.enter_text": "Entrez le texte...", + "placeholder.enter_value": "Entrez la valeur...", + "placeholder.select_option": "Sélectionnez une option...", + "placeholder.enter_name": "Entrez le nom...", + + "menu.file": "Fichier", + "menu.edit": "Modifier", + "menu.view": "Affichage", + "menu.help": "Aide", + + "dialog.confirm": "Confirmer", + "dialog.delete_confirmation": "Êtes-vous sûr de vouloir supprimer ceci?", + "dialog.unsaved_changes": "Vous avez des modifications non enregistrées. Voulez-vous les enregistrer?", + "dialog.overwrite_confirmation": "Ce fichier existe déjà. Voulez-vous le remplacer?" +} diff --git a/Sources/TUIkit/Localization/translations/it.json b/Sources/TUIkit/Localization/translations/it.json new file mode 100644 index 00000000..d4908f0b --- /dev/null +++ b/Sources/TUIkit/Localization/translations/it.json @@ -0,0 +1,60 @@ +{ + "button.ok": "OK", + "button.cancel": "Annulla", + "button.yes": "Sì", + "button.no": "No", + "button.save": "Salva", + "button.delete": "Elimina", + "button.close": "Chiudi", + "button.apply": "Applica", + "button.reset": "Ripristina", + "button.submit": "Invia", + "button.search": "Cerca", + "button.clear": "Cancella", + "button.add": "Aggiungi", + "button.remove": "Rimuovi", + "button.edit": "Modifica", + "button.done": "Fatto", + "button.next": "Avanti", + "button.previous": "Indietro", + "button.back": "Indietro", + "button.forward": "Avanti", + "button.refresh": "Aggiorna", + + "label.search": "Ricerca", + "label.name": "Nome", + "label.description": "Descrizione", + "label.value": "Valore", + "label.status": "Stato", + "label.error": "Errore", + "label.warning": "Avvertenza", + "label.info": "Informazione", + "label.loading": "Caricamento", + "label.empty": "Vuoto", + "label.none": "Nessuno", + + "error.invalid_input": "Input non valido", + "error.required_field": "Questo campo è obbligatorio", + "error.not_found": "Non trovato", + "error.access_denied": "Accesso negato", + "error.network_error": "Errore di rete", + "error.unknown": "Errore sconosciuto", + "error.invalid_format": "Formato non valido", + "error.operation_failed": "Operazione non riuscita", + + "placeholder.search": "Cerca...", + "placeholder.enter_text": "Inserisci testo...", + "placeholder.enter_value": "Inserisci valore...", + "placeholder.select_option": "Seleziona un'opzione...", + "placeholder.enter_name": "Inserisci nome...", + + "menu.file": "File", + "menu.edit": "Modifica", + "menu.view": "Visualizza", + "menu.help": "Aiuto", + + "dialog.confirm": "Conferma", + "dialog.delete_confirmation": "Sei sicuro di voler eliminare questo?", + "dialog.unsaved_changes": "Hai modifiche non salvate. Vuoi salvarle?", + "dialog.overwrite_confirmation": "Questo file esiste già. Vuoi sovrascriverlo?" +}