ai-generated ui translations

This commit is contained in:
end-4
2025-10-14 18:50:01 +02:00
parent a228c54dd5
commit 1dd4c4a109
6 changed files with 254 additions and 74 deletions
@@ -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}`])
@@ -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 {
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
if [[ -z "$1" ]]; then
echo "Usage: $0 <target_locale> [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"
+98 -54
View File
@@ -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
}
}
@@ -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",
+55 -16
View File
@@ -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
};
})]
}
}