From 1dd4c4a109822330792c8cc00b3470ca2cbf983e Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Tue, 14 Oct 2025 18:50:01 +0200 Subject: [PATCH] ai-generated ui translations --- .../ii/modules/common/Directories.qml | 1 + .../ii/modules/settings/GeneralConfig.qml | 35 +++- .../ii/scripts/ai/gemini-translate.sh | 65 ++++++++ .../quickshell/ii/services/Translation.qml | 152 +++++++++++------- .../translations/tools/translation-manager.py | 4 +- .config/quickshell/ii/welcome.qml | 71 ++++++-- 6 files changed, 254 insertions(+), 74 deletions(-) create mode 100755 .config/quickshell/ii/scripts/ai/gemini-translate.sh diff --git a/.config/quickshell/ii/modules/common/Directories.qml b/.config/quickshell/ii/modules/common/Directories.qml index 9b113de8d..94821e857 100644 --- a/.config/quickshell/ii/modules/common/Directories.qml +++ b/.config/quickshell/ii/modules/common/Directories.qml @@ -41,6 +41,7 @@ Singleton { property string defaultAiPrompts: Quickshell.shellPath("defaults/ai/prompts") property string userAiPrompts: FileUtils.trimFileProtocol(`${Directories.shellConfig}/ai/prompts`) property string aiChats: FileUtils.trimFileProtocol(`${Directories.state}/user/ai/chats`) + property string aiTranslationScriptPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/ai/gemini-translate.sh`) // Cleanup on init Component.onCompleted: { Quickshell.execDetached(["mkdir", "-p", `${shellConfig}`]) diff --git a/.config/quickshell/ii/modules/settings/GeneralConfig.qml b/.config/quickshell/ii/modules/settings/GeneralConfig.qml index 0794aef6e..565758468 100644 --- a/.config/quickshell/ii/modules/settings/GeneralConfig.qml +++ b/.config/quickshell/ii/modules/settings/GeneralConfig.qml @@ -1,5 +1,6 @@ import QtQuick import Quickshell +import Quickshell.Io import QtQuick.Layouts import qs.services import qs.modules.common @@ -8,6 +9,12 @@ import qs.modules.common.widgets ContentPage { forceWidth: true + Process { + id: translationProc + property string locale: "" + command: [Directories.aiTranslationScriptPath, translationProc.locale] + } + ContentSection { icon: "volume_up" title: Translation.tr("Audio") @@ -126,15 +133,39 @@ ContentPage { displayName: Translation.tr("Auto (System)"), value: "auto" }, - ...Translation.availableLanguages.map(lang => { + ...Translation.allAvailableLanguages.map(lang => { return { - displayName: lang.replace('_', '-'), + displayName: lang, value: lang }; }) ] } } + ContentSubsection { + title: Translation.tr("Generate translation with Gemini") + + ConfigRow { + MaterialTextArea { + id: localeInput + Layout.fillWidth: true + placeholderText: Translation.tr("Locale code, e.g. fr_FR, de_DE, zh_CN...") + text: Config.options.language.ui === "auto" ? Qt.locale().name : Config.options.language.ui + } + RippleButtonWithIcon { + id: generateTranslationBtn + Layout.fillHeight: true + nerdIcon: "" + enabled: !translationProc.running || (translationProc.locale !== localeInput.text.trim()) + mainText: enabled ? Translation.tr("Generate\nTypically takes 2 minutes") : Translation.tr("Generating...\nDon't close this window!") + onClicked: { + translationProc.locale = localeInput.text.trim(); + translationProc.running = false; + translationProc.running = true; + } + } + } + } } ContentSection { diff --git a/.config/quickshell/ii/scripts/ai/gemini-translate.sh b/.config/quickshell/ii/scripts/ai/gemini-translate.sh new file mode 100755 index 000000000..1425aa5da --- /dev/null +++ b/.config/quickshell/ii/scripts/ai/gemini-translate.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +if [[ -z "$1" ]]; then + echo "Usage: $0 [model]" + exit 1 +fi + +# Variables +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" +SHELL_CONFIG_DIR="$XDG_CONFIG_HOME/illogical-impulse" +SHELL_CONFIG_FILE="${SHELL_CONFIG_DIR}/config.json" +TRANSLATIONS_DIR="${SCRIPT_DIR}/../../translations" +TRANSLATIONS_TARGET_DIR="${SHELL_CONFIG_DIR}/translations" +SOURCE_LOCALE="en_US" +NOTIFICATION_APP_NAME="Shell" +TARGET_LOCALE="$1" +MODEL="${2:-${GEMINI_MODEL:-gemini-2.5-flash}}" + +# Update the source keys for translation +"${TRANSLATIONS_DIR}/tools/manage-translations.sh" update -l "$SOURCE_LOCALE" --yes +mkdir -p "$TRANSLATIONS_TARGET_DIR" + +# Construct the prompt string +instruction='You are to translate the user interface of a **desktop shell**. Given a JSON object of key-value pairs, return a JSON with the same structure, with keys unchanged and values translated to '"$TARGET_LOCALE"'. Be as **concise** as possible to save screen space, and make sure terminology is relevant (e.g. "discharging" refers to the battery status).' +content=$(cat "${TRANSLATIONS_DIR}/en_US.json") +prompt_json=$(jq -n --arg prompt_text "$instruction" --arg content "$content" '$prompt_text + "\n```\n" + $content + "\n```\n"') + +# Prepare request data using jq +payload=$(jq -n \ + --arg prompt "$prompt_json" \ + --arg temperature "0" \ + --arg model "$MODEL" \ + '{ + contents: [{ + parts: [ + {text: $prompt} + ] + }], + generationConfig: { + temperature: ($temperature | tonumber), + "responseMimeType": "application/json", + } + }' +) +# echo "$payload" | jq + +# Get API key +API_KEY=$(secret-tool lookup 'application' 'illogical-impulse' | jq -r '.apiKeys.gemini') + +# Notify start +notify-send "Translation started" "Will take 2 minutes, and you'll be notified when it's done, so feel free to do something else in the meantime." -a "$NOTIFICATION_APP_NAME" + +# Make the request +response=$(curl "https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent" \ +-H "x-goog-api-key: $API_KEY" \ +-H 'Content-Type: application/json' \ +-X POST \ +-d "$payload" 2> /dev/null) +# echo "$response" | jq + +# Write the result +echo "$response" | jq -r '.candidates[0].content.parts[0].text' > "${TRANSLATIONS_TARGET_DIR}/${TARGET_LOCALE}.json" +jq --arg locale "$TARGET_LOCALE" '.language.ui = $locale' "$SHELL_CONFIG_FILE" > "${SHELL_CONFIG_FILE}.tmp" && mv "${SHELL_CONFIG_FILE}.tmp" "$SHELL_CONFIG_FILE" +notify-send "Translation complete" "Enjoy! In case you wanna refine it, the file is in ${TRANSLATIONS_TARGET_DIR}/${TARGET_LOCALE}.json" -a "$NOTIFICATION_APP_NAME" diff --git a/.config/quickshell/ii/services/Translation.qml b/.config/quickshell/ii/services/Translation.qml index d06075ed3..aff9d69a3 100644 --- a/.config/quickshell/ii/services/Translation.qml +++ b/.config/quickshell/ii/services/Translation.qml @@ -10,11 +10,18 @@ Singleton { id: root property var translations: ({}) + property var generatedTranslations: ({}) property var availableLanguages: ["en_US"] + property var availableGeneratedLanguages: [] + property var allAvailableLanguages: { + const combined = new Set([...root.availableLanguages, ...root.availableGeneratedLanguages]); + return Array.from(combined).sort(); + } property bool isScanning: scanLanguagesProcess.running property bool isLoading: false property string translationKeepSuffix: "/*keep*/" - property string translationsPath: Quickshell.shellPath("translations") + property string translationsDir: Quickshell.shellPath("translations") + property string generatedTranslationsDir: Directories.shellConfig + "/translations" property string languageCode: { var configLang = Config?.options.language.ui ?? "auto"; @@ -25,80 +32,117 @@ Singleton { return Qt.locale().name; } - Process { + TranslationScanner { id: scanLanguagesProcess - command: ["find", root.translationsPath, "-name", "*.json", "-exec", "basename", "{}", ".json", ";"] - running: true - - stdout: SplitParser { - onRead: data => { - if (data.trim().length === 0) - return; - var files = data.trim().split('\n'); - - for (var i = 0; i < files.length; i++) { - var lang = files[i].trim(); - if (lang.length > 0 && root.availableLanguages.indexOf(lang) === -1) { - root.availableLanguages.push(lang); - } - } - } + translationsDir: root.translationsDir + onLanguagesScanned: (languages) => { + root.availableLanguages = [...languages]; } + } - onExited: (exitCode, exitStatus) => { - root.availableLanguages = [...root.availableLanguages] // Forcibly emit change - - if (exitCode !== 0) { - root.availableLanguages = ["en_US"]; - } - // TODO: notify and offer to translate when translation not available + TranslationScanner { + id: scanGeneratedLanguagesProcess + translationsDir: root.generatedTranslationsDir + onLanguagesScanned: (languages) => { + root.availableGeneratedLanguages = [...languages]; } } onLanguageCodeChanged: { - translationFileView.reload(); + print("[Translation] Language changed to", root.languageCode); + translationFileView.languageCode = root.languageCode; + generatedTranslationFileView.languageCode = root.languageCode; + translationFileView.reread(); + generatedTranslationFileView.reread(); } - FileView { + TranslationReader { id: translationFileView - path: root.languageCode?.length > 0 ? Qt.resolvedUrl(`${root.translationsPath}/${root.languageCode}.json`) : "" + translationsDir: root.translationsDir + languageCode: root.languageCode + onContentLoaded: (data) => { + root.translations = data; + root.isLoading = false; + } + } + + TranslationReader { + id: generatedTranslationFileView + translationsDir: root.generatedTranslationsDir + languageCode: root.languageCode + onContentLoaded: (data) => { + root.generatedTranslations = data; + root.isLoading = false; + } + } + + function tr(text) { + // Special cases + if (!text) return ""; + var key = text.toString(); + if (root.isLoading || (!root.translations.hasOwnProperty(key) && !root.generatedTranslations.hasOwnProperty(key))) + return key; + + // Normal cases + var translation = root.translations[key] || root.generatedTranslations[key] || key; + // print(key, "-> [", root.translations[key], root.generatedTranslations[key], key, "] ->", translation); + if (translation.endsWith(root.translationKeepSuffix)) { + translation = translation.substring(0, translation.length - root.translationKeepSuffix.length).trim(); + } + return translation; + } + + component TranslationScanner: Process { + id: translationScanner + required property string translationsDir + signal languagesScanned(var languages) + + command: ["find", translationScanner.translationsDir, "-name", "*.json", "-exec", "basename", "{}", ".json", ";"] + running: true + + stdout: StdioCollector { + id: languagesCollector + onStreamFinished: { + const output = languagesCollector.text; + const files = output.trim().split('\n').map(f => f.trim()); + translationScanner.languagesScanned(files); + } + } + + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + translationScanner.languagesScanned(["en_US"]); + } + } + } + + component TranslationReader: FileView { + id: translationReader + required property string translationsDir + property string languageCode: root.languageCode + signal contentLoaded(var data) + + function reread() { // Proper reload in case the file was incorrect before + print("rereading translations for", translationReader.languageCode); + translationReader.path = ""; + translationReader.path = `${translationReader.translationsDir}/${translationReader.languageCode}.json`; + translationReader.reload(); + } + path: "" onLoaded: { var textContent = ""; try { textContent = text(); var jsonData = JSON.parse(textContent); - root.translations = jsonData; + translationReader.contentLoaded(jsonData); } catch (e) { console.log("[Translation] Failed to load translations:", e); - root.translations = {}; + translationReader.contentLoaded({}); } - root.isLoading = false; } onLoadFailed: error => { - root.translations = {}; - root.isLoading = false; + translationReader.contentLoaded({}); } } - - function tr(text) { - if (!text) - return ""; - var key = text.toString(); - if (root.isLoading) - return key; - - if (root.translations.hasOwnProperty(key)) { - var translation = root.translations[key].toString().trim(); - if (translation.length === 0) - return key; - - if (translation.endsWith(root.translationKeepSuffix)) { - translation = translation.substring(0, translation.length - root.translationKeepSuffix.length).trim(); - } - return translation; - } - - return key; // Fallback to key name - } } diff --git a/.config/quickshell/ii/translations/tools/translation-manager.py b/.config/quickshell/ii/translations/tools/translation-manager.py index c7ba78dbd..902fa9b39 100755 --- a/.config/quickshell/ii/translations/tools/translation-manager.py +++ b/.config/quickshell/ii/translations/tools/translation-manager.py @@ -216,8 +216,8 @@ def main(): default=".config/quickshell/translations", help="Translation files directory (default: .config/quickshell/translations)") parser.add_argument("--source-dir", "-s", - default=".config/quickshell", - help="Source code directory (default: .config/quickshell)") + default=".config/quickshell/ii", + help="Source code directory (default: .config/quickshell/ii)") parser.add_argument("--language", "-l", help="Specify language code to process (e.g., zh_CN)") parser.add_argument("--extract-only", "-e", action="store_true", diff --git a/.config/quickshell/ii/welcome.qml b/.config/quickshell/ii/welcome.qml index 78b1e2543..4cd6129c6 100644 --- a/.config/quickshell/ii/welcome.qml +++ b/.config/quickshell/ii/welcome.qml @@ -53,6 +53,12 @@ ApplicationWindow { } } + Process { + id: translationProc + property string locale: "" + command: [Directories.aiTranslationScriptPath, translationProc.locale] + } + ColumnLayout { anchors { fill: parent @@ -135,23 +141,56 @@ ApplicationWindow { icon: "language" title: Translation.tr("Language") - ConfigSelectionArray { - id: languageSelector - currentValue: Config.options.language.ui - onSelected: newValue => { - Config.options.language.ui = newValue; + ContentSubsection { + title: Translation.tr("Select language") + ConfigSelectionArray { + id: languageSelector + currentValue: Config.options.language.ui + onSelected: newValue => { + Config.options.language.ui = newValue; + } + options: [ + { + displayName: Translation.tr("Auto (System)"), + value: "auto" + }, + ...Translation.allAvailableLanguages.map(lang => { + return { + displayName: lang, + value: lang + }; + })] + } + } + + NoticeBox { + Layout.fillWidth: true + text: Translation.tr("Language not listed or incomplete translations?\nYou can choose to generate translations for it with Gemini.\n1. Open the left sidebar with Super+A, set model to Gemini (if it isn't already)\n2. Type /key, hit Enter and follow the instructions\n3. Type /key YOUR_API_KEY\n4. Type the locale of your language below and press Generate") + } + + ContentSubsection { + title: Translation.tr("Generate translation with Gemini") + + ConfigRow { + MaterialTextArea { + id: localeInput + Layout.fillWidth: true + placeholderText: Translation.tr("Locale code, e.g. fr_FR, de_DE, zh_CN...") + text: Config.options.language.ui === "auto" ? Qt.locale().name : Config.options.language.ui + } + RippleButtonWithIcon { + id: generateTranslationBtn + Layout.fillHeight: true + nerdIcon: "" + enabled: !translationProc.running || (translationProc.locale !== localeInput.text.trim()) + mainText: enabled ? Translation.tr("Generate\nTypically takes 2 minutes") : Translation.tr("Generating...\nDon't close this window!") + onClicked: { + translationProc.locale = localeInput.text.trim(); + translationProc.running = false; + translationProc.running = true; + } + } } - options: [ - { - displayName: Translation.tr("Auto (System)"), - value: "auto" - }, - ...Translation.availableLanguages.map(lang => { - return { - displayName: lang.replace('_', '-'), - value: lang - }; - })] } }