mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
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
This commit is contained in:
+5
-1
@@ -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(
|
||||
|
||||
@@ -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] }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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?"
|
||||
}
|
||||
@@ -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?"
|
||||
}
|
||||
@@ -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?"
|
||||
}
|
||||
@@ -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?"
|
||||
}
|
||||
@@ -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?"
|
||||
}
|
||||
Reference in New Issue
Block a user