Files
noctalia-shell/Commons/I18n.qml
T
loner 63bd97e76f feat(i18n): Implement and refine language selection feature
Introduces a language selection option in settings, allowing users to manually choose an application language or revert to automatic system locale detection. This includes UI updates, settings integration, and improved language detection logic.
2025-10-19 01:29:39 +08:00

360 lines
11 KiB
QML

pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Singleton {
id: root
property bool isLoaded: false
property string langCode: ""
property string systemDetectedLangCode: ""
property var availableLanguages: []
property var translations: ({})
property var fallbackTranslations: ({})
// Signals for reactive updates
signal languageChanged(string newLanguage)
signal translationsLoaded
// Process to list directory contents
property Process directoryScanner: Process {
id: directoryProcess
command: ["ls", `${Quickshell.shellDir}/Assets/Translations/`]
running: false
stdout: StdioCollector {
id: stdoutCollector
}
onExited: function (exitCode, exitStatus) {
if (exitCode === 0) {
var output = stdoutCollector.text || ""
parseDirectoryListing(output)
} else {
Logger.e("I18n", `Failed to scan translation directory`)
// Fallback to default languages
availableLanguages = ["en"]
detectLanguage()
}
}
}
// FileView to load translation files
property FileView translationFile: FileView {
id: fileView
watchChanges: true
onFileChanged: reload()
onLoaded: {
try {
var data = JSON.parse(text())
root.translations = data
Logger.i("I18n", `Loaded translations for "${root.langCode}"`)
Logger.d("I18n", `Available root keys: ${Object.keys(data).join(", ")}`)
root.isLoaded = true
root.translationsLoaded()
} catch (e) {
Logger.e("I18n", `Failed to parse translation file: ${e}`)
setLanguage("en")
}
}
onLoadFailed: function (error) {
setLanguage("en")
Logger.e("I18n", `Failed to load translation file: ${error}`)
}
}
// FileView to load fallback translation files
property FileView fallbackTranslationFile: FileView {
id: fallbackFileView
watchChanges: true
onFileChanged: reload()
onLoaded: {
try {
var data = JSON.parse(text())
root.fallbackTranslations = data
Logger.d("I18n", `Loaded english fallback translations`)
} catch (e) {
Logger.e("I18n", `Failed to parse fallback translation file: ${e}`)
}
}
onLoadFailed: function (error) {
Logger.e("I18n", `Failed to load fallback translation file: ${error}`)
}
}
Component.onCompleted: {
Logger.i("I18n", "Service started")
scanAvailableLanguages()
}
// -------------------------------------------
function scanAvailableLanguages() {
Logger.d("I18n", "Scanning for available translation files...")
directoryScanner.running = true
}
// -------------------------------------------
function parseDirectoryListing(output) {
var languages = []
try {
if (!output || output.trim() === "") {
Logger.w("I18n", "Empty directory listing output")
availableLanguages = ["en"]
detectLanguage()
return
}
const entries = output.trim().split('\n')
for (var i = 0; i < entries.length; i++) {
const entry = entries[i].trim()
if (entry && entry.endsWith('.json')) {
// Extract language code from filename (e.g., "en.json" -> "en")
const langCode = entry.substring(0, entry.lastIndexOf('.json'))
if (langCode.length >= 2 && langCode.length <= 5) {
// Basic validation for language codes
languages.push(langCode)
}
}
}
// Sort languages alphabetically, but ensure "en" comes first if available
languages.sort()
const enIndex = languages.indexOf("en")
if (enIndex > 0) {
languages.splice(enIndex, 1)
languages.unshift("en")
}
if (languages.length === 0) {
Logger.w("I18n", "No translation files found, using fallback")
languages = ["en"] // Fallback
}
availableLanguages = languages
Logger.d("I18n", `Found ${languages.length} available languages: ${languages.join(', ')}`)
// Detect language after scanning
detectLanguage()
} catch (e) {
Logger.e("I18n", `Failed to parse directory listing: ${e}`)
// Fallback to default languages
availableLanguages = ["en"]
detectLanguage()
}
}
// -------------------------------------------
function detectLanguage() {
Logger.d("I18n", `detectLanguage() called. Available languages: [${availableLanguages.join(', ')}]`)
if (availableLanguages.length === 0) {
Logger.w("I18n", "No available languages found")
return
}
var detectedLang = ""
// First, determine the system's preferred language
for (var i = 0; i < Qt.locale().uiLanguages.length; i++) {
const fullUserLang = Qt.locale().uiLanguages[i]
if (availableLanguages.includes(fullUserLang)) {
detectedLang = fullUserLang
break
}
const shortUserLang = fullUserLang.substring(0, 2)
if (availableLanguages.includes(shortUserLang)) {
detectedLang = shortUserLang
break
}
}
// If no system language is found among available languages, fallback
if (detectedLang === "") {
detectedLang = availableLanguages.includes("en") ? "en" : availableLanguages[0]
}
root.systemDetectedLangCode = detectedLang
Logger.d("I18n", `System detected language: "${root.systemDetectedLangCode}"`)
// Now, apply the language: user-defined, then system-detected
if (Settings.data.general.language !== "" && availableLanguages.includes(Settings.data.general.language)) {
Logger.d("I18n", `User-defined language found: "${Settings.data.general.language}"`)
setLanguage(Settings.data.general.language)
} else {
Logger.d("I18n", `No user-defined language, using system detected: "${root.systemDetectedLangCode}"`)
setLanguage(root.systemDetectedLangCode)
}
}
// -------------------------------------------
function setLanguage(newLangCode) {
if (newLangCode !== langCode && availableLanguages.includes(newLangCode)) {
langCode = newLangCode
Logger.i("I18n", `Language set to "${langCode}"`)
languageChanged(langCode)
loadTranslations()
} else if (!availableLanguages.includes(newLangCode)) {
Logger.w("I18n", `Language "${newLangCode}" is not available`)
}
}
// -------------------------------------------
function loadTranslations() {
if (langCode === "")
return
const filePath = `file://${Quickshell.shellDir}/Assets/Translations/${langCode}.json`
fileView.path = filePath
isLoaded = false
Logger.d("I18n", `Loading translations: ${langCode}`)
// Only load fallback translations if we are not using english and english is available
if (langCode !== "en" && availableLanguages.includes("en")) {
fallbackFileView.path = `file://${Quickshell.shellDir}/Assets/Translations/en.json`
}
}
// -------------------------------------------
// Check if a translation exists
function hasTranslation(key) {
if (!isLoaded)
return false
const keys = key.split(".")
var value = translations
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === "object" && keys[i] in value) {
value = value[keys[i]]
} else {
return false
}
}
return typeof value === "string"
}
// -------------------------------------------
// Get all translation keys (useful for debugging)
function getAllKeys(obj, prefix) {
if (typeof obj === "undefined")
obj = translations
if (typeof prefix === "undefined")
prefix = ""
var keys = []
for (var key in (obj || {})) {
const value = obj[key]
const fullKey = prefix ? `${prefix}.${key}` : key
if (typeof value === "object" && value !== null) {
keys = keys.concat(getAllKeys(value, fullKey))
} else if (typeof value === "string") {
keys.push(fullKey)
}
}
return keys
}
// -------------------------------------------
// Reload translations (useful for development)
function reload() {
Logger.d("I18n", "Reloading translations")
loadTranslations()
}
// -------------------------------------------
// Main translation function
function tr(key, interpolations) {
if (typeof interpolations === "undefined")
interpolations = {}
if (!isLoaded) {
Logger.d("I18n", "Translations not loaded yet")
return key
}
// Navigate nested keys (e.g., "menu.file.open")
const keys = key.split(".")
// Look-up translation in the active language
var value = translations
var notFound = false
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === "object" && keys[i] in value) {
value = value[keys[i]]
} else {
Logger.d("I18n", `Translation key "${key}" not found at part "${keys[i]}"`)
Logger.d("I18n", `Available keys: ${Object.keys(value || {}).join(", ")}`)
notFound = true
break
}
}
// Fallback to english if not found
if (notFound && availableLanguages.includes("en") && langCode !== "en") {
value = fallbackTranslations
for (var i = 0; i < keys.length; i++) {
if (value && typeof value === "object" && keys[i] in value) {
value = value[keys[i]]
} else {
// Indicate this key does not even exists in the english fallback
return `## ${key} ##`
}
}
// Make untranslated string easy to spot
value = `<i>${value}</i>`
} else if (notFound) {
// No fallback available
return `## ${key} ##`
}
if (typeof value !== "string") {
Logger.d("I18n", `Translation key "${key}" is not a string`)
return key
}
// Handle interpolations (e.g., "Hello {name}!")
var result = value
for (var placeholder in interpolations) {
const regex = new RegExp(`\\{${placeholder}\\}`, 'g')
result = result.replace(regex, interpolations[placeholder])
}
return result
}
// -------------------------------------------
// Plural translation function
function trp(key, count, defaultSingular, defaultPlural, interpolations) {
if (typeof defaultSingular === "undefined")
defaultSingular = ""
if (typeof defaultPlural === "undefined")
defaultPlural = ""
if (typeof interpolations === "undefined")
interpolations = {}
const pluralKey = count === 1 ? key : `${key}_plural`
const defaultValue = count === 1 ? defaultSingular : defaultPlural
// Merge interpolations with count (QML doesn't support spread operator)
var finalInterpolations = {
"count": count
}
for (var prop in interpolations) {
finalInterpolations[prop] = interpolations[prop]
}
return tr(pluralKey, finalInterpolations)
}
}