forked from Shinonome/dots-hyprland
ai-generated ui translations
This commit is contained in:
@@ -41,6 +41,7 @@ Singleton {
|
|||||||
property string defaultAiPrompts: Quickshell.shellPath("defaults/ai/prompts")
|
property string defaultAiPrompts: Quickshell.shellPath("defaults/ai/prompts")
|
||||||
property string userAiPrompts: FileUtils.trimFileProtocol(`${Directories.shellConfig}/ai/prompts`)
|
property string userAiPrompts: FileUtils.trimFileProtocol(`${Directories.shellConfig}/ai/prompts`)
|
||||||
property string aiChats: FileUtils.trimFileProtocol(`${Directories.state}/user/ai/chats`)
|
property string aiChats: FileUtils.trimFileProtocol(`${Directories.state}/user/ai/chats`)
|
||||||
|
property string aiTranslationScriptPath: FileUtils.trimFileProtocol(`${Directories.scriptPath}/ai/gemini-translate.sh`)
|
||||||
// Cleanup on init
|
// Cleanup on init
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
Quickshell.execDetached(["mkdir", "-p", `${shellConfig}`])
|
Quickshell.execDetached(["mkdir", "-p", `${shellConfig}`])
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.modules.common
|
import qs.modules.common
|
||||||
@@ -8,6 +9,12 @@ import qs.modules.common.widgets
|
|||||||
ContentPage {
|
ContentPage {
|
||||||
forceWidth: true
|
forceWidth: true
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: translationProc
|
||||||
|
property string locale: ""
|
||||||
|
command: [Directories.aiTranslationScriptPath, translationProc.locale]
|
||||||
|
}
|
||||||
|
|
||||||
ContentSection {
|
ContentSection {
|
||||||
icon: "volume_up"
|
icon: "volume_up"
|
||||||
title: Translation.tr("Audio")
|
title: Translation.tr("Audio")
|
||||||
@@ -126,15 +133,39 @@ ContentPage {
|
|||||||
displayName: Translation.tr("Auto (System)"),
|
displayName: Translation.tr("Auto (System)"),
|
||||||
value: "auto"
|
value: "auto"
|
||||||
},
|
},
|
||||||
...Translation.availableLanguages.map(lang => {
|
...Translation.allAvailableLanguages.map(lang => {
|
||||||
return {
|
return {
|
||||||
displayName: lang.replace('_', '-'),
|
displayName: lang,
|
||||||
value: 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 {
|
ContentSection {
|
||||||
|
|||||||
+65
@@ -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"
|
||||||
@@ -10,11 +10,18 @@ Singleton {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var translations: ({})
|
property var translations: ({})
|
||||||
|
property var generatedTranslations: ({})
|
||||||
property var availableLanguages: ["en_US"]
|
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 isScanning: scanLanguagesProcess.running
|
||||||
property bool isLoading: false
|
property bool isLoading: false
|
||||||
property string translationKeepSuffix: "/*keep*/"
|
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: {
|
property string languageCode: {
|
||||||
var configLang = Config?.options.language.ui ?? "auto";
|
var configLang = Config?.options.language.ui ?? "auto";
|
||||||
@@ -25,80 +32,117 @@ Singleton {
|
|||||||
return Qt.locale().name;
|
return Qt.locale().name;
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
TranslationScanner {
|
||||||
id: scanLanguagesProcess
|
id: scanLanguagesProcess
|
||||||
command: ["find", root.translationsPath, "-name", "*.json", "-exec", "basename", "{}", ".json", ";"]
|
translationsDir: root.translationsDir
|
||||||
running: true
|
onLanguagesScanned: (languages) => {
|
||||||
|
root.availableLanguages = [...languages];
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
TranslationScanner {
|
||||||
root.availableLanguages = [...root.availableLanguages] // Forcibly emit change
|
id: scanGeneratedLanguagesProcess
|
||||||
|
translationsDir: root.generatedTranslationsDir
|
||||||
if (exitCode !== 0) {
|
onLanguagesScanned: (languages) => {
|
||||||
root.availableLanguages = ["en_US"];
|
root.availableGeneratedLanguages = [...languages];
|
||||||
}
|
|
||||||
// TODO: notify and offer to translate when translation not available
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onLanguageCodeChanged: {
|
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
|
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: {
|
onLoaded: {
|
||||||
var textContent = "";
|
var textContent = "";
|
||||||
try {
|
try {
|
||||||
textContent = text();
|
textContent = text();
|
||||||
var jsonData = JSON.parse(textContent);
|
var jsonData = JSON.parse(textContent);
|
||||||
root.translations = jsonData;
|
translationReader.contentLoaded(jsonData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[Translation] Failed to load translations:", e);
|
console.log("[Translation] Failed to load translations:", e);
|
||||||
root.translations = {};
|
translationReader.contentLoaded({});
|
||||||
}
|
}
|
||||||
root.isLoading = false;
|
|
||||||
}
|
}
|
||||||
onLoadFailed: error => {
|
onLoadFailed: error => {
|
||||||
root.translations = {};
|
translationReader.contentLoaded({});
|
||||||
root.isLoading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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",
|
default=".config/quickshell/translations",
|
||||||
help="Translation files directory (default: .config/quickshell/translations)")
|
help="Translation files directory (default: .config/quickshell/translations)")
|
||||||
parser.add_argument("--source-dir", "-s",
|
parser.add_argument("--source-dir", "-s",
|
||||||
default=".config/quickshell",
|
default=".config/quickshell/ii",
|
||||||
help="Source code directory (default: .config/quickshell)")
|
help="Source code directory (default: .config/quickshell/ii)")
|
||||||
parser.add_argument("--language", "-l",
|
parser.add_argument("--language", "-l",
|
||||||
help="Specify language code to process (e.g., zh_CN)")
|
help="Specify language code to process (e.g., zh_CN)")
|
||||||
parser.add_argument("--extract-only", "-e", action="store_true",
|
parser.add_argument("--extract-only", "-e", action="store_true",
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: translationProc
|
||||||
|
property string locale: ""
|
||||||
|
command: [Directories.aiTranslationScriptPath, translationProc.locale]
|
||||||
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors {
|
anchors {
|
||||||
fill: parent
|
fill: parent
|
||||||
@@ -135,23 +141,56 @@ ApplicationWindow {
|
|||||||
icon: "language"
|
icon: "language"
|
||||||
title: Translation.tr("Language")
|
title: Translation.tr("Language")
|
||||||
|
|
||||||
ConfigSelectionArray {
|
ContentSubsection {
|
||||||
id: languageSelector
|
title: Translation.tr("Select language")
|
||||||
currentValue: Config.options.language.ui
|
ConfigSelectionArray {
|
||||||
onSelected: newValue => {
|
id: languageSelector
|
||||||
Config.options.language.ui = newValue;
|
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
|
|
||||||
};
|
|
||||||
})]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user