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 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
@@ -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
|
||||
|
||||
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",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user