mirror of
https://github.com/zoriya/noctalia-shell.git
synced 2026-08-09 07:48:58 +00:00
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.
360 lines
11 KiB
QML
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)
|
|
}
|
|
}
|