From 4e0cb60c1e2165a70ca68fea7ad23b970cfe80bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Apr 2023 15:02:32 +0000 Subject: [PATCH 01/58] Bump org.jetbrains.intellij from 1.13.2 to 1.13.3 Bumps org.jetbrains.intellij from 1.13.2 to 1.13.3. --- updated-dependencies: - dependency-name: org.jetbrains.intellij dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 71c5b4e..f461131 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { // Java support id("java") // Gradle IntelliJ Plugin - id("org.jetbrains.intellij") version "1.13.2" + id("org.jetbrains.intellij") version "1.13.3" // Gradle Changelog Plugin id("org.jetbrains.changelog") version "2.0.0" } From 88409664ee5e34f681cf5efe868ef5e59607844c Mon Sep 17 00:00:00 2001 From: Airsaid Date: Mon, 5 Jun 2023 22:01:20 +0800 Subject: [PATCH 02/58] Add Korean and Norwegian to DeepL #127 --- .../localization/translate/impl/deepl/DeepLTranslator.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.java b/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.java index 78c9438..febad56 100644 --- a/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.java +++ b/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.java @@ -88,8 +88,10 @@ public boolean isNeedAppId() { supportedLanguages.add(new Lang(98, "id", "Indonesia", "Indonesian")); supportedLanguages.add(Languages.ITALIAN); supportedLanguages.add(Languages.JAPANESE); + supportedLanguages.add(Languages.KOREAN.setTranslationCode("KO")); supportedLanguages.add(Languages.LITHUANIAN); supportedLanguages.add(Languages.LATVIAN); + supportedLanguages.add(Languages.NORWEGIAN.setTranslationCode("NB")); supportedLanguages.add(Languages.DUTCH); supportedLanguages.add(Languages.POLISH); supportedLanguages.add(new Lang(120, "pt-br", "Portuguese (Brazilian)", "Portuguese (Brazilian)")); From a4c3b61c5f229265675b17932608dd6790c94a81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 May 2023 14:59:00 +0000 Subject: [PATCH 03/58] Bump com.google.auto.service:auto-service from 1.0.1 to 1.1.0 Bumps [com.google.auto.service:auto-service](https://github.com/google/auto) from 1.0.1 to 1.1.0. - [Release notes](https://github.com/google/auto/releases) - [Commits](https://github.com/google/auto/compare/auto-common-1.0.1...auto-value-1.1) --- updated-dependencies: - dependency-name: com.google.auto.service:auto-service dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index f461131..0b60bab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -112,8 +112,8 @@ tasks { dependencies { // https://github.com/google/auto/tree/master/service - compileOnly("com.google.auto.service:auto-service-annotations:1.0.1") - annotationProcessor("com.google.auto.service:auto-service:1.0.1") + compileOnly("com.google.auto.service:auto-service-annotations:1.1.0") + annotationProcessor("com.google.auto.service:auto-service:1.1.0") implementation("com.google.code.gson:gson:2.10.1") implementation("com.aliyun:alimt20181012:1.0.3") From d6c80707192f9494a1a15935b266ac96ce854ca8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 May 2023 15:02:12 +0000 Subject: [PATCH 04/58] Bump org.junit.jupiter:junit-jupiter-engine from 5.9.2 to 5.9.3 Bumps [org.junit.jupiter:junit-jupiter-engine](https://github.com/junit-team/junit5) from 5.9.2 to 5.9.3. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.9.2...r5.9.3) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-engine dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0b60bab..c12dcfa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -119,6 +119,6 @@ dependencies { implementation("com.aliyun:alimt20181012:1.0.3") testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.2") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.2") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.3") } \ No newline at end of file From c09483389e370af721c205ec37333400bec7f320 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jul 2023 14:50:48 +0000 Subject: [PATCH 05/58] Bump gradle/wrapper-validation-action from 1.0.6 to 1.1.0 Bumps [gradle/wrapper-validation-action](https://github.com/gradle/wrapper-validation-action) from 1.0.6 to 1.1.0. - [Release notes](https://github.com/gradle/wrapper-validation-action/releases) - [Commits](https://github.com/gradle/wrapper-validation-action/compare/v1.0.6...v1.1.0) --- updated-dependencies: - dependency-name: gradle/wrapper-validation-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b469cf5..752e13e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,7 +44,7 @@ jobs: # Validate wrapper - name: Gradle Wrapper Validation - uses: gradle/wrapper-validation-action@v1.0.6 + uses: gradle/wrapper-validation-action@v1.1.0 # Setup Java 11 environment for the next steps - name: Setup Java From 0e88d3f4d0aa399a44c7fe8952170a7c2b4cd3d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jun 2023 14:59:48 +0000 Subject: [PATCH 06/58] Bump com.google.auto.service:auto-service from 1.1.0 to 1.1.1 Bumps [com.google.auto.service:auto-service](https://github.com/google/auto) from 1.1.0 to 1.1.1. - [Release notes](https://github.com/google/auto/releases) - [Commits](https://github.com/google/auto/compare/auto-value-1.1...auto-service-1.1.1) --- updated-dependencies: - dependency-name: com.google.auto.service:auto-service dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index c12dcfa..a44b834 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -112,8 +112,8 @@ tasks { dependencies { // https://github.com/google/auto/tree/master/service - compileOnly("com.google.auto.service:auto-service-annotations:1.1.0") - annotationProcessor("com.google.auto.service:auto-service:1.1.0") + compileOnly("com.google.auto.service:auto-service-annotations:1.1.1") + annotationProcessor("com.google.auto.service:auto-service:1.1.1") implementation("com.google.code.gson:gson:2.10.1") implementation("com.aliyun:alimt20181012:1.0.3") From fe70261d5e54d7626f8b682ebcbe2c0b877ecf51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Jul 2023 14:53:54 +0000 Subject: [PATCH 07/58] Bump org.junit.jupiter:junit-jupiter-api from 5.9.2 to 5.10.0 Bumps [org.junit.jupiter:junit-jupiter-api](https://github.com/junit-team/junit5) from 5.9.2 to 5.10.0. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.9.2...r5.10.0) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-api dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index a44b834..87fa7ab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -118,7 +118,7 @@ dependencies { implementation("com.google.code.gson:gson:2.10.1") implementation("com.aliyun:alimt20181012:1.0.3") - testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.2") + testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.3") } \ No newline at end of file From 364e28701b4b4aab11e4b33a2778cc31b5455012 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jul 2023 14:11:12 +0000 Subject: [PATCH 08/58] Bump org.jetbrains.changelog from 2.0.0 to 2.1.2 Bumps org.jetbrains.changelog from 2.0.0 to 2.1.2. --- updated-dependencies: - dependency-name: org.jetbrains.changelog dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 87fa7ab..720bf3d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { // Gradle IntelliJ Plugin id("org.jetbrains.intellij") version "1.13.3" // Gradle Changelog Plugin - id("org.jetbrains.changelog") version "2.0.0" + id("org.jetbrains.changelog") version "2.1.2" } group = properties("pluginGroup") From 2d9e35acdddfea1761188d913225231bdc6c8caf Mon Sep 17 00:00:00 2001 From: asier2 Date: Tue, 3 Oct 2023 13:37:00 +0200 Subject: [PATCH 09/58] Fix region langs not generating the correct values folder. --- .../localization/services/AndroidValuesService.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/airsaid/localization/services/AndroidValuesService.java b/src/main/java/com/airsaid/localization/services/AndroidValuesService.java index 438aa15..469de34 100644 --- a/src/main/java/com/airsaid/localization/services/AndroidValuesService.java +++ b/src/main/java/com/airsaid/localization/services/AndroidValuesService.java @@ -193,7 +193,12 @@ public File getValueFile(@NotNull VirtualFile resourceDir, @NotNull Lang lang, S } private String getValuesDirectoryName(@NotNull Lang lang) { - return "values-".concat(lang.getCode()); + String[] parts = lang.getCode().split("-"); + if (parts.length > 1) { + return "values-".concat(parts[0] + "-" + "r" + parts[1].toUpperCase()); + } else { + return "values-".concat(lang.getCode()); + } } /** From 9cf091cbcf34091a7f6d4fec4842a27dcb2430e9 Mon Sep 17 00:00:00 2001 From: Andrey Pavlenko Date: Thu, 15 Feb 2024 22:42:47 +0100 Subject: [PATCH 10/58] Add option to skip non-translatable strings --- .../config/SettingsComponent.form | 10 ++++- .../config/SettingsComponent.java | 9 +++++ .../config/SettingsConfigurable.java | 7 ++++ .../localization/config/SettingsState.java | 12 ++++++ .../services/AndroidValuesService.java | 38 +++++++++++++++++-- 5 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/airsaid/localization/config/SettingsComponent.form b/src/main/java/com/airsaid/localization/config/SettingsComponent.form index f37e685..a1dd45e 100644 --- a/src/main/java/com/airsaid/localization/config/SettingsComponent.form +++ b/src/main/java/com/airsaid/localization/config/SettingsComponent.form @@ -141,7 +141,7 @@ - + @@ -182,6 +182,14 @@ + + + + + + + + diff --git a/src/main/java/com/airsaid/localization/config/SettingsComponent.java b/src/main/java/com/airsaid/localization/config/SettingsComponent.java index f294ee8..4f40e32 100644 --- a/src/main/java/com/airsaid/localization/config/SettingsComponent.java +++ b/src/main/java/com/airsaid/localization/config/SettingsComponent.java @@ -58,6 +58,7 @@ public class SettingsComponent { private JBCheckBox enableCacheCheckBox; private ComboBox maxCacheSizeComboBox; private ComboBox translationIntervalComboBox; + private JCheckBox skipNonTranslatableCheckBox; public SettingsComponent() { initTranslatorComponents(); @@ -206,4 +207,12 @@ public int getTranslationInterval() { public void setTranslationInterval(int intervalTime) { translationIntervalComboBox.setSelectedItem(String.valueOf(intervalTime)); } + + public boolean isSkipNonTranslatable() { + return skipNonTranslatableCheckBox.isSelected(); + } + + public void setSkipNonTranslatable(boolean isSkipNonTranslatable) { + skipNonTranslatableCheckBox.setSelected(isSkipNonTranslatable); + } } diff --git a/src/main/java/com/airsaid/localization/config/SettingsConfigurable.java b/src/main/java/com/airsaid/localization/config/SettingsConfigurable.java index 3eedfe8..ece66f7 100644 --- a/src/main/java/com/airsaid/localization/config/SettingsConfigurable.java +++ b/src/main/java/com/airsaid/localization/config/SettingsConfigurable.java @@ -18,6 +18,7 @@ package com.airsaid.localization.config; import com.airsaid.localization.constant.Constants; +import com.airsaid.localization.services.AndroidValuesService; import com.airsaid.localization.translate.AbstractTranslator; import com.airsaid.localization.translate.services.TranslatorService; import com.intellij.openapi.diagnostic.Logger; @@ -63,6 +64,7 @@ private void initComponents() { settingsComponent.setEnableCache(settingsState.isEnableCache()); settingsComponent.setMaxCacheSize(settingsState.getMaxCacheSize()); settingsComponent.setTranslationInterval(settingsState.getTranslationInterval()); + settingsComponent.setSkipNonTranslatable(settingsState.isSkipNonTranslatable()); } @Override @@ -75,6 +77,7 @@ public boolean isModified() { isChanged |= settingsState.isEnableCache() == settingsComponent.isEnableCache(); isChanged |= settingsState.getMaxCacheSize() == settingsComponent.getMaxCacheSize(); isChanged |= settingsState.getTranslationInterval() == settingsComponent.getTranslationInterval(); + isChanged |= settingsState.isSkipNonTranslatable() == settingsComponent.isSkipNonTranslatable(); LOG.info("isModified: " + isChanged); return isChanged; } @@ -103,12 +106,15 @@ public void apply() throws ConfigurationException { settingsState.setEnableCache(settingsComponent.isEnableCache()); settingsState.setMaxCacheSize(settingsComponent.getMaxCacheSize()); settingsState.setTranslationInterval(settingsComponent.getTranslationInterval()); + settingsState.setSkipNonTranslatable(settingsComponent.isSkipNonTranslatable()); TranslatorService translatorService = TranslatorService.getInstance(); translatorService.setSelectedTranslator(selectedTranslator); translatorService.setEnableCache(settingsComponent.isEnableCache()); translatorService.setMaxCacheSize(settingsComponent.getMaxCacheSize()); translatorService.setTranslationInterval(settingsComponent.getTranslationInterval()); + + AndroidValuesService.getInstance().setSkipNonTranslatable(settingsComponent.isSkipNonTranslatable()); } @Override @@ -122,6 +128,7 @@ public void reset() { settingsComponent.setEnableCache(settingsState.isEnableCache()); settingsComponent.setMaxCacheSize(settingsState.getMaxCacheSize()); settingsComponent.setTranslationInterval(settingsState.getTranslationInterval()); + settingsComponent.setSkipNonTranslatable(settingsState.isSkipNonTranslatable()); } @Override diff --git a/src/main/java/com/airsaid/localization/config/SettingsState.java b/src/main/java/com/airsaid/localization/config/SettingsState.java index 7e2f8eb..20d371a 100644 --- a/src/main/java/com/airsaid/localization/config/SettingsState.java +++ b/src/main/java/com/airsaid/localization/config/SettingsState.java @@ -17,6 +17,7 @@ package com.airsaid.localization.config; +import com.airsaid.localization.services.AndroidValuesService; import com.airsaid.localization.translate.AbstractTranslator; import com.airsaid.localization.translate.services.TranslatorService; import com.airsaid.localization.utils.SecureStorage; @@ -71,6 +72,8 @@ public void initSetting() { translatorService.setMaxCacheSize(getMaxCacheSize()); translatorService.setTranslationInterval(getTranslationInterval()); } + + AndroidValuesService.getInstance().setSkipNonTranslatable(isSkipNonTranslatable()); } public AbstractTranslator getSelectedTranslator() { @@ -129,6 +132,14 @@ public void setTranslationInterval(int intervalTime) { state.translationInterval = intervalTime; } + public boolean isSkipNonTranslatable() { + return state.isSkipNonTranslatable; + } + + public void setSkipNonTranslatable(boolean isSkipNonTranslatable) { + state.isSkipNonTranslatable = isSkipNonTranslatable; + } + @Override public @Nullable SettingsState.State getState() { return state; @@ -145,5 +156,6 @@ static class State { public boolean isEnableCache = true; public int maxCacheSize = 500; public int translationInterval = 2; // 2 second + public boolean isSkipNonTranslatable; } } diff --git a/src/main/java/com/airsaid/localization/services/AndroidValuesService.java b/src/main/java/com/airsaid/localization/services/AndroidValuesService.java index 469de34..1f11926 100644 --- a/src/main/java/com/airsaid/localization/services/AndroidValuesService.java +++ b/src/main/java/com/airsaid/localization/services/AndroidValuesService.java @@ -42,6 +42,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.regex.Pattern; @@ -57,6 +58,8 @@ public final class AndroidValuesService { private static final Pattern STRINGS_FILE_NAME_PATTERN = Pattern.compile(".+\\.xml"); + private boolean isSkipNonTranslatable; + /** * Returns the {@link AndroidValuesService} object instance. * @@ -96,18 +99,45 @@ public List loadValues(@NotNull PsiFile valueFile) { }); } + public boolean isSkipNonTranslatable() { + return isSkipNonTranslatable; + } + + public void setSkipNonTranslatable(boolean isSkipNonTranslatable) { + this.isSkipNonTranslatable = isSkipNonTranslatable; + } + private List parseValuesXml(@NotNull PsiFile valueFile) { - final List values = new ArrayList<>(); final XmlFile xmlFile = (XmlFile) valueFile; final XmlDocument document = xmlFile.getDocument(); - if (document == null) return values; + if (document == null) return Collections.emptyList(); final XmlTag rootTag = document.getRootTag(); - if (rootTag == null) return values; + if (rootTag == null) return Collections.emptyList(); PsiElement[] subTags = rootTag.getChildren(); - values.addAll(Arrays.asList(subTags)); + + if (!isSkipNonTranslatable()) { + return Arrays.asList(subTags); + } + + List values = new ArrayList<>(subTags.length); + boolean skipNext = false; + + for (PsiElement e : subTags) { + if (skipNext) { + skipNext = false; + if (!(e instanceof XmlTag)) { + continue; + } + } + if ((e instanceof XmlTag) && !isTranslatable((XmlTag) e)) { + skipNext = true; + } else { + values.add(e); + } + } return values; } From 7c7bb7bfb366578a86bec870811588465a6b7b6d Mon Sep 17 00:00:00 2001 From: Airsaid Date: Wed, 17 Sep 2025 09:40:48 +0800 Subject: [PATCH 11/58] Convert entire AndroidLocalizePlugin from Java to Kotlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrated all 54 Java source files to Kotlin - Updated build.gradle.kts to support Kotlin compilation - Converted interfaces to use properties instead of getter/setter methods - Applied Kotlin idioms: data classes, object declarations, null safety - Fixed all compilation errors and maintained original functionality - All tests pass successfully - Preserved original licensing headers and package structure Key improvements: - Reduced boilerplate code significantly - Enhanced type safety with Kotlin's null safety features - Better maintainability with more concise, readable code - Modernized codebase while maintaining full backward compatibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- build.gradle.kts | 8 + .../localization/action/TranslateAction.java | 108 ------- .../config/SettingsComponent.form | 194 ------------ .../config/SettingsComponent.java | 209 ------------- .../config/SettingsConfigurable.java | 131 --------- .../localization/config/SettingsState.java | 149 ---------- .../services/AndroidValuesService.java | 211 -------------- .../localization/task/TranslateTask.java | 268 ----------------- .../translate/AbstractTranslator.java | 150 ---------- .../translate/TranslationException.java | 67 ----- .../translate/TranslatorConfigurable.java | 60 ---- .../translate/impl/ali/AliTranslator.java | 142 --------- .../impl/baidu/BaiduTranslationResult.java | 162 ----------- .../translate/impl/baidu/BaiduTranslator.java | 140 --------- .../impl/deepl/DeepLProTranslator.java | 49 ---- .../impl/deepl/DeepLTranslationResult.java | 153 ---------- .../translate/impl/deepl/DeepLTranslator.java | 148 ---------- .../impl/google/AbsGoogleTranslator.java | 66 ----- .../translate/impl/google/GoogleToken.java | 143 --------- .../impl/google/GoogleTranslationResult.java | 160 ---------- .../impl/google/GoogleTranslator.java | 96 ------ .../googleapi/GoogleApiTranslationResult.java | 68 ----- .../impl/googleapi/GoogleApiTranslator.java | 89 ------ .../microsoft/MicrosoftTranslationResult.java | 153 ---------- .../impl/microsoft/MicrosoftTranslator.java | 201 ------------- .../impl/openai/ChatGPTTranslator.java | 110 ------- .../translate/impl/openai/OpenAIRequest.java | 46 --- .../translate/impl/openai/OpenAIResponse.java | 184 ------------ .../impl/youdao/YoudaoTranslationResult.java | 95 ------ .../impl/youdao/YoudaoTranslator.java | 244 ---------------- .../EscapeCharactersInterceptor.java | 55 ---- .../localization/translate/lang/Lang.java | 106 ------- .../translate/lang/Languages.java | 275 ------------------ .../services/TranslationCacheService.java | 109 ------- .../translate/services/TranslatorService.java | 160 ---------- .../translate/util/AgentUtil.java | 57 ---- .../localization/translate/util/LRUCache.java | 131 --------- .../localization/translate/util/MD5.java | 61 ---- .../translate/util/UrlBuilder.java | 68 ----- .../localization/ui/FixedLinkLabel.java | 58 ---- .../ui/SelectLanguagesDialog.form | 73 ----- .../ui/SelectLanguagesDialog.java | 186 ------------ .../ui/SupportLanguagesDialog.java | 74 ----- .../localization/utils/LanguageUtil.java | 92 ------ .../localization/utils/NotificationUtil.java | 54 ---- .../localization/utils/SecureStorage.java | 54 ---- src/main/java/icons/PluginIcons.java | 39 --- .../localization/action/TranslateAction.kt | 100 +++++++ .../localization/config/SettingsComponent.kt | 202 +++++++++++++ .../config/SettingsConfigurable.kt | 138 +++++++++ .../localization/config/SettingsState.kt | 133 +++++++++ .../localization/constant/Constants.kt} | 25 +- .../services/AndroidValuesService.kt | 202 +++++++++++++ .../localization/task/TranslateTask.kt | 264 +++++++++++++++++ .../translate/AbstractTranslator.kt | 120 ++++++++ .../translate/TranslationException.kt | 55 ++++ .../translate/TranslationResult.kt} | 23 +- .../localization/translate/Translator.kt} | 18 +- .../translate/TranslatorConfigurable.kt} | 39 +-- .../translate/impl/ali/AliTranslator.kt | 124 ++++++++ .../impl/baidu/BaiduTranslationResult.kt | 57 ++++ .../translate/impl/baidu/BaiduTranslator.kt | 126 ++++++++ .../impl/deepl/DeepLProTranslator.kt | 41 +++ .../impl/deepl/DeepLTranslationResult.kt | 48 +++ .../translate/impl/deepl/DeepLTranslator.kt | 121 ++++++++ .../impl/google/AbsGoogleTranslator.kt | 56 ++++ .../translate/impl/google/GoogleToken.kt | 148 ++++++++++ .../impl/google/GoogleTranslationResult.kt | 52 ++++ .../translate/impl/google/GoogleTranslator.kt | 78 +++++ .../googleapi/GoogleApiTranslationResult.kt | 49 ++++ .../impl/googleapi/GoogleApiTranslator.kt | 66 +++++ .../microsoft/MicrosoftTranslationResult.kt | 48 +++ .../impl/microsoft/MicrosoftTranslator.kt | 174 +++++++++++ .../translate/impl/openai/ChatGPTMessage.kt} | 31 +- .../impl/openai/ChatGPTTranslator.kt | 88 ++++++ .../translate/impl/openai/OpenAIRequest.kt | 23 ++ .../translate/impl/openai/OpenAIResponse.kt | 53 ++++ .../impl/youdao/YoudaoTranslationResult.kt | 66 +++++ .../translate/impl/youdao/YoudaoTranslator.kt | 228 +++++++++++++++ .../EscapeCharactersInterceptor.kt | 47 +++ .../localization/translate/lang/Lang.kt | 72 +++++ .../localization/translate/lang/Languages.kt | 176 +++++++++++ .../services/TranslationCacheService.kt | 98 +++++++ .../translate/services/TranslatorService.kt | 152 ++++++++++ .../localization/translate/util/AgentUtil.kt | 50 ++++ .../localization/translate/util/GsonUtil.kt} | 28 +- .../localization/translate/util/LRUCache.kt | 117 ++++++++ .../localization/translate/util/MD5.kt | 59 ++++ .../localization/translate/util/UrlBuilder.kt | 54 ++++ .../airsaid/localization/ui/FixedLinkLabel.kt | 54 ++++ .../localization/ui/SelectLanguagesDialog.kt | 174 +++++++++++ .../localization/ui/SupportLanguagesDialog.kt | 63 ++++ .../localization/utils/LanguageUtil.kt | 66 +++++ .../localization/utils/NotificationUtil.kt | 49 ++++ .../localization/utils/SecureStorage.kt | 45 +++ .../airsaid/localization/utils/TextUtil.kt | 38 +++ src/main/kotlin/icons/PluginIcons.kt | 53 ++++ .../impl/google/GoogleTokenTest.java | 22 -- .../translate/util/LRUCacheTest.java | 42 --- .../translate/util/UrlBuilderTest.java | 45 --- .../localization/utils/TextUtilTest.java | 34 --- .../translate/impl/google/GoogleTokenTest.kt | 21 ++ .../translate/util/LRUCacheTest.kt | 42 +++ .../translate/util/UrlBuilderTest.kt | 46 +++ .../localization/utils/TextUtilTest.kt | 33 +++ 105 files changed, 4444 insertions(+), 5888 deletions(-) delete mode 100644 src/main/java/com/airsaid/localization/action/TranslateAction.java delete mode 100644 src/main/java/com/airsaid/localization/config/SettingsComponent.form delete mode 100644 src/main/java/com/airsaid/localization/config/SettingsComponent.java delete mode 100644 src/main/java/com/airsaid/localization/config/SettingsConfigurable.java delete mode 100644 src/main/java/com/airsaid/localization/config/SettingsState.java delete mode 100644 src/main/java/com/airsaid/localization/services/AndroidValuesService.java delete mode 100644 src/main/java/com/airsaid/localization/task/TranslateTask.java delete mode 100644 src/main/java/com/airsaid/localization/translate/AbstractTranslator.java delete mode 100644 src/main/java/com/airsaid/localization/translate/TranslationException.java delete mode 100644 src/main/java/com/airsaid/localization/translate/TranslatorConfigurable.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/ali/AliTranslator.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/google/GoogleToken.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/google/GoogleTranslator.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/openai/OpenAIRequest.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/openai/OpenAIResponse.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/youdao/YoudaoTranslationResult.java delete mode 100644 src/main/java/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.java delete mode 100644 src/main/java/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.java delete mode 100644 src/main/java/com/airsaid/localization/translate/lang/Lang.java delete mode 100644 src/main/java/com/airsaid/localization/translate/lang/Languages.java delete mode 100644 src/main/java/com/airsaid/localization/translate/services/TranslationCacheService.java delete mode 100644 src/main/java/com/airsaid/localization/translate/services/TranslatorService.java delete mode 100644 src/main/java/com/airsaid/localization/translate/util/AgentUtil.java delete mode 100644 src/main/java/com/airsaid/localization/translate/util/LRUCache.java delete mode 100755 src/main/java/com/airsaid/localization/translate/util/MD5.java delete mode 100644 src/main/java/com/airsaid/localization/translate/util/UrlBuilder.java delete mode 100644 src/main/java/com/airsaid/localization/ui/FixedLinkLabel.java delete mode 100644 src/main/java/com/airsaid/localization/ui/SelectLanguagesDialog.form delete mode 100644 src/main/java/com/airsaid/localization/ui/SelectLanguagesDialog.java delete mode 100644 src/main/java/com/airsaid/localization/ui/SupportLanguagesDialog.java delete mode 100644 src/main/java/com/airsaid/localization/utils/LanguageUtil.java delete mode 100644 src/main/java/com/airsaid/localization/utils/NotificationUtil.java delete mode 100644 src/main/java/com/airsaid/localization/utils/SecureStorage.java delete mode 100644 src/main/java/icons/PluginIcons.java create mode 100644 src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt create mode 100644 src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt create mode 100644 src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt create mode 100644 src/main/kotlin/com/airsaid/localization/config/SettingsState.kt rename src/main/{java/com/airsaid/localization/constant/Constants.java => kotlin/com/airsaid/localization/constant/Constants.kt} (57%) create mode 100644 src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt create mode 100644 src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/TranslationException.kt rename src/main/{java/com/airsaid/localization/translate/TranslationResult.java => kotlin/com/airsaid/localization/translate/TranslationResult.kt} (73%) rename src/main/{java/com/airsaid/localization/translate/Translator.java => kotlin/com/airsaid/localization/translate/Translator.kt} (73%) rename src/main/{java/com/airsaid/localization/translate/util/GsonUtil.java => kotlin/com/airsaid/localization/translate/TranslatorConfigurable.kt} (58%) create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt rename src/main/{java/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.java => kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.kt} (53%) create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIRequest.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslationResult.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/services/TranslationCacheService.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/util/AgentUtil.kt rename src/main/{java/com/airsaid/localization/utils/TextUtil.java => kotlin/com/airsaid/localization/translate/util/GsonUtil.kt} (59%) create mode 100644 src/main/kotlin/com/airsaid/localization/translate/util/LRUCache.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/util/MD5.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/util/UrlBuilder.kt create mode 100644 src/main/kotlin/com/airsaid/localization/ui/FixedLinkLabel.kt create mode 100644 src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt create mode 100644 src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt create mode 100644 src/main/kotlin/com/airsaid/localization/utils/LanguageUtil.kt create mode 100644 src/main/kotlin/com/airsaid/localization/utils/NotificationUtil.kt create mode 100644 src/main/kotlin/com/airsaid/localization/utils/SecureStorage.kt create mode 100644 src/main/kotlin/com/airsaid/localization/utils/TextUtil.kt create mode 100644 src/main/kotlin/icons/PluginIcons.kt delete mode 100644 src/test/java/com/airsaid/localization/translate/impl/google/GoogleTokenTest.java delete mode 100644 src/test/java/com/airsaid/localization/translate/util/LRUCacheTest.java delete mode 100644 src/test/java/com/airsaid/localization/translate/util/UrlBuilderTest.java delete mode 100644 src/test/java/com/airsaid/localization/utils/TextUtilTest.java create mode 100644 src/test/kotlin/com/airsaid/localization/translate/impl/google/GoogleTokenTest.kt create mode 100644 src/test/kotlin/com/airsaid/localization/translate/util/LRUCacheTest.kt create mode 100644 src/test/kotlin/com/airsaid/localization/translate/util/UrlBuilderTest.kt create mode 100644 src/test/kotlin/com/airsaid/localization/utils/TextUtilTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 720bf3d..f384bf5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,8 @@ import org.jetbrains.changelog.markdownToHTML fun properties(key: String) = project.findProperty(key).toString() plugins { + // Kotlin support + kotlin("jvm") version "1.8.0" // Java support id("java") // Gradle IntelliJ Plugin @@ -46,6 +48,12 @@ tasks { targetCompatibility = it options.encoding = "UTF-8" } + withType { + kotlinOptions { + jvmTarget = it + freeCompilerArgs = listOf("-Xjvm-default=all") + } + } } wrapper { diff --git a/src/main/java/com/airsaid/localization/action/TranslateAction.java b/src/main/java/com/airsaid/localization/action/TranslateAction.java deleted file mode 100644 index d9b8e16..0000000 --- a/src/main/java/com/airsaid/localization/action/TranslateAction.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.action; - -import com.airsaid.localization.config.SettingsState; -import com.airsaid.localization.services.AndroidValuesService; -import com.airsaid.localization.task.TranslateTask; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.ui.SelectLanguagesDialog; -import com.airsaid.localization.utils.NotificationUtil; -import com.intellij.openapi.actionSystem.AnAction; -import com.intellij.openapi.actionSystem.AnActionEvent; -import com.intellij.openapi.actionSystem.CommonDataKeys; -import com.intellij.openapi.project.Project; -import com.intellij.psi.PsiElement; -import com.intellij.psi.PsiFile; -import com.intellij.psi.xml.XmlTag; -import org.jetbrains.annotations.NotNull; - -import java.util.List; - -/** - * Translate android string value to other languages that can be used to localize your Android APP. - * - * @author airsaid - */ -public class TranslateAction extends AnAction implements SelectLanguagesDialog.OnClickListener { - - private Project mProject; - private PsiFile mValueFile; - private List mValues; - private final AndroidValuesService mValueService = AndroidValuesService.getInstance(); - - @Override - public void actionPerformed(AnActionEvent e) { - mProject = e.getRequiredData(CommonDataKeys.PROJECT); - mValueFile = e.getRequiredData(CommonDataKeys.PSI_FILE); - - SettingsState.getInstance().initSetting(); - - mValueService.loadValuesByAsync(mValueFile, values -> { - if (!isTranslatable(values)) { - NotificationUtil.notifyInfo(mProject, "The " + mValueFile.getName() + " has no text to translate."); - return; - } - mValues = values; - showSelectLanguageDialog(); - }); - } - - // Verify that there is a text in the value file that needs to be translated. - private boolean isTranslatable(@NotNull List values) { - for (PsiElement psiElement : values) { - if (psiElement instanceof XmlTag) { - if (mValueService.isTranslatable((XmlTag) psiElement)) { - return true; - } - } - } - return false; - } - - private void showSelectLanguageDialog() { - SelectLanguagesDialog dialog = new SelectLanguagesDialog(mProject); - dialog.setOnClickListener(this); - dialog.show(); - } - - @Override - public void update(@NotNull AnActionEvent e) { - // The translation option is only show when xml file from values is selected - Project project = e.getData(CommonDataKeys.PROJECT); - boolean isSelectValueFile = mValueService.isValueFile(e.getData(CommonDataKeys.PSI_FILE)); - e.getPresentation().setEnabledAndVisible(project != null && isSelectValueFile); - } - - @Override - public void onClickListener(List selectedLanguage) { - TranslateTask translationTask = new TranslateTask(mProject, "Translating...", selectedLanguage, mValues, mValueFile); - translationTask.setOnTranslateListener(new TranslateTask.OnTranslateListener() { - @Override - public void onTranslateSuccess() { - NotificationUtil.notifyInfo(mProject, "Translation completed!"); - } - - @Override - public void onTranslateError(Throwable e) { - NotificationUtil.notifyError(mProject, "Translation failure: " + e.getLocalizedMessage()); - } - }); - translationTask.queue(); - } -} diff --git a/src/main/java/com/airsaid/localization/config/SettingsComponent.form b/src/main/java/com/airsaid/localization/config/SettingsComponent.form deleted file mode 100644 index f37e685..0000000 --- a/src/main/java/com/airsaid/localization/config/SettingsComponent.form +++ /dev/null @@ -1,194 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
diff --git a/src/main/java/com/airsaid/localization/config/SettingsComponent.java b/src/main/java/com/airsaid/localization/config/SettingsComponent.java deleted file mode 100644 index f294ee8..0000000 --- a/src/main/java/com/airsaid/localization/config/SettingsComponent.java +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.config; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.services.TranslatorService; -import com.airsaid.localization.ui.FixedLinkLabel; -import com.airsaid.localization.ui.SupportLanguagesDialog; -import com.intellij.ide.BrowserUtil; -import com.intellij.ide.HelpTooltip; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.ui.ComboBox; -import com.intellij.openapi.util.text.StringUtil; -import com.intellij.ui.CollectionComboBoxModel; -import com.intellij.ui.SimpleListCellRenderer; -import com.intellij.ui.components.JBCheckBox; -import com.intellij.ui.components.JBLabel; -import com.intellij.ui.components.JBPasswordField; -import com.intellij.ui.components.JBTextField; -import org.jetbrains.annotations.NotNull; - -import javax.swing.*; -import java.awt.event.ItemEvent; -import java.util.ArrayList; -import java.util.Map; -import java.util.Objects; - -/** - * @author airsaid - */ -public class SettingsComponent { - private static final Logger LOG = Logger.getInstance(SettingsComponent.class); - - private JPanel contentJPanel; - private ComboBox translatorsComboBox; - private JBLabel appIdLabel; - private JBTextField appIdField; - private JBLabel appKeyLabel; - private JBPasswordField appKeyField; - private FixedLinkLabel applyLink; - private JButton supportLanguagesButton; - private JLabel maxCacheSizeLabel; - private JBCheckBox enableCacheCheckBox; - private ComboBox maxCacheSizeComboBox; - private ComboBox translationIntervalComboBox; - - public SettingsComponent() { - initTranslatorComponents(); - initCacheComponents(); - } - - private void initTranslatorComponents() { - translatorsComboBox.setRenderer(new SimpleListCellRenderer<>() { - @Override - public void customize(@NotNull JList list, AbstractTranslator value, int index, boolean selected, boolean hasFocus) { - setText(value.getName()); - setIcon(value.getIcon()); - } - }); - translatorsComboBox.addItemListener(itemEvent -> { - if (itemEvent.getStateChange() == ItemEvent.SELECTED) { - setSelectedTranslator(getSelectedTranslator()); - } - }); - applyLink.setListener((aSource, aLinkData) -> { - AbstractTranslator selectedTranslator = getSelectedTranslator(); - String applyAppIdUrl = selectedTranslator.getApplyAppIdUrl(); - if (!StringUtil.isEmpty(applyAppIdUrl)) { - BrowserUtil.browse(applyAppIdUrl); - applyLink.setFocusable(false); - } - }, null); - supportLanguagesButton.addActionListener(actionEvent -> { - showSupportLanguagesDialog(getSelectedTranslator()); - }); - } - - private void initCacheComponents() { - enableCacheCheckBox.addItemListener(event -> { - if (event.getStateChange() == ItemEvent.SELECTED) { - setEnableCache(true); - } else if (event.getStateChange() == ItemEvent.DESELECTED) { - setEnableCache(false); - } - }); - } - - @NotNull - public AbstractTranslator getSelectedTranslator() { - return (AbstractTranslator) Objects.requireNonNull(translatorsComboBox.getSelectedItem()); - } - - private void showSupportLanguagesDialog(AbstractTranslator selectedTranslator) { - new SupportLanguagesDialog(selectedTranslator).show(); - } - - public JPanel getContent() { - return contentJPanel; - } - - public JComponent getPreferredFocusedComponent() { - return translatorsComboBox; - } - - public void setTranslators(@NotNull Map translators) { - LOG.info("setTranslators: " + translators.keySet()); - translatorsComboBox.setModel(new CollectionComboBoxModel<>(new ArrayList<>(translators.values()))); - } - - public void setSelectedTranslator(@NotNull AbstractTranslator selected) { - LOG.info("setSelectedTranslator: " + selected); - translatorsComboBox.setSelectedItem(selected); - - boolean isNeedAppId = selected.isNeedAppId(); - appIdLabel.setVisible(isNeedAppId); - appIdField.setVisible(isNeedAppId); - if (isNeedAppId) { - appIdLabel.setText(selected.getAppIdDisplay() + ":"); - appIdField.setText(selected.getAppId()); - } - - boolean isNeedAppKey = selected.isNeedAppKey(); - appKeyLabel.setVisible(isNeedAppKey); - appKeyField.setVisible(isNeedAppKey); - if (isNeedAppKey) { - appKeyLabel.setText(selected.getAppKeyDisplay() + ":"); - appKeyField.setText(selected.getAppKey()); - } - - String applyAppIdUrl = selected.getApplyAppIdUrl(); - if (!StringUtil.isEmpty(applyAppIdUrl)) { - applyLink.setVisible(true); - new HelpTooltip() - .setDescription("Apply for " + selected.getName() + " translation API service") - .installOn(applyLink); - } else { - applyLink.setVisible(false); - } - } - - public boolean isSelectedDefaultTranslator() { - return isSelectedDefaultTranslator(getSelectedTranslator()); - } - - private boolean isSelectedDefaultTranslator(@NotNull AbstractTranslator selected) { - return selected == TranslatorService.getInstance().getDefaultTranslator(); - } - - @NotNull - public String getAppId() { - String appId = appIdField.getText(); - return appId != null ? appId : ""; - } - - public void setAppId(@NotNull String appId) { - appIdField.setText(appId); - } - - @NotNull - public String getAppKey() { - char[] password = appKeyField.getPassword(); - return password != null ? String.valueOf(password) : ""; - } - - public void setAppKey(@NotNull String appKey) { - appKeyField.setText(appKey); - } - - public void setEnableCache(boolean isEnable) { - enableCacheCheckBox.setSelected(isEnable); - maxCacheSizeComboBox.setVisible(isEnable); - maxCacheSizeLabel.setVisible(isEnable); - } - - public boolean isEnableCache() { - return enableCacheCheckBox.isSelected(); - } - - public int getMaxCacheSize() { - return Integer.parseInt((String) Objects.requireNonNull(maxCacheSizeComboBox.getSelectedItem())); - } - - public void setMaxCacheSize(int maxCacheSize) { - maxCacheSizeComboBox.setSelectedItem(String.valueOf(maxCacheSize)); - } - - public int getTranslationInterval() { - return Integer.parseInt((String) Objects.requireNonNull(translationIntervalComboBox.getSelectedItem())); - } - - public void setTranslationInterval(int intervalTime) { - translationIntervalComboBox.setSelectedItem(String.valueOf(intervalTime)); - } -} diff --git a/src/main/java/com/airsaid/localization/config/SettingsConfigurable.java b/src/main/java/com/airsaid/localization/config/SettingsConfigurable.java deleted file mode 100644 index 3eedfe8..0000000 --- a/src/main/java/com/airsaid/localization/config/SettingsConfigurable.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.config; - -import com.airsaid.localization.constant.Constants; -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.services.TranslatorService; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.options.Configurable; -import com.intellij.openapi.options.ConfigurationException; -import com.intellij.openapi.util.text.StringUtil; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.util.Map; - -/** - * @author airsaid - */ -public class SettingsConfigurable implements Configurable { - - private static final Logger LOG = Logger.getInstance(SettingsConfigurable.class); - - private SettingsComponent settingsComponent; - - @Override - public String getDisplayName() { - return Constants.PLUGIN_NAME; - } - - @Override - public JComponent getPreferredFocusedComponent() { - return settingsComponent.getPreferredFocusedComponent(); - } - - @Override - public @Nullable JComponent createComponent() { - settingsComponent = new SettingsComponent(); - initComponents(); - return settingsComponent.getContent(); - } - - private void initComponents() { - SettingsState settingsState = SettingsState.getInstance(); - Map translators = TranslatorService.getInstance().getTranslators(); - settingsComponent.setTranslators(translators); - settingsComponent.setSelectedTranslator(translators.get(settingsState.getSelectedTranslator().getKey())); - settingsComponent.setEnableCache(settingsState.isEnableCache()); - settingsComponent.setMaxCacheSize(settingsState.getMaxCacheSize()); - settingsComponent.setTranslationInterval(settingsState.getTranslationInterval()); - } - - @Override - public boolean isModified() { - SettingsState settingsState = SettingsState.getInstance(); - AbstractTranslator selectedTranslator = settingsComponent.getSelectedTranslator(); - boolean isChanged = settingsState.getSelectedTranslator() == selectedTranslator; - isChanged |= settingsState.getAppId(selectedTranslator.getKey()).equals(selectedTranslator.getAppId()); - isChanged |= settingsState.getAppKey(selectedTranslator.getKey()).equals(selectedTranslator.getAppKey()); - isChanged |= settingsState.isEnableCache() == settingsComponent.isEnableCache(); - isChanged |= settingsState.getMaxCacheSize() == settingsComponent.getMaxCacheSize(); - isChanged |= settingsState.getTranslationInterval() == settingsComponent.getTranslationInterval(); - LOG.info("isModified: " + isChanged); - return isChanged; - } - - @Override - public void apply() throws ConfigurationException { - SettingsState settingsState = SettingsState.getInstance(); - AbstractTranslator selectedTranslator = settingsComponent.getSelectedTranslator(); - LOG.info("apply selectedTranslator: " + selectedTranslator.getName()); - - // Verify that the required parameters are not configured - if (selectedTranslator.isNeedAppId() && StringUtil.isEmpty(settingsComponent.getAppId())) { - throw new ConfigurationException(selectedTranslator.getAppIdDisplay() + " not configured"); - } - if (selectedTranslator.isNeedAppKey() && StringUtil.isEmpty(settingsComponent.getAppKey())) { - throw new ConfigurationException(selectedTranslator.getAppKeyDisplay() + " not configured"); - } - - settingsState.setSelectedTranslator(selectedTranslator); - if (selectedTranslator.isNeedAppId()) { - settingsState.setAppId(selectedTranslator.getKey(), settingsComponent.getAppId()); - } - if (selectedTranslator.isNeedAppKey()) { - settingsState.setAppKey(selectedTranslator.getKey(), settingsComponent.getAppKey()); - } - settingsState.setEnableCache(settingsComponent.isEnableCache()); - settingsState.setMaxCacheSize(settingsComponent.getMaxCacheSize()); - settingsState.setTranslationInterval(settingsComponent.getTranslationInterval()); - - TranslatorService translatorService = TranslatorService.getInstance(); - translatorService.setSelectedTranslator(selectedTranslator); - translatorService.setEnableCache(settingsComponent.isEnableCache()); - translatorService.setMaxCacheSize(settingsComponent.getMaxCacheSize()); - translatorService.setTranslationInterval(settingsComponent.getTranslationInterval()); - } - - @Override - public void reset() { - LOG.info("reset"); - SettingsState settingsState = SettingsState.getInstance(); - AbstractTranslator selectedTranslator = settingsState.getSelectedTranslator(); - settingsComponent.setSelectedTranslator(selectedTranslator); - settingsComponent.setAppId(settingsState.getAppId(selectedTranslator.getKey())); - settingsComponent.setAppKey(settingsState.getAppKey(selectedTranslator.getKey())); - settingsComponent.setEnableCache(settingsState.isEnableCache()); - settingsComponent.setMaxCacheSize(settingsState.getMaxCacheSize()); - settingsComponent.setTranslationInterval(settingsState.getTranslationInterval()); - } - - @Override - public void disposeUIResources() { - settingsComponent = null; - } -} diff --git a/src/main/java/com/airsaid/localization/config/SettingsState.java b/src/main/java/com/airsaid/localization/config/SettingsState.java deleted file mode 100644 index 7e2f8eb..0000000 --- a/src/main/java/com/airsaid/localization/config/SettingsState.java +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.config; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.services.TranslatorService; -import com.airsaid.localization.utils.SecureStorage; -import com.intellij.openapi.components.*; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.util.text.StringUtil; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; - -/** - * @author airsaid - */ -@State( - name = "com.airsaid.localization.config.SettingsState", - storages = {@Storage("androidLocalizeSettings.xml")} -) -@Service -public final class SettingsState implements PersistentStateComponent { - - private static final Logger LOG = Logger.getInstance(SettingsState.class); - - private final Map appKeyStorage; - - private State state = new State(); - - public SettingsState() { - appKeyStorage = new HashMap<>(); - TranslatorService translatorService = TranslatorService.getInstance(); - Collection translators = translatorService.getTranslators().values(); - for (AbstractTranslator translator : translators) { - if (translatorService.getDefaultTranslator() != translator) { - appKeyStorage.put(translator.getKey(), new SecureStorage(translator.getKey())); - } - } - } - - public static SettingsState getInstance() { - return ServiceManager.getService(SettingsState.class); - } - - public void initSetting() { - TranslatorService translatorService = TranslatorService.getInstance(); - AbstractTranslator selectedTranslator = translatorService.getSelectedTranslator(); - if (selectedTranslator == null) { - LOG.info("initSetting"); - translatorService.setSelectedTranslator(getSelectedTranslator()); - translatorService.setEnableCache(isEnableCache()); - translatorService.setMaxCacheSize(getMaxCacheSize()); - translatorService.setTranslationInterval(getTranslationInterval()); - } - } - - public AbstractTranslator getSelectedTranslator() { - return StringUtil.isEmpty(state.selectedTranslatorKey) ? TranslatorService.getInstance().getDefaultTranslator() : - TranslatorService.getInstance().getTranslators().get(state.selectedTranslatorKey); - } - - public void setSelectedTranslator(AbstractTranslator translator) { - this.state.selectedTranslatorKey = translator.getKey(); - } - - public void setAppId(@NotNull String translatorKey, @NotNull String appId) { - state.appIds.put(translatorKey, appId); - } - - @NotNull - public String getAppId(String translatorKey) { - String appId = state.appIds.get(translatorKey); - return appId != null ? appId : ""; - } - - public void setAppKey(@NotNull String translatorKey, @NotNull String appKey) { - SecureStorage secureStorage = appKeyStorage.get(translatorKey); - if (secureStorage != null) { - secureStorage.save(appKey); - } - } - - @NotNull - public String getAppKey(@NotNull String translatorKey) { - SecureStorage secureStorage = appKeyStorage.get(translatorKey); - return secureStorage != null ? secureStorage.read() : ""; - } - - public boolean isEnableCache() { - return state.isEnableCache; - } - - public void setEnableCache(boolean isEnable) { - state.isEnableCache = isEnable; - } - - public int getMaxCacheSize() { - return state.maxCacheSize; - } - - public void setMaxCacheSize(int maxCacheSize) { - state.maxCacheSize = maxCacheSize; - } - - public int getTranslationInterval() { - return state.translationInterval; - } - - public void setTranslationInterval(int intervalTime) { - state.translationInterval = intervalTime; - } - - @Override - public @Nullable SettingsState.State getState() { - return state; - } - - @Override - public void loadState(@NotNull State state) { - this.state = state; - } - - static class State { - public String selectedTranslatorKey; - public Map appIds = new HashMap<>(); - public boolean isEnableCache = true; - public int maxCacheSize = 500; - public int translationInterval = 2; // 2 second - } -} diff --git a/src/main/java/com/airsaid/localization/services/AndroidValuesService.java b/src/main/java/com/airsaid/localization/services/AndroidValuesService.java deleted file mode 100644 index 438aa15..0000000 --- a/src/main/java/com/airsaid/localization/services/AndroidValuesService.java +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.services; - -import com.airsaid.localization.translate.lang.Lang; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.components.Service; -import com.intellij.openapi.components.ServiceManager; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.util.Computable; -import com.intellij.openapi.util.io.FileUtil; -import com.intellij.openapi.vfs.LocalFileSystem; -import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.psi.PsiDirectory; -import com.intellij.psi.PsiElement; -import com.intellij.psi.PsiFile; -import com.intellij.psi.PsiManager; -import com.intellij.psi.xml.XmlDocument; -import com.intellij.psi.xml.XmlFile; -import com.intellij.psi.xml.XmlTag; -import com.intellij.util.Consumer; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.*; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.regex.Pattern; - -/** - * Operation service for the android value files. eg: strings.xml (or any string resource from values directory). - * - * @author airsaid - */ -@Service -public final class AndroidValuesService { - - private static final Logger LOG = Logger.getInstance(AndroidValuesService.class); - - private static final Pattern STRINGS_FILE_NAME_PATTERN = Pattern.compile(".+\\.xml"); - - /** - * Returns the {@link AndroidValuesService} object instance. - * - * @return the {@link AndroidValuesService} object instance. - */ - public static AndroidValuesService getInstance() { - return ServiceManager.getService(AndroidValuesService.class); - } - - /** - * Asynchronous loading the value file as the {@link PsiElement} collection. - * - * @param valueFile the value file. - * @param consumer load result. called in the event dispatch thread. - */ - public void loadValuesByAsync(@NotNull PsiFile valueFile, @NotNull Consumer> consumer) { - ApplicationManager.getApplication().executeOnPooledThread(() -> { - List values = loadValues(valueFile); - ApplicationManager.getApplication().invokeLater(() -> - consumer.consume(values)); - } - ); - } - - /** - * Loading the value file as the {@link PsiElement} collection. - * - * @param valueFile the value file. - * @return {@link PsiElement} collection. - */ - public List loadValues(@NotNull PsiFile valueFile) { - return ApplicationManager.getApplication().runReadAction((Computable>) () -> { - LOG.info("loadValues valueFile: " + valueFile.getName()); - List values = parseValuesXml(valueFile); - LOG.info("loadValues parsed " + valueFile.getName() + " result: " + values); - return values; - }); - } - - private List parseValuesXml(@NotNull PsiFile valueFile) { - final List values = new ArrayList<>(); - final XmlFile xmlFile = (XmlFile) valueFile; - - final XmlDocument document = xmlFile.getDocument(); - if (document == null) return values; - - final XmlTag rootTag = document.getRootTag(); - if (rootTag == null) return values; - - PsiElement[] subTags = rootTag.getChildren(); - values.addAll(Arrays.asList(subTags)); - - return values; - } - - /** - * Write {@link PsiElement} collection data to the specified file. - * - * @param values specified {@link PsiElement} collection data. - * @param valueFile specified file. - */ - public void writeValueFile(@NotNull List values, @NotNull File valueFile) { - boolean isCreateSuccess = FileUtil.createIfDoesntExist(valueFile); - if (!isCreateSuccess) { - LOG.error("Failed to write to " + valueFile.getPath() + " file: create failed!"); - return; - } - ApplicationManager.getApplication().invokeLater(() -> ApplicationManager.getApplication().runWriteAction(() -> { - try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(valueFile, false), StandardCharsets.UTF_8))) { - for (PsiElement value : values) { - bw.write(value.getText()); - } - bw.flush(); - } catch (IOException e) { - e.printStackTrace(); - LOG.error("Failed to write to " + valueFile.getPath() + " file.", e); - } - })); - } - - /** - * Verify that the specified file is a string resource file in the values directory. - * - * @param file the verify file. - * @return true: the file is a string resource file in the values directory. - */ - public boolean isValueFile(@Nullable PsiFile file) { - if (file == null) return false; - - PsiDirectory parent = file.getParent(); - if (parent == null) return false; - - String parentName = parent.getName(); - if (!"values".equals(parentName)) return false; - - String fileName = file.getName(); - return STRINGS_FILE_NAME_PATTERN.matcher(fileName).matches(); - } - - /** - * Get the value file of the specified language in the specified project resource directory. - * - * @param project current project. - * @param resourceDir specified resource directory. - * @param lang specified language. - * @param fileName the name of value file. - * @return null if not exist, otherwise return the value file. - */ - @Nullable - public PsiFile getValuePsiFile(@NotNull Project project, - @NotNull VirtualFile resourceDir, - @NotNull Lang lang, - @NotNull String fileName) { - return ApplicationManager.getApplication().runReadAction((Computable) () -> { - VirtualFile virtualFile = LocalFileSystem.getInstance().findFileByIoFile(getValueFile(resourceDir, lang, fileName)); - if (virtualFile == null) { - return null; - } - return PsiManager.getInstance(project).findFile(virtualFile); - }); - } - - /** - * Get the value file in the {@code values} directory of the specified language in the resource directory. - * - * @param resourceDir specified resource directory. - * @param lang specified language. - * @param fileName the name of value file. - * @return the value file. - */ - @NotNull - public File getValueFile(@NotNull VirtualFile resourceDir, @NotNull Lang lang, String fileName) { - return new File(resourceDir.getPath().concat(File.separator).concat(getValuesDirectoryName(lang)), fileName); - } - - private String getValuesDirectoryName(@NotNull Lang lang) { - return "values-".concat(lang.getCode()); - } - - /** - * Returns whether the specified xml tag (string entry) needs to be translated. - * - * @param xmlTag the specified xml tag of string entry. - * @return true: need translation. false: no translation is needed. - */ - public boolean isTranslatable(@NotNull XmlTag xmlTag) { - return ApplicationManager.getApplication().runReadAction((Computable) () -> { - String translatableStr = xmlTag.getAttributeValue("translatable"); - return Boolean.parseBoolean(translatableStr == null ? "true" : translatableStr); - }); - } -} diff --git a/src/main/java/com/airsaid/localization/task/TranslateTask.java b/src/main/java/com/airsaid/localization/task/TranslateTask.java deleted file mode 100644 index 500869c..0000000 --- a/src/main/java/com/airsaid/localization/task/TranslateTask.java +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.task; - -import com.airsaid.localization.constant.Constants; -import com.airsaid.localization.services.AndroidValuesService; -import com.airsaid.localization.translate.TranslationException; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.translate.lang.Languages; -import com.airsaid.localization.translate.services.TranslatorService; -import com.airsaid.localization.utils.TextUtil; -import com.intellij.ide.util.PropertiesComponent; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.fileEditor.FileEditorManager; -import com.intellij.openapi.progress.ProgressIndicator; -import com.intellij.openapi.progress.Task; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.util.Computable; -import com.intellij.openapi.vfs.LocalFileSystem; -import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.psi.PsiElement; -import com.intellij.psi.PsiFile; -import com.intellij.psi.xml.*; -import org.jetbrains.annotations.Nls; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.function.Function; -import java.util.stream.Collectors; - -/** - * @author airsaid - */ -public class TranslateTask extends Task.Backgroundable { - - private static final String NAME_TAG_STRING = "string"; - private static final String NAME_TAG_PLURALS = "plurals"; - private static final String NAME_TAG_STRING_ARRAY = "string-array"; - - private static final Logger LOG = Logger.getInstance(TranslateTask.class); - - private final List mToLanguages; - private final List mValues; - private final VirtualFile mValueFile; - private final TranslatorService mTranslatorService; - private final AndroidValuesService mValueService; - - private OnTranslateListener mOnTranslateListener; - private TranslationException mTranslationError; - - public interface OnTranslateListener { - void onTranslateSuccess(); - - void onTranslateError(Throwable e); - } - - public TranslateTask(@Nullable Project project, @Nls @NotNull String title, List languages, - List values, PsiFile valueFile) { - super(project, title); - mToLanguages = languages; - mValues = values; - mValueFile = valueFile.getVirtualFile(); - mTranslatorService = TranslatorService.getInstance(); - mValueService = AndroidValuesService.getInstance(); - } - - /** - * Set translate result listener. - * - * @param listener callback interface. success or fail. - */ - public void setOnTranslateListener(OnTranslateListener listener) { - mOnTranslateListener = listener; - } - - @Override - public void run(@NotNull ProgressIndicator progressIndicator) { - boolean isOverwriteExistingString = PropertiesComponent.getInstance(myProject) - .getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING); - LOG.info("run isOverwriteExistingString: " + isOverwriteExistingString); - - for (Lang toLanguage : mToLanguages) { - if (progressIndicator.isCanceled()) break; - - progressIndicator.setText("Translation to " + toLanguage.getEnglishName() + "..."); - - VirtualFile resourceDir = mValueFile.getParent().getParent(); - String valueFileName = mValueFile.getName(); - PsiFile toValuePsiFile = mValueService.getValuePsiFile(myProject, resourceDir, toLanguage, valueFileName); - LOG.info("Translating language: " + toLanguage.getEnglishName() + ", toValuePsiFile: " + toValuePsiFile); - if (toValuePsiFile != null) { - List toValues = mValueService.loadValues(toValuePsiFile); - Map toValuesMap = toValues.stream().collect(Collectors.toMap( - psiElement -> { - if (psiElement instanceof XmlTag) - return ApplicationManager.getApplication().runReadAction((Computable) () -> - ((XmlTag) psiElement).getAttributeValue("name")); - else return UUID.randomUUID().toString(); - }, - Function.identity() - )); - List translatedValues = doTranslate(progressIndicator, toLanguage, toValuesMap, isOverwriteExistingString); - writeTranslatedValues(progressIndicator, new File(toValuePsiFile.getVirtualFile().getPath()), translatedValues); - } else { - List translatedValues = doTranslate(progressIndicator, toLanguage, null, isOverwriteExistingString); - File valueFile = mValueService.getValueFile(resourceDir, toLanguage, valueFileName); - writeTranslatedValues(progressIndicator, valueFile, translatedValues); - } - // If an exception occurs during the translation of the language, - // the translation of the subsequent languages is terminated. - // This prevents the loss of successfully translated strings in that language. - if (mTranslationError != null) { - throw mTranslationError; - } - } - } - - private List doTranslate(@NotNull ProgressIndicator progressIndicator, - @NotNull Lang toLanguage, - @Nullable Map toValues, - boolean isOverwrite) { - LOG.info("doTranslate toLanguage: " + toLanguage.getEnglishName() + ", toValues: " + toValues + ", isOverwrite: " + isOverwrite); - - List translatedValues = new ArrayList<>(); - for (PsiElement value : mValues) { - if (progressIndicator.isCanceled()) break; - - if (value instanceof XmlTag) { - XmlTag xmlTag = (XmlTag) value; - if (!mValueService.isTranslatable(xmlTag)) { - translatedValues.add(value); - continue; - } - - String name = ApplicationManager.getApplication().runReadAction((Computable) () -> - xmlTag.getAttributeValue("name") - ); - if (!isOverwrite && toValues != null && toValues.containsKey(name)) { - translatedValues.add(toValues.get(name)); - continue; - } - - XmlTag translateValue = ApplicationManager.getApplication().runReadAction((Computable) () -> - (XmlTag) xmlTag.copy() - ); - translatedValues.add(translateValue); - switch (translateValue.getName()) { - case NAME_TAG_STRING: - doTranslate(progressIndicator, toLanguage, translateValue); - break; - case NAME_TAG_STRING_ARRAY: - case NAME_TAG_PLURALS: - XmlTag[] subTags = ApplicationManager.getApplication() - .runReadAction((Computable) translateValue::getSubTags); - for (XmlTag subTag : subTags) { - doTranslate(progressIndicator, toLanguage, subTag); - } - break; - } - } else { - translatedValues.add(value); - } - } - return translatedValues; - } - - private void doTranslate(@NotNull ProgressIndicator progressIndicator, - @NotNull Lang toLanguage, - @NotNull XmlTag xmlTag) { - if (progressIndicator.isCanceled() || isXliffTag(xmlTag)) return; - - XmlTagValue xmlTagValue = ApplicationManager.getApplication() - .runReadAction((Computable) xmlTag::getValue); - XmlTagChild[] children = xmlTagValue.getChildren(); - for (XmlTagChild child : children) { - if (child instanceof XmlText) { - XmlText xmlText = (XmlText) child; - String text = ApplicationManager.getApplication() - .runReadAction((Computable) xmlText::getValue); - if (TextUtil.isEmptyOrSpacesLineBreak(text)) { - continue; - } - try { - String translatedText = mTranslatorService.doTranslate(Languages.AUTO, toLanguage, text); - ApplicationManager.getApplication().runReadAction(() -> xmlText.setValue(translatedText)); - } catch (TranslationException e) { - LOG.warn(e); - // Just catch the error and wait for that file to be translated and released. - mTranslationError = e; - } - } else if (child instanceof XmlTag) { - doTranslate(progressIndicator, toLanguage, (XmlTag) child); - } - } - } - - private void writeTranslatedValues(@NotNull ProgressIndicator progressIndicator, - @NotNull File valueFile, - @NotNull List translatedValues) { - LOG.info("writeTranslatedValues valueFile: " + valueFile + ", translatedValues: " + translatedValues); - - if (progressIndicator.isCanceled() || translatedValues.isEmpty()) return; - - progressIndicator.setText("Writing to " + valueFile.getParentFile().getName() + " data..."); - mValueService.writeValueFile(translatedValues, valueFile); - - refreshAndOpenFile(valueFile); - } - - private void refreshAndOpenFile(File file) { - VirtualFile virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file); - boolean isOpenTranslatedFile = PropertiesComponent.getInstance(myProject) - .getBoolean(Constants.KEY_IS_OPEN_TRANSLATED_FILE); - if (virtualFile != null && isOpenTranslatedFile) { - ApplicationManager.getApplication().invokeLater(() -> - FileEditorManager.getInstance(myProject).openFile(virtualFile, true)); - } - } - - private boolean isXliffTag(XmlTag xmlTag) { - return xmlTag != null && "xliff:g".equals(xmlTag.getName()); - } - - @Override - public void onSuccess() { - super.onSuccess(); - translateSuccess(); - } - - @Override - public void onThrowable(@NotNull Throwable error) { - super.onThrowable(error); - translateError(error); - } - - private void translateSuccess() { - if (mOnTranslateListener != null) { - mOnTranslateListener.onTranslateSuccess(); - } - } - - private void translateError(Throwable error) { - if (mOnTranslateListener != null) { - mOnTranslateListener.onTranslateError(error); - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/AbstractTranslator.java b/src/main/java/com/airsaid/localization/translate/AbstractTranslator.java deleted file mode 100644 index 7e40199..0000000 --- a/src/main/java/com/airsaid/localization/translate/AbstractTranslator.java +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate; - -import com.airsaid.localization.config.SettingsState; -import com.airsaid.localization.translate.lang.Lang; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.util.Pair; -import com.intellij.util.io.HttpRequests; -import com.intellij.util.io.RequestBuilder; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.stream.Collectors; - -/** - * @author airsaid - */ -public abstract class AbstractTranslator implements Translator, TranslatorConfigurable { - - protected static final Logger LOG = Logger.getInstance(AbstractTranslator.class); - - private static final String CONTENT_TYPE = "application/x-www-form-urlencoded"; - - @Override - public String doTranslate(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) throws TranslationException { - checkSupportedLanguages(fromLang, toLang, text); - - String requestUrl = getRequestUrl(fromLang, toLang, text); - RequestBuilder requestBuilder = HttpRequests.post(requestUrl, CONTENT_TYPE); - // Set the timeout time to 60 seconds. - requestBuilder.connectTimeout(60 * 1000); - configureRequestBuilder(requestBuilder); - - try { - return requestBuilder.connect(request -> { - String requestParams = getRequestParams(fromLang, toLang, text) - .stream() - .map(pair -> { - return pair.first.concat("=").concat(URLEncoder.encode(pair.second, StandardCharsets.UTF_8)); - }) - .collect(Collectors.joining("&")); - if (!requestParams.isEmpty()) { - request.write(requestParams); - } - String requestBody = getRequestBody(fromLang, toLang, text); - if (!requestBody.isEmpty()) { - request.write(requestBody); - } - - String resultText = request.readString(); - return parsingResult(fromLang, toLang, text, resultText); - }); - } catch (Exception e) { - e.printStackTrace(); - LOG.error(e.getMessage(), e); - throw new TranslationException(fromLang, toLang, text, e); - } - } - - @Override - public @Nullable Icon getIcon() { - return null; - } - - @Override - public boolean isNeedAppId() { - return true; - } - - @Override - public @Nullable String getAppId() { - return SettingsState.getInstance().getAppId(getKey()); - } - - @Override - public String getAppIdDisplay() { - return "APP ID"; - } - - @Override - public boolean isNeedAppKey() { - return true; - } - - @Override - public @Nullable String getAppKey() { - return SettingsState.getInstance().getAppKey(getKey()); - } - - @Override - public String getAppKeyDisplay() { - return "APP KEY"; - } - - @Override - public @Nullable String getApplyAppIdUrl() { - return null; - } - - @NotNull - public String getRequestUrl(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - throw new UnsupportedOperationException(); - } - - @NotNull - public List> getRequestParams(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return List.of(); - } - - @NotNull - public String getRequestBody(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return ""; - } - - public void configureRequestBuilder(@NotNull RequestBuilder requestBuilder) { - - } - - @NotNull - public String parsingResult(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text, @NotNull String resultText) { - throw new UnsupportedOperationException(); - } - - protected void checkSupportedLanguages(Lang fromLang, Lang toLang, String text) { - List supportedLanguages = getSupportedLanguages(); - if (!supportedLanguages.contains(toLang)) { - throw new TranslationException(fromLang, toLang, text, toLang.getEnglishName() + " is not supported."); - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/TranslationException.java b/src/main/java/com/airsaid/localization/translate/TranslationException.java deleted file mode 100644 index 81b4982..0000000 --- a/src/main/java/com/airsaid/localization/translate/TranslationException.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate; - -import com.airsaid.localization.translate.lang.Lang; -import com.intellij.openapi.diagnostic.Logger; - -import org.apache.http.HttpException; -import org.jetbrains.annotations.NotNull; - -/** - * @author airsaid - */ -public class TranslationException extends RuntimeException { - - private static final Logger LOG = Logger.getInstance(TranslationException.class); - - private final Lang fromLang; - private final Lang toLang; - private final String text; - - public TranslationException(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text, @NotNull Throwable cause) { - super("Failed to translate \"" + text + "\" from " + fromLang.getEnglishName() + - " to " + toLang.getEnglishName() + " with error: " + cause.getMessage(), cause); - this.fromLang = fromLang; - this.toLang = toLang; - this.text = text; - cause.printStackTrace(); - LOG.error("TranslationException: " + cause.getMessage(), cause); - } - - public TranslationException(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text, String message) { - super("Failed to translate \"" + text + "\" from " + fromLang.getEnglishName() + - " to " + toLang.getEnglishName() + " with error: " + message); - this.fromLang = fromLang; - this.toLang = toLang; - this.text = text; - LOG.error("TranslationException: ", message); - } - - public Lang getFromLang() { - return fromLang; - } - - public Lang getToLang() { - return toLang; - } - - public String getText() { - return text; - } -} diff --git a/src/main/java/com/airsaid/localization/translate/TranslatorConfigurable.java b/src/main/java/com/airsaid/localization/translate/TranslatorConfigurable.java deleted file mode 100644 index ca2eac2..0000000 --- a/src/main/java/com/airsaid/localization/translate/TranslatorConfigurable.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate; - -import com.airsaid.localization.translate.lang.Lang; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.util.List; - -/** - * @author airsaid - */ -public interface TranslatorConfigurable { - - @NotNull - String getKey(); - - @NotNull - String getName(); - - @Nullable - Icon getIcon(); - - @NotNull - List getSupportedLanguages(); - - boolean isNeedAppId(); - - @Nullable - String getAppId(); - - String getAppIdDisplay(); - - boolean isNeedAppKey(); - - @Nullable - String getAppKey(); - - String getAppKeyDisplay(); - - @Nullable - String getApplyAppIdUrl(); -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/ali/AliTranslator.java b/src/main/java/com/airsaid/localization/translate/impl/ali/AliTranslator.java deleted file mode 100644 index b8d8067..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/ali/AliTranslator.java +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.ali; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.TranslationException; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.translate.lang.Languages; -import com.aliyun.alimt20181012.Client; -import com.aliyun.alimt20181012.models.TranslateGeneralRequest; -import com.aliyun.alimt20181012.models.TranslateGeneralResponse; -import com.aliyun.alimt20181012.models.TranslateGeneralResponseBody; -import com.aliyun.teaopenapi.models.Config; -import com.aliyun.teautil.models.RuntimeOptions; -import com.google.auto.service.AutoService; -import icons.PluginIcons; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.util.LinkedList; -import java.util.List; - -/** - * @author airsaid - */ -@AutoService(AbstractTranslator.class) -public class AliTranslator extends AbstractTranslator { - private static final String KEY = "Ali"; - private static final String ENDPOINT = "mt.aliyuncs.com"; - private static final String APPLY_APP_ID_URL = "https://www.aliyun.com/product/ai/base_alimt"; - - private final Config config = new Config(); - private List supportedLanguages; - private Client client; - - @Override - public @NotNull String getKey() { - return KEY; - } - - @Override - public @NotNull String getName() { - return "Ali"; - } - - @Override - public @Nullable Icon getIcon() { - return PluginIcons.ALI_ICON; - } - - @Override - public @NotNull List getSupportedLanguages() { - if (supportedLanguages == null) { - supportedLanguages = new LinkedList<>(); - final List languages = Languages.getLanguages(); - for (int i = 1; i < languages.size(); i++) { - Lang lang = languages.get(i); - if (lang.equals(Languages.UKRAINIAN) || lang.equals(Languages.DARI)) { - continue; - } - if (lang.equals(Languages.CHINESE_SIMPLIFIED)) { - lang = lang.setTranslationCode("zh"); - } else if (lang.equals(Languages.CHINESE_TRADITIONAL)) { - lang = lang.setTranslationCode("zh-tw"); - } else if (lang.equals(Languages.INDONESIAN)) { - lang = lang.setTranslationCode("id"); - } else if (lang.equals(Languages.CROATIAN)) { - lang = lang.setTranslationCode("hbs"); - } else if (lang.equals(Languages.HEBREW)) { - lang = lang.setTranslationCode("he"); - } - supportedLanguages.add(lang); - } - } - return supportedLanguages; - } - - @Override - public String getAppIdDisplay() { - return "AccessKey ID"; - } - - @Override - public String getAppKeyDisplay() { - return "AccessKey Secret"; - } - - @Override - public @Nullable String getApplyAppIdUrl() { - return APPLY_APP_ID_URL; - } - - @Override - public String doTranslate(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) throws TranslationException { - checkSupportedLanguages(fromLang, toLang, text); - - config.setAccessKeyId(getAppId()).setAccessKeySecret(getAppKey()).setEndpoint(ENDPOINT); - if (client == null) { - try { - client = new Client(config); - } catch (Exception e) { - throw new TranslationException(fromLang, toLang, text, e); - } - } - - TranslateGeneralRequest request = new TranslateGeneralRequest() - .setFormatType("text") - .setSourceLanguage(fromLang.getTranslationCode()) - .setTargetLanguage(toLang.getTranslationCode()) - .setSourceText(text) - .setScene("general"); - RuntimeOptions runtime = new RuntimeOptions(); - TranslateGeneralResponse response; - try { - response = client.translateGeneralWithOptions(request, runtime); - } catch (Exception e) { - throw new TranslationException(fromLang, toLang, text, e); - } - final TranslateGeneralResponseBody body = response.body; - if (body.getCode() == 200) { - return body.getData().translated; - } else { - throw new TranslationException(fromLang, toLang, text, body.getMessage() + "(" + body.getCode() + ")"); - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.java b/src/main/java/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.java deleted file mode 100644 index 53675f4..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.java +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.baidu; - -import com.airsaid.localization.translate.TranslationResult; -import com.google.gson.annotations.SerializedName; -import com.intellij.openapi.util.text.StringUtil; -import org.jetbrains.annotations.NotNull; - -import java.util.List; -import java.util.Objects; - -/** - * @author airsaid - */ -public class BaiduTranslationResult implements TranslationResult { - private String from; - private String to; - @SerializedName("trans_result") - private List contents; - - @SerializedName("error_code") - private String errorCode; - @SerializedName("error_msg") - private String errorMsg; - - public String getFrom() { - return from; - } - - public void setFrom(String from) { - this.from = from; - } - - public String getTo() { - return to; - } - - public void setTo(String to) { - this.to = to; - } - - public List getContents() { - return contents; - } - - public void setContents(List contents) { - this.contents = contents; - } - - public String getErrorCode() { - return errorCode; - } - - public void setErrorCode(String errorCode) { - this.errorCode = errorCode; - } - - public String getErrorMsg() { - return errorMsg; - } - - public void setErrorMsg(String errorMsg) { - this.errorMsg = errorMsg; - } - - public boolean isSuccess() { - String errorCode = getErrorCode(); - return StringUtil.isEmpty(errorCode) || "52000".equals(getErrorCode()); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - BaiduTranslationResult that = (BaiduTranslationResult) o; - return Objects.equals(from, that.from) && Objects.equals(to, that.to) && Objects.equals(contents, that.contents) && Objects.equals(errorCode, that.errorCode) && Objects.equals(errorMsg, that.errorMsg); - } - - @Override - public int hashCode() { - return Objects.hash(from, to, contents, errorCode, errorMsg); - } - - @Override - public @NotNull String getTranslationResult() { - List contents = getContents(); - if (contents == null || contents.isEmpty()) { - return ""; - } - String dst = contents.get(0).getDst(); - return dst != null ? dst : ""; - } - - @Override - public String toString() { - return "BaiduTranslationResult{" + - "from='" + from + '\'' + - ", to='" + to + '\'' + - ", contents=" + contents + - ", errorCode='" + errorCode + '\'' + - ", errorMsg='" + errorMsg + '\'' + - '}'; - } - - public static class Content { - private String src; - private String dst; - - public String getSrc() { - return src; - } - - public void setSrc(String src) { - this.src = src; - } - - public String getDst() { - return dst; - } - - public void setDst(String dst) { - this.dst = dst; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Content content = (Content) o; - return Objects.equals(src, content.src) && Objects.equals(dst, content.dst); - } - - @Override - public int hashCode() { - return Objects.hash(src, dst); - } - - @Override - public String toString() { - return "Content{" + - "src='" + src + '\'' + - ", dst='" + dst + '\'' + - '}'; - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.java b/src/main/java/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.java deleted file mode 100644 index f084a1a..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.java +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.baidu; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.TranslationException; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.translate.lang.Languages; -import com.airsaid.localization.translate.util.GsonUtil; -import com.airsaid.localization.translate.util.MD5; -import com.google.auto.service.AutoService; -import com.intellij.openapi.util.Pair; -import com.intellij.util.io.RequestBuilder; -import icons.PluginIcons; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.util.ArrayList; -import java.util.List; - -/** - * @author airsaid - */ -@AutoService(AbstractTranslator.class) -public class BaiduTranslator extends AbstractTranslator { - private static final String KEY = "Baidu"; - private static final String HOST_URL = "http://api.fanyi.baidu.com"; - private static final String TRANSLATE_URL = HOST_URL.concat("/api/trans/vip/translate"); - private static final String APPLY_APP_ID_URL = "http://api.fanyi.baidu.com/api/trans/product/desktop?req=developer"; - - private List supportedLanguages; - - @Override - public @NotNull String getKey() { - return KEY; - } - - @Override - public @NotNull String getName() { - return "Baidu"; - } - - @Override - public @Nullable Icon getIcon() { - return PluginIcons.BAIDU_ICON; - } - - @Override - public @NotNull List getSupportedLanguages() { - if (supportedLanguages == null) { - supportedLanguages = new ArrayList<>(); - supportedLanguages.add(Languages.CHINESE_SIMPLIFIED.setTranslationCode("zh")); - supportedLanguages.add(Languages.ENGLISH); - supportedLanguages.add(Languages.JAPANESE.setTranslationCode("jp")); - supportedLanguages.add(Languages.KOREAN.setTranslationCode("kor")); - supportedLanguages.add(Languages.FRENCH.setTranslationCode("fra")); - supportedLanguages.add(Languages.SPANISH.setTranslationCode("spa")); - supportedLanguages.add(Languages.THAI); - supportedLanguages.add(Languages.ARABIC.setTranslationCode("ara")); - supportedLanguages.add(Languages.RUSSIAN); - supportedLanguages.add(Languages.PORTUGUESE); - supportedLanguages.add(Languages.GERMAN); - supportedLanguages.add(Languages.ITALIAN); - supportedLanguages.add(Languages.GREEK); - supportedLanguages.add(Languages.DUTCH); - supportedLanguages.add(Languages.POLISH); - supportedLanguages.add(Languages.BULGARIAN.setTranslationCode("bul")); - supportedLanguages.add(Languages.ESTONIAN.setTranslationCode("est")); - supportedLanguages.add(Languages.DANISH.setTranslationCode("dan")); - supportedLanguages.add(Languages.FINNISH.setTranslationCode("fin")); - supportedLanguages.add(Languages.CZECH); - supportedLanguages.add(Languages.ROMANIAN.setTranslationCode("rom")); - supportedLanguages.add(Languages.SLOVENIAN.setTranslationCode("slo")); - supportedLanguages.add(Languages.SWEDISH.setTranslationCode("swe")); - supportedLanguages.add(Languages.HUNGARIAN); - supportedLanguages.add(Languages.CHINESE_TRADITIONAL.setTranslationCode("cht")); - supportedLanguages.add(Languages.VIETNAMESE.setTranslationCode("vie")); - } - return supportedLanguages; - } - - @Override - public @Nullable String getApplyAppIdUrl() { - return APPLY_APP_ID_URL; - } - - @Override - public @NotNull String getRequestUrl(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return TRANSLATE_URL; - } - - @Override - public @NotNull List> getRequestParams(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - String salt = String.valueOf(System.currentTimeMillis()); - String appId = getAppId(); - String securityKey = getAppKey(); - String sign = MD5.md5(appId + text + salt + securityKey); - List> params = new ArrayList<>(); - params.add(Pair.create("from", fromLang.getTranslationCode())); - params.add(Pair.create("to", toLang.getTranslationCode())); - params.add(Pair.create("appid", appId)); - params.add(Pair.create("salt", salt)); - params.add(Pair.create("sign", sign)); - params.add(Pair.create("q", text)); - return params; - } - - @Override - public void configureRequestBuilder(@NotNull RequestBuilder requestBuilder) { - requestBuilder.tuner(connection -> connection.setRequestProperty("Referer", HOST_URL)); - } - - @Override - public @NotNull String parsingResult(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text, @NotNull String resultText) { - LOG.info("parsingResult: " + resultText); - BaiduTranslationResult baiduTranslationResult = GsonUtil.getInstance().getGson().fromJson(resultText, BaiduTranslationResult.class); - if (baiduTranslationResult.isSuccess()) { - return baiduTranslationResult.getTranslationResult(); - } else { - String message = baiduTranslationResult.getErrorMsg().concat("(").concat(baiduTranslationResult.getErrorCode()).concat(")"); - throw new TranslationException(fromLang, toLang, text, message); - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.java b/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.java deleted file mode 100644 index ba122bd..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.deepl; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.lang.Lang; -import com.google.auto.service.AutoService; -import org.jetbrains.annotations.NotNull; - -/** - * @author airsaid - */ -@AutoService(AbstractTranslator.class) -public class DeepLProTranslator extends DeepLTranslator { - - private static final String KEY = "DeepLPro"; - private static final String HOST_URL = "https://api.deepl.com/v2"; - private static final String TRANSLATE_URL = HOST_URL.concat("/translate"); - - @Override - public @NotNull String getKey() { - return KEY; - } - - @Override - public @NotNull String getName() { - return KEY; - } - - @Override - public @NotNull String getRequestUrl(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return TRANSLATE_URL; - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.java b/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.java deleted file mode 100644 index 1f5a6af..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.deepl; - -import com.airsaid.localization.translate.TranslationResult; -import org.jetbrains.annotations.NotNull; - -import java.util.List; -import java.util.Objects; - -/** - * @author musagil - */ -public class DeepLTranslationResult implements TranslationResult { - - private List translations; - - @Override - public @NotNull String getTranslationResult() { - if (translations != null && !translations.isEmpty()) { - String result = translations.get(0).getText(); - return result != null ? result : ""; - } - return ""; - } - - public List getTranslations() { - return translations; - } - - public void setTranslations(List translations) { - this.translations = translations; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - DeepLTranslationResult that = (DeepLTranslationResult) o; - return Objects.equals(translations, that.translations); - } - - @Override - public int hashCode() { - return Objects.hash(translations); - } - - @Override - public String toString() { - return "DeepLTranslationResult{" + - "translations=" + translations + - '}'; - } - - public static class Translation { - private String text; - private String to; - - public String getText() { - return text; - } - - public void setText(String text) { - this.text = text; - } - - public String getTo() { - return to; - } - - public void setTo(String to) { - this.to = to; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Translation that = (Translation) o; - return Objects.equals(text, that.text) && Objects.equals(to, that.to); - } - - @Override - public int hashCode() { - return Objects.hash(text, to); - } - - @Override - public String toString() { - return "Translation{" + - "text='" + text + '\'' + - ", to='" + to + '\'' + - '}'; - } - } - - public static class Error { - private String code; - private String message; - - public String getCode() { - return code; - } - - public void setCode(String code) { - this.code = code; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Error error = (Error) o; - return Objects.equals(code, error.code) && Objects.equals(message, error.message); - } - - @Override - public int hashCode() { - return Objects.hash(code, message); - } - - @Override - public String toString() { - return "Error{" + - "code='" + code + '\'' + - ", message='" + message + '\'' + - '}'; - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.java b/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.java deleted file mode 100644 index febad56..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.java +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.deepl; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.translate.lang.Languages; -import com.airsaid.localization.translate.util.GsonUtil; -import com.airsaid.localization.translate.util.UrlBuilder; -import com.google.auto.service.AutoService; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.util.Pair; -import com.intellij.util.io.RequestBuilder; -import icons.PluginIcons; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.util.ArrayList; -import java.util.List; - -/** - * @author musagil - */ -@AutoService(AbstractTranslator.class) -public class DeepLTranslator extends AbstractTranslator { - - private static final Logger LOG = Logger.getInstance(DeepLTranslator.class); - - private static final String KEY = "DeepL"; - private static final String HOST_URL = "https://api-free.deepl.com/v2"; - private static final String TRANSLATE_URL = HOST_URL.concat("/translate"); - private static final String APPLY_APP_ID_URL = "https://www.deepl.com/pro-api?cta=header-pro-api/"; - - private List supportedLanguages; - - @Override - public @NotNull String getKey() { - return KEY; - } - - @Override - public @NotNull String getName() { - return "DeepL"; - } - - @Override - public @Nullable Icon getIcon() { - return PluginIcons.DEEP_L_ICON; - } - - @Override - public boolean isNeedAppId() { - return false; - } - - @Override - public @NotNull List getSupportedLanguages() { - if (supportedLanguages == null) { - supportedLanguages = new ArrayList<>(); - supportedLanguages.add(Languages.BULGARIAN); - supportedLanguages.add(Languages.CZECH); - supportedLanguages.add(Languages.DANISH); - supportedLanguages.add(Languages.GERMAN); - supportedLanguages.add(Languages.GREEK); - supportedLanguages.add(new Lang(118, "en-gb", "English (British)", "English (British)")); - supportedLanguages.add(new Lang(119, "en-us", "English (American)", "English (American)")); - supportedLanguages.add(Languages.SPANISH); - supportedLanguages.add(Languages.ESTONIAN); - supportedLanguages.add(Languages.FINNISH); - supportedLanguages.add(Languages.FRENCH); - supportedLanguages.add(Languages.HUNGARIAN); - supportedLanguages.add(new Lang(98, "id", "Indonesia", "Indonesian")); - supportedLanguages.add(Languages.ITALIAN); - supportedLanguages.add(Languages.JAPANESE); - supportedLanguages.add(Languages.KOREAN.setTranslationCode("KO")); - supportedLanguages.add(Languages.LITHUANIAN); - supportedLanguages.add(Languages.LATVIAN); - supportedLanguages.add(Languages.NORWEGIAN.setTranslationCode("NB")); - supportedLanguages.add(Languages.DUTCH); - supportedLanguages.add(Languages.POLISH); - supportedLanguages.add(new Lang(120, "pt-br", "Portuguese (Brazilian)", "Portuguese (Brazilian)")); - supportedLanguages.add(new Lang(121, "pt-pt", "Portuguese (European)", "Portuguese (European)")); - supportedLanguages.add(Languages.ROMANIAN); - supportedLanguages.add(Languages.RUSSIAN); - supportedLanguages.add(Languages.SLOVAK); - supportedLanguages.add(Languages.SLOVENIAN); - supportedLanguages.add(Languages.SWEDISH); - supportedLanguages.add(Languages.TURKISH); - supportedLanguages.add(Languages.UKRAINIAN); - supportedLanguages.add(new Lang(104, "zh", "简体中文", "Chinese Simplified")); - } - return supportedLanguages; - } - - @Override - public String getAppKeyDisplay() { - return "KEY"; - } - - @Override - public @Nullable String getApplyAppIdUrl() { - return APPLY_APP_ID_URL; - } - - @Override - public @NotNull String getRequestUrl(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return new UrlBuilder(TRANSLATE_URL).build(); - } - - @Override - public @NotNull List> getRequestParams(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - List> params = new ArrayList<>(); - params.add(Pair.create("text", text)); - params.add(Pair.create("target_lang", toLang.getCode())); - return params; - } - - @Override - public void configureRequestBuilder(@NotNull RequestBuilder requestBuilder) { - requestBuilder.tuner(connection -> { - connection.setRequestProperty("Authorization", "DeepL-Auth-Key " + getAppKey()); - connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); - }); - } - - @Override - public @NotNull String parsingResult(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text, @NotNull String resultText) { - LOG.info("parsingResult: " + resultText); - return GsonUtil.getInstance().getGson().fromJson(resultText, DeepLTranslationResult.class).getTranslationResult(); - } - -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.java b/src/main/java/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.java deleted file mode 100644 index 2cb33b6..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.google; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.translate.lang.Languages; -import icons.PluginIcons; -import org.jetbrains.annotations.NotNull; - -import javax.swing.*; -import java.util.ArrayList; -import java.util.List; - -/** - * @author airsaid - */ -public abstract class AbsGoogleTranslator extends AbstractTranslator { - - protected List supportedLanguages; - - @Override - public @NotNull Icon getIcon() { - return PluginIcons.GOOGLE_ICON; - } - - @Override - @NotNull - public List getSupportedLanguages() { - if (supportedLanguages == null) { - List languages = Languages.getLanguages(); - supportedLanguages = new ArrayList<>(104); - for (int i = 1; i <= 104; i++) { - Lang lang = languages.get(i); - if (lang.equals(Languages.CHINESE_SIMPLIFIED)) { - lang = lang.setTranslationCode("zh-CN"); - } else if (lang.equals(Languages.CHINESE_TRADITIONAL)) { - lang = lang.setTranslationCode("zh-TW"); - } else if (lang.equals(Languages.FILIPINO)) { - lang = lang.setTranslationCode("tl"); - } else if (lang.equals(Languages.INDONESIAN)) { - lang = lang.setTranslationCode("id"); - } else if (lang.equals(Languages.JAVANESE)) { - lang = lang.setTranslationCode("jw"); - } - supportedLanguages.add(lang); - } - } - return supportedLanguages; - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/google/GoogleToken.java b/src/main/java/com/airsaid/localization/translate/impl/google/GoogleToken.java deleted file mode 100644 index 0349564..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/google/GoogleToken.java +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.google; - -import com.airsaid.localization.translate.util.AgentUtil; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.util.Pair; -import com.intellij.util.io.HttpRequests; - -import java.util.ArrayList; -import java.util.List; -import java.util.Random; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -/** - * @author airsaid - */ -public class GoogleToken { - - private static final Logger LOG = Logger.getInstance(GoogleToken.class); - - private static final int MIM = 60 * 60 * 1000; - private static final Random GENERATOR = new Random(); - private static final Pattern TKK_PATTERN = Pattern.compile("tkk='(\\d+).(-?\\d+)'"); - private static final String ELEMENT_URL = "%s/translate_a/element.js"; - - private static Pair sInnerValue = Pair.create(0L, 0L); - private static boolean sNeedUpdate = true; - - public static String getToken(String text) { - return getToken(text, getDefaultTKK()); - } - - public static String getToken(String text, Pair tkk) { - int length = text.length(); - List a = new ArrayList<>(); - int b = 0; - char[] ch = text.toCharArray(); - while (b < length) { - int c = ch[b]; - if (128 > c) { - a.add((long) c); - } else { - if (2048 > c) { - a.add((long) (c >> 6 | 192)); - } else { - if (55296 == (c & 64512) && b + 1 < length && 56320 == (ch[b + 1] & 64512)) { - c = 65536 + ((c & 1023) << 10) + (ch[++b] & 1023); - a.add((long) (c >> 18 | 240)); - a.add((long) (c >> 12 & 63 | 128)); - } else { - a.add((long) (c >> 12 | 224)); - } - a.add((long) (c >> 6 & 63 | 128)); - } - a.add((long) (c & 63 | 128)); - } - b++; - } - - long d = tkk.first; - long e = tkk.second; - long f = d; - for (Long h : a) { - f += h; - f = fun(f, "+-a^+6"); - } - - f = fun(f, "+-3^+b+-f"); - f = f ^ e; - if (0 > f) { - f = (f & Integer.MAX_VALUE) + Integer.MAX_VALUE + 1; - } - f = (long) (f % 1E6); - - return f + "." + (f ^ d); - } - - private static Long fun(Long a, String b) { - long g = a; - char[] ch = b.toCharArray(); - for (int c = 0; c < ch.length - 1; c += 3) { - char d = ch[c + 2]; - int e = 'a' <= d ? (d - 87) : d - '0'; - long f = '+' == ch[c + 1] ? g >>> e : g << e; - g = '+' == ch[c] ? g + f & ((long) Integer.MAX_VALUE * 2 + 1) : g ^ f; - } - return g; - } - - private static Pair getDefaultTKK() { - long now = System.currentTimeMillis() / MIM; - long curVal = sInnerValue.first; - if (!sNeedUpdate && now == curVal) { - return sInnerValue; - } - - Pair newTKK = getTKKFromGoogle(); - sNeedUpdate = newTKK == null; - sInnerValue = newTKK != null ? newTKK : Pair.create(now, Math.abs((long) GENERATOR.nextInt()) + (long) GENERATOR.nextInt()); - - return sInnerValue; - } - - private static Pair getTKKFromGoogle() { - try { - String url = String.format(ELEMENT_URL, GoogleTranslator.HOST_URL); - LOG.info("getTKKFromGoogle url: " + url); - String elementJs = HttpRequests.request(url) - .userAgent(AgentUtil.getUserAgent()) - .tuner(connection -> connection.setRequestProperty("Referer", GoogleTranslator.HOST_URL)) - .readString(); - Matcher matcher = TKK_PATTERN.matcher(elementJs); - if (matcher.find()) { - long value1 = Long.parseLong(matcher.group(1)); - long value2 = Long.parseLong(matcher.group(2)); - LOG.info(String.format("TKK: %d.%d", value1, value2)); - return Pair.create(value1, value1); - } - } catch (Exception e) { - e.printStackTrace(); - LOG.warn("TKK get failed.", e); - } - return null; - } - -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.java b/src/main/java/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.java deleted file mode 100644 index 09341e3..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.java +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.google; - -import com.airsaid.localization.translate.TranslationResult; -import com.google.gson.annotations.SerializedName; -import org.jetbrains.annotations.NotNull; - -import java.util.List; -import java.util.Objects; - -/** - * @author airsaid - */ -public class GoogleTranslationResult implements TranslationResult { - @SerializedName("src") - private String sourceCode; - - private List sentences; - - public GoogleTranslationResult() { - - } - - public GoogleTranslationResult(String sourceCode, List sentences) { - this.sourceCode = sourceCode; - this.sentences = sentences; - } - - public String getSourceCode() { - return sourceCode; - } - - public void setSourceCode(String sourceCode) { - this.sourceCode = sourceCode; - } - - public List getSentences() { - return sentences; - } - - public void setSentences(List sentences) { - this.sentences = sentences; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - GoogleTranslationResult that = (GoogleTranslationResult) o; - return Objects.equals(sourceCode, that.sourceCode) && Objects.equals(sentences, that.sentences); - } - - @Override - public int hashCode() { - return Objects.hash(sourceCode, sentences); - } - - @Override - public String toString() { - return "GoogleTranslationResult{" + - "sourceCode='" + sourceCode + '\'' + - ", sentences=" + sentences + - '}'; - } - - @Override - public @NotNull String getTranslationResult() { - List sentences = getSentences(); - if (sentences == null || sentences.isEmpty()) { - return ""; - } - StringBuilder result = new StringBuilder(); - for (Sentences sentence : sentences) { - String trans = sentence.getTrans(); - if (trans != null) result.append(trans); - } - return result.toString(); - } - - public static class Sentences { - private String trans; - - @SerializedName("orig") - private String origin; - - private int backend; - - public Sentences() { - - } - - public Sentences(String trans, String origin, int backend) { - this.trans = trans; - this.origin = origin; - this.backend = backend; - } - - public String getTrans() { - return trans; - } - - public void setTrans(String trans) { - this.trans = trans; - } - - public String getOrigin() { - return origin; - } - - public void setOrigin(String origin) { - this.origin = origin; - } - - public int getBackend() { - return backend; - } - - public void setBackend(int backend) { - this.backend = backend; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Sentences sentences = (Sentences) o; - return backend == sentences.backend && Objects.equals(trans, sentences.trans) && Objects.equals(origin, sentences.origin); - } - - @Override - public int hashCode() { - return Objects.hash(trans, origin, backend); - } - - @Override - public String toString() { - return "Sentences{" + - "trans='" + trans + '\'' + - ", origin='" + origin + '\'' + - ", backend=" + backend + - '}'; - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/google/GoogleTranslator.java b/src/main/java/com/airsaid/localization/translate/impl/google/GoogleTranslator.java deleted file mode 100644 index 6b3a292..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/google/GoogleTranslator.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.google; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.translate.util.AgentUtil; -import com.airsaid.localization.translate.util.GsonUtil; -import com.airsaid.localization.translate.util.UrlBuilder; -import com.google.auto.service.AutoService; -import com.intellij.openapi.util.Pair; -import com.intellij.util.io.RequestBuilder; -import org.jetbrains.annotations.NotNull; - -import java.util.ArrayList; -import java.util.List; - -/** - * @author airsaid - */ -@AutoService(AbstractTranslator.class) -public class GoogleTranslator extends AbsGoogleTranslator { - public static final String KEY = "Google"; - - public static final String HOST_URL = "https://translate.googleapis.com"; - private static final String BASE_URL = HOST_URL.concat("/translate_a/single"); - - @Override - public @NotNull String getKey() { - return KEY; - } - - @Override - public @NotNull String getName() { - return "Google"; - } - - @Override - public boolean isNeedAppId() { - return false; - } - - @Override - public boolean isNeedAppKey() { - return false; - } - - @Override - public @NotNull String getRequestUrl(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return new UrlBuilder(BASE_URL) - .addQueryParameter("sl", fromLang.getTranslationCode()) // source language code (auto for auto detection) - .addQueryParameter("tl", toLang.getTranslationCode()) // translation language - .addQueryParameter("client", "gtx") // client of request (guess) - .addQueryParameters("dt", "t") // specify what to return - .addQueryParameter("dj", "1") // json response with names - .addQueryParameter("ie", "UTF-8") // input encoding - .addQueryParameter("oe", "UTF-8") // output encoding - .addQueryParameter("tk", GoogleToken.getToken(text)) // translate token - .build(); - } - - @Override - public @NotNull List> getRequestParams(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - List> params = new ArrayList<>(); - params.add(Pair.create("q", text)); - return params; - } - - @Override - public void configureRequestBuilder(@NotNull RequestBuilder requestBuilder) { - requestBuilder.userAgent(AgentUtil.getUserAgent()) - .tuner(connection -> connection.setRequestProperty("Referer", GoogleTranslator.HOST_URL)); - } - - @Override - public @NotNull String parsingResult(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text, @NotNull String resultText) { - LOG.info("parsingResult: " + resultText); - GoogleTranslationResult googleTranslationResult = GsonUtil.getInstance().getGson().fromJson(resultText, GoogleTranslationResult.class); - return googleTranslationResult.getTranslationResult(); - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.java b/src/main/java/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.java deleted file mode 100644 index 96da04e..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.airsaid.localization.translate.impl.googleapi; - -import com.airsaid.localization.translate.TranslationResult; -import org.jetbrains.annotations.NotNull; - -import java.util.Arrays; - -/** - * @author airsaid - */ -public class GoogleApiTranslationResult implements TranslationResult { - public Data data; - public Error error; - - public boolean isSuccess() { - return data != null && error == null; - } - - @Override - public @NotNull String getTranslationResult() { - return data.translations[0].translatedText; - } - - @Override - public String toString() { - return "GoogleApiTranslationResult{" + - "data=" + data + - ", error=" + error + - '}'; - } - - public static class Data { - public Translation[] translations; - - @Override - public String toString() { - return "Data{" + - "translations=" + Arrays.toString(translations) + - '}'; - } - - public static class Translation { - public String translatedText; - public String detectedSourceLanguage; - - @Override - public String toString() { - return "Translation{" + - "translatedText='" + translatedText + '\'' + - ", detectedSourceLanguage='" + detectedSourceLanguage + '\'' + - '}'; - } - } - } - - public static class Error { - public int code; - public String message; - - @Override - public String toString() { - return "Error{" + - "code=" + code + - ", message='" + message + '\'' + - '}'; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.java b/src/main/java/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.java deleted file mode 100644 index 83d2baa..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.airsaid.localization.translate.impl.googleapi; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.TranslationException; -import com.airsaid.localization.translate.impl.google.AbsGoogleTranslator; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.translate.util.GsonUtil; -import com.google.auto.service.AutoService; -import com.intellij.openapi.util.Pair; -import com.intellij.util.io.RequestBuilder; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayList; -import java.util.List; - -/** - * @author airsaid - */ -@AutoService(AbstractTranslator.class) -public class GoogleApiTranslator extends AbsGoogleTranslator { - private static final String KEY = "GoogleApi"; - private static final String HOST_URL = "https://translation.googleapis.com"; - private static final String TRANSLATE_URL = HOST_URL.concat("/language/translate/v2"); - private static final String APPLY_APP_ID_URL = "https://cloud.google.com/translate"; - - @Override - public @NotNull String getKey() { - return KEY; - } - - @Override - public @NotNull String getName() { - return "Google (API)"; - } - - @Override - public @NotNull String getRequestUrl(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return TRANSLATE_URL; - } - - @Override - public String getAppKeyDisplay() { - return "API Key"; - } - - @Nullable - @Override - public String getApplyAppIdUrl() { - return APPLY_APP_ID_URL; - } - - @Override - public boolean isNeedAppId() { - return false; - } - - @Override - public @NotNull List> getRequestParams(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - List> params = new ArrayList<>(); - params.add(Pair.create("q", text)); - params.add(Pair.create("target", toLang.getTranslationCode())); - params.add(Pair.create("key", getAppKey())); - params.add(Pair.create("format", "text")); - return params; - } - - @Override - public void configureRequestBuilder(@NotNull RequestBuilder requestBuilder) { - requestBuilder.tuner(connection -> connection.setRequestProperty("Referer", HOST_URL)); - } - - @Override - public @NotNull String parsingResult(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text, @NotNull String resultText) { - LOG.info("parsingResult: " + resultText); - GoogleApiTranslationResult result = GsonUtil.getInstance().getGson().fromJson(resultText, GoogleApiTranslationResult.class); - if (result.isSuccess()) { - return result.getTranslationResult(); - } else { - String message; - if (result.error != null) { - message = result.error.message.concat("(").concat(String.valueOf(result.error.code)).concat(")"); - } else { - message = "Unknown error"; - } - throw new TranslationException(fromLang, toLang, text, message); - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.java b/src/main/java/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.java deleted file mode 100644 index 0d5c7b9..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.microsoft; - -import com.airsaid.localization.translate.TranslationResult; -import org.jetbrains.annotations.NotNull; - -import java.util.List; -import java.util.Objects; - -/** - * @author airsaid - */ -public class MicrosoftTranslationResult implements TranslationResult { - - private List translations; - - @Override - public @NotNull String getTranslationResult() { - if (translations != null && !translations.isEmpty()) { - String result = translations.get(0).getText(); - return result != null ? result : ""; - } - return ""; - } - - public List getTranslations() { - return translations; - } - - public void setTranslations(List translations) { - this.translations = translations; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - MicrosoftTranslationResult that = (MicrosoftTranslationResult) o; - return Objects.equals(translations, that.translations); - } - - @Override - public int hashCode() { - return Objects.hash(translations); - } - - @Override - public String toString() { - return "MicrosoftTranslationResult{" + - "translations=" + translations + - '}'; - } - - public static class Translation { - private String text; - private String to; - - public String getText() { - return text; - } - - public void setText(String text) { - this.text = text; - } - - public String getTo() { - return to; - } - - public void setTo(String to) { - this.to = to; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Translation that = (Translation) o; - return Objects.equals(text, that.text) && Objects.equals(to, that.to); - } - - @Override - public int hashCode() { - return Objects.hash(text, to); - } - - @Override - public String toString() { - return "Translation{" + - "text='" + text + '\'' + - ", to='" + to + '\'' + - '}'; - } - } - - public static class Error { - private String code; - private String message; - - public String getCode() { - return code; - } - - public void setCode(String code) { - this.code = code; - } - - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Error error = (Error) o; - return Objects.equals(code, error.code) && Objects.equals(message, error.message); - } - - @Override - public int hashCode() { - return Objects.hash(code, message); - } - - @Override - public String toString() { - return "Error{" + - "code='" + code + '\'' + - ", message='" + message + '\'' + - '}'; - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.java b/src/main/java/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.java deleted file mode 100644 index db8dd94..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.java +++ /dev/null @@ -1,201 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.microsoft; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.translate.lang.Languages; -import com.airsaid.localization.translate.util.GsonUtil; -import com.airsaid.localization.translate.util.UrlBuilder; -import com.google.auto.service.AutoService; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.util.io.RequestBuilder; -import icons.PluginIcons; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.util.ArrayList; -import java.util.List; - -/** - * @author airsaid - */ -@AutoService(AbstractTranslator.class) -public class MicrosoftTranslator extends AbstractTranslator { - - private static final Logger LOG = Logger.getInstance(MicrosoftTranslator.class); - - private static final String KEY = "Microsoft"; - private static final String HOST_URL = "https://api.cognitive.microsofttranslator.com"; - private static final String TRANSLATE_URL = HOST_URL.concat("/translate"); - private static final String APPLY_APP_ID_URL = "https://docs.microsoft.com/azure/cognitive-services/translator/translator-how-to-signup"; - - private List supportedLanguages; - - @Override - public @NotNull String getKey() { - return KEY; - } - - @Override - public @NotNull String getName() { - return "Microsoft"; - } - - @Override - public @Nullable Icon getIcon() { - return PluginIcons.MICROSOFT_ICON; - } - - @Override - public boolean isNeedAppId() { - return false; - } - - @Override - public @NotNull List getSupportedLanguages() { - if (supportedLanguages == null) { - supportedLanguages = new ArrayList<>(); - supportedLanguages.add(Languages.AFRIKAANS); - supportedLanguages.add(Languages.ALBANIAN); - supportedLanguages.add(Languages.AMHARIC); - supportedLanguages.add(Languages.ARABIC); - supportedLanguages.add(Languages.ARMENIAN); - supportedLanguages.add(Languages.ASSAMESE); - supportedLanguages.add(Languages.AZERBAIJANI); - supportedLanguages.add(Languages.BANGLA); - supportedLanguages.add(Languages.BOSNIAN); - supportedLanguages.add(Languages.BULGARIAN); - supportedLanguages.add(Languages.CATALAN); - supportedLanguages.add(Languages.CHINESE_SIMPLIFIED.setTranslationCode("zh-Hans")); - supportedLanguages.add(Languages.CHINESE_TRADITIONAL.setTranslationCode("zh-Hant")); - supportedLanguages.add(Languages.CROATIAN); - supportedLanguages.add(Languages.CZECH); - supportedLanguages.add(Languages.DANISH); - supportedLanguages.add(Languages.DARI); - supportedLanguages.add(Languages.DUTCH); - supportedLanguages.add(Languages.ENGLISH); - supportedLanguages.add(Languages.ESTONIAN); - supportedLanguages.add(Languages.FIJIAN); - supportedLanguages.add(Languages.FILIPINO.setTranslationCode("fil")); - supportedLanguages.add(Languages.FINNISH); - supportedLanguages.add(Languages.FRENCH); - supportedLanguages.add(Languages.GERMAN); - supportedLanguages.add(Languages.GREEK); - supportedLanguages.add(Languages.GUJARATI); - supportedLanguages.add(Languages.HAITIAN_CREOLE); - supportedLanguages.add(Languages.HEBREW.setTranslationCode("he")); - supportedLanguages.add(Languages.HINDI); - supportedLanguages.add(Languages.HMONG_DAW); - supportedLanguages.add(Languages.HUNGARIAN); - supportedLanguages.add(Languages.ICELANDIC); - supportedLanguages.add(Languages.INDONESIAN.setTranslationCode("id")); - supportedLanguages.add(Languages.INUKTITUT); - supportedLanguages.add(Languages.IRISH); - supportedLanguages.add(Languages.ITALIAN); - supportedLanguages.add(Languages.JAPANESE); - supportedLanguages.add(Languages.KANNADA); - supportedLanguages.add(Languages.KAZAKH); - supportedLanguages.add(Languages.KHMER); - supportedLanguages.add(Languages.KLINGON_LATIN); - supportedLanguages.add(Languages.KLINGON_PIQAD); - supportedLanguages.add(Languages.KOREAN); - supportedLanguages.add(Languages.KURDISH); - supportedLanguages.add(Languages.LAO); - supportedLanguages.add(Languages.LATVIAN); - supportedLanguages.add(Languages.LITHUANIAN); - supportedLanguages.add(Languages.MALAGASY); - supportedLanguages.add(Languages.MALAY); - supportedLanguages.add(Languages.MALAYALAM); - supportedLanguages.add(Languages.MALTESE); - supportedLanguages.add(Languages.MAORI); - supportedLanguages.add(Languages.MARATHI); - supportedLanguages.add(Languages.BURMESE); - supportedLanguages.add(Languages.NEPALI); - supportedLanguages.add(Languages.NORWEGIAN.setTranslationCode("nb")); - supportedLanguages.add(Languages.ODIA); - supportedLanguages.add(Languages.PASHTO); - supportedLanguages.add(Languages.PERSIAN); - supportedLanguages.add(Languages.PORTUGUESE); - supportedLanguages.add(Languages.PUNJABI); - supportedLanguages.add(Languages.QUERETARO_OTOMI); - supportedLanguages.add(Languages.ROMANIAN); - supportedLanguages.add(Languages.RUSSIAN); - supportedLanguages.add(Languages.SAMOAN); - supportedLanguages.add(Languages.SERBIAN); - supportedLanguages.add(Languages.SLOVAK); - supportedLanguages.add(Languages.SLOVENIAN); - supportedLanguages.add(Languages.SPANISH); - supportedLanguages.add(Languages.SWAHILI); - supportedLanguages.add(Languages.SWEDISH); - supportedLanguages.add(Languages.TAHITIAN); - supportedLanguages.add(Languages.TAMIL); - supportedLanguages.add(Languages.TELUGU); - supportedLanguages.add(Languages.THAI); - supportedLanguages.add(Languages.TIGRINYA); - supportedLanguages.add(Languages.TONGAN); - supportedLanguages.add(Languages.TURKISH); - supportedLanguages.add(Languages.UKRAINIAN); - supportedLanguages.add(Languages.URDU); - supportedLanguages.add(Languages.VIETNAMESE); - supportedLanguages.add(Languages.WELSH); - supportedLanguages.add(Languages.YUCATEC_MAYA); - } - return supportedLanguages; - } - - @Override - public String getAppKeyDisplay() { - return "KEY"; - } - - @Override - public @Nullable String getApplyAppIdUrl() { - return APPLY_APP_ID_URL; - } - - @Override - public @NotNull String getRequestUrl(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return new UrlBuilder(TRANSLATE_URL) - .addQueryParameter("api-version", "3.0") - .addQueryParameter("to", toLang.getTranslationCode()) - .build(); - } - - @Override - @NotNull - public String getRequestBody(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return "[{\"Text\": \"" + text + "\"}]"; - } - - @Override - public void configureRequestBuilder(@NotNull RequestBuilder requestBuilder) { - requestBuilder.tuner(connection -> { - connection.setRequestProperty("Ocp-Apim-Subscription-Key", getAppKey()); - connection.setRequestProperty("Content-type", "application/json"); - }); - } - - @Override - public @NotNull String parsingResult(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text, @NotNull String resultText) { - LOG.info("parsingResult: " + resultText); - return GsonUtil.getInstance().getGson().fromJson(resultText, MicrosoftTranslationResult[].class)[0].getTranslationResult(); - } - -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.java b/src/main/java/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.java deleted file mode 100644 index f19214a..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.openai; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.translate.lang.Languages; -import com.airsaid.localization.translate.util.GsonUtil; -import com.google.auto.service.AutoService; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.util.io.RequestBuilder; -import icons.PluginIcons; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.util.List; - - -@AutoService(AbstractTranslator.class) -public class ChatGPTTranslator extends AbstractTranslator { - - private static final Logger LOG = Logger.getInstance(ChatGPTTranslator.class); - private static final String KEY = "ChatGPT"; - - @Override - public @NotNull String getKey() { - return KEY; - } - - @Override - public @NotNull String getName() { - return "OpenAI ChatGPT"; - } - - @Override - public @Nullable Icon getIcon() { - return PluginIcons.OPENAI_ICON; - } - - @Override - public boolean isNeedAppId() { - return false; - } - - @Override - public boolean isNeedAppKey() { - return true; - } - - @Override - public @NotNull List getSupportedLanguages() { - return Languages.getLanguages(); - } - - @Override - public String getAppKeyDisplay() { - return "KEY"; - } - - - @Override - public @NotNull String getRequestUrl(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return "https://api.openai.com/v1/chat/completions"; - } - - @Override - @NotNull - public String getRequestBody(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - String lang = toLang.getEnglishName(); - String roleSystem = String.format("Translate the user provided text into high quality, well written %s. Apply these 4 translation rules; 1.Keep the exact original formatting and style, 2.Keep translations concise and just repeat the original text for unchanged translations (e.g. 'OK'), 3.Audience: native %s speakers, 4.Text can be used in Android app UI (limited space, concise translations!).", lang, lang); - - ChatGPTMessage role = new ChatGPTMessage("system", roleSystem); - ChatGPTMessage msg = new ChatGPTMessage("user", String.format("Text to translate: %s", text)); - - OpenAIRequest body = new OpenAIRequest("gpt-3.5-turbo", List.of(role, msg)); - - return GsonUtil.getInstance().getGson().toJson(body); - } - - @Override - public void configureRequestBuilder(@NotNull RequestBuilder requestBuilder) { - requestBuilder.tuner(connection -> { - connection.setRequestProperty("Authorization", "Bearer " + getAppKey()); - connection.setRequestProperty("Content-Type", "application/json"); - }); - } - - @Override - public @NotNull String parsingResult(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text, @NotNull String resultText) { - LOG.info("parsingResult ChatGPT: " + resultText); - return GsonUtil.getInstance().getGson().fromJson(resultText, OpenAIResponse.class).getTranslation(); - } - -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/openai/OpenAIRequest.java b/src/main/java/com/airsaid/localization/translate/impl/openai/OpenAIRequest.java deleted file mode 100644 index 14aca4b..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/openai/OpenAIRequest.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.openai; - -import java.util.List; - -public class OpenAIRequest { - private String model; - private List messages; - - public OpenAIRequest(String model, List messages) { - this.model = model; - this.messages = messages; - } - - public String getModel() { - return model; - } - - public void setModel(String model) { - this.model = model; - } - - public List getMessages() { - return messages; - } - - public void setMessages(List messages) { - this.messages = messages; - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/openai/OpenAIResponse.java b/src/main/java/com/airsaid/localization/translate/impl/openai/OpenAIResponse.java deleted file mode 100644 index b31d6da..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/openai/OpenAIResponse.java +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.openai; - -import java.util.List; - -public class OpenAIResponse { - private List choices; - private Integer created; - private String id; - private String object; - private Usage usage; - - public OpenAIResponse(List choices, Integer created, String id, String object, Usage usage) { - this.choices = choices; - this.created = created; - this.id = id; - this.object = object; - this.usage = usage; - } - - public List getChoices() { - return choices; - } - - public void setChoices(List choices) { - this.choices = choices; - } - - public Integer getCreated() { - return created; - } - - public void setCreated(Integer created) { - this.created = created; - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getObject() { - return object; - } - - public void setObject(String object) { - this.object = object; - } - - public Usage getUsage() { - return usage; - } - - public void setUsage(Usage usage) { - this.usage = usage; - } - - public String getTranslation() { - if (choices != null && !choices.isEmpty()) { - String result = choices.get(0).getMessage().getContent(); - return result.trim(); - - } else { - return ""; - } - } - - public static class Choice { - private String finish_reason; - private Integer index; - private Message message; - - public Choice(String finish_reason, Integer index, Message message) { - this.finish_reason = finish_reason; - this.index = index; - this.message = message; - } - - public String getFinish_reason() { - return finish_reason; - } - - public void setFinish_reason(String finish_reason) { - this.finish_reason = finish_reason; - } - - public Integer getIndex() { - return index; - } - - public void setIndex(Integer index) { - this.index = index; - } - - public Message getMessage() { - return message; - } - - public void setMessage(Message message) { - this.message = message; - } - } - - public static class Message { - private String content; - private String role; - - public Message(String content, String role) { - this.content = content; - this.role = role; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } - - public String getRole() { - return role; - } - - public void setRole(String role) { - this.role = role; - } - } - - public static class Usage { - private Integer completion_tokens; - private Integer prompt_tokens; - private Integer total_tokens; - - public Usage(Integer completion_tokens, Integer prompt_tokens, Integer total_tokens) { - this.completion_tokens = completion_tokens; - this.prompt_tokens = prompt_tokens; - this.total_tokens = total_tokens; - } - - public Integer getCompletion_tokens() { - return completion_tokens; - } - - public void setCompletion_tokens(Integer completion_tokens) { - this.completion_tokens = completion_tokens; - } - - public Integer getPrompt_tokens() { - return prompt_tokens; - } - - public void setPrompt_tokens(Integer prompt_tokens) { - this.prompt_tokens = prompt_tokens; - } - - public Integer getTotal_tokens() { - return total_tokens; - } - - public void setTotal_tokens(Integer total_tokens) { - this.total_tokens = total_tokens; - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/youdao/YoudaoTranslationResult.java b/src/main/java/com/airsaid/localization/translate/impl/youdao/YoudaoTranslationResult.java deleted file mode 100644 index 6c92c54..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/youdao/YoudaoTranslationResult.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.youdao; - -import com.airsaid.localization.translate.TranslationResult; -import com.intellij.openapi.util.text.StringUtil; -import org.jetbrains.annotations.NotNull; - -import java.util.List; -import java.util.Objects; - -/** - * @author airsaid - */ -public class YoudaoTranslationResult implements TranslationResult { - private String requestId; - private String errorCode; - private List translation; - - public String getRequestId() { - return requestId; - } - - public void setRequestId(String requestId) { - this.requestId = requestId; - } - - public String getErrorCode() { - return errorCode; - } - - public void setErrorCode(String errorCode) { - this.errorCode = errorCode; - } - - public List getTranslation() { - return translation; - } - - public void setTranslation(List translation) { - this.translation = translation; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - YoudaoTranslationResult that = (YoudaoTranslationResult) o; - return Objects.equals(requestId, that.requestId); - } - - @Override - public int hashCode() { - return Objects.hash(requestId); - } - - public boolean isSuccess() { - String errorCode = getErrorCode(); - return !StringUtil.isEmpty(errorCode) && "0".equals(errorCode); - } - - @Override - public @NotNull String getTranslationResult() { - List translation = getTranslation(); - if (translation != null) { - String result = translation.get(0); - return result != null ? result : ""; - } - return ""; - } - - @Override - public String toString() { - return "YoudaoTranslationResult{" + - "requestId='" + requestId + '\'' + - ", errorCode='" + errorCode + '\'' + - ", translation=" + translation + - '}'; - } -} diff --git a/src/main/java/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.java b/src/main/java/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.java deleted file mode 100644 index 6df9643..0000000 --- a/src/main/java/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.java +++ /dev/null @@ -1,244 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.youdao; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.TranslationException; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.translate.lang.Languages; -import com.airsaid.localization.translate.util.GsonUtil; -import com.google.auto.service.AutoService; -import com.intellij.openapi.util.Pair; -import com.intellij.util.io.RequestBuilder; -import icons.PluginIcons; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.List; - -/** - * @author airsaid - */ -@SuppressWarnings(value = {"SpellCheckingInspection", "unused"}) -@AutoService(AbstractTranslator.class) -public class YoudaoTranslator extends AbstractTranslator { - - private static final String KEY = "Youdao"; - private static final String HOST_URL = "https://openapi.youdao.com"; - private static final String TRANSLATE_URL = HOST_URL.concat("/api"); - private static final String APPLY_APP_ID_URL = "https://ai.youdao.com/DOCSIRMA/html/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E7%BF%BB%E8%AF%91/API%E6%96%87%E6%A1%A3/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1-API%E6%96%87%E6%A1%A3.html"; - - private List supportedLanguages; - - @Override - public @NotNull String getKey() { - return KEY; - } - - @Override - public @NotNull String getName() { - return "Youdao"; - } - - @Override - public @Nullable Icon getIcon() { - return PluginIcons.YOUDAO_ICON; - } - - @Override - public @NotNull List getSupportedLanguages() { - if (supportedLanguages == null) { - supportedLanguages = new ArrayList<>(); - supportedLanguages.add(Languages.CHINESE_SIMPLIFIED.setTranslationCode("zh-CHS")); - supportedLanguages.add(Languages.ENGLISH); - supportedLanguages.add(Languages.JAPANESE); - supportedLanguages.add(Languages.KOREAN); - supportedLanguages.add(Languages.FRENCH); - supportedLanguages.add(Languages.SPANISH); - supportedLanguages.add(Languages.ITALIAN); - supportedLanguages.add(Languages.RUSSIAN); - supportedLanguages.add(Languages.VIETNAMESE); - supportedLanguages.add(Languages.GERMAN); - supportedLanguages.add(Languages.ARABIC); - supportedLanguages.add(Languages.INDONESIAN.setTranslationCode("id")); - supportedLanguages.add(Languages.AFRIKAANS); - supportedLanguages.add(Languages.BOSNIAN); - supportedLanguages.add(Languages.BULGARIAN); - supportedLanguages.add(Languages.CATALAN); - supportedLanguages.add(Languages.CROATIAN); - supportedLanguages.add(Languages.CZECH); - supportedLanguages.add(Languages.DANISH); - supportedLanguages.add(Languages.DUTCH); - supportedLanguages.add(Languages.ESTONIAN); - supportedLanguages.add(Languages.FINNISH); - supportedLanguages.add(Languages.HAITIAN_CREOLE); - supportedLanguages.add(Languages.HINDI); - supportedLanguages.add(Languages.HUNGARIAN); - supportedLanguages.add(Languages.SWAHILI); - supportedLanguages.add(Languages.LITHUANIAN); - supportedLanguages.add(Languages.MALAY); - supportedLanguages.add(Languages.MALTESE); - supportedLanguages.add(Languages.NORWEGIAN); - supportedLanguages.add(Languages.POLISH); - supportedLanguages.add(Languages.ROMANIAN); - supportedLanguages.add(Languages.SERBIAN.setTranslationCode("sr-Cyrl")); - supportedLanguages.add(Languages.SLOVAK); - supportedLanguages.add(Languages.SLOVENIAN); - supportedLanguages.add(Languages.SWEDISH); - supportedLanguages.add(Languages.THAI); - supportedLanguages.add(Languages.TURKISH); - supportedLanguages.add(Languages.UKRAINIAN); - supportedLanguages.add(Languages.URDU); - supportedLanguages.add(Languages.AMHARIC); - supportedLanguages.add(Languages.AZERBAIJANI); - supportedLanguages.add(Languages.BANGLA); - supportedLanguages.add(Languages.BASQUE); - supportedLanguages.add(Languages.BELARUSIAN); - supportedLanguages.add(Languages.CEBUANO); - supportedLanguages.add(Languages.CORSICAN); - supportedLanguages.add(Languages.ESPERANTO); - supportedLanguages.add(Languages.FILIPINO.setTranslationCode("tl")); - supportedLanguages.add(Languages.FRISIAN); - supportedLanguages.add(Languages.GUJARATI); - supportedLanguages.add(Languages.HAUSA); - supportedLanguages.add(Languages.HAWAIIAN); - supportedLanguages.add(Languages.ICELANDIC); - supportedLanguages.add(Languages.JAVANESE.setTranslationCode("jw")); - supportedLanguages.add(Languages.KANNADA); - supportedLanguages.add(Languages.KAZAKH); - supportedLanguages.add(Languages.KHMER); - supportedLanguages.add(Languages.KURDISH); - supportedLanguages.add(Languages.KYRGYZ); - supportedLanguages.add(Languages.LAO); - supportedLanguages.add(Languages.LATIN); - supportedLanguages.add(Languages.LUXEMBOURGISH); - supportedLanguages.add(Languages.MACEDONIAN); - supportedLanguages.add(Languages.MALAGASY); - supportedLanguages.add(Languages.MALAYALAM); - supportedLanguages.add(Languages.MARATHI); - supportedLanguages.add(Languages.MONGOLIAN); - supportedLanguages.add(Languages.BURMESE); - supportedLanguages.add(Languages.NEPALI); - supportedLanguages.add(Languages.CHICHEWA); - supportedLanguages.add(Languages.PASHTO); - supportedLanguages.add(Languages.PUNJABI); - supportedLanguages.add(Languages.SAMOAN); - supportedLanguages.add(Languages.SCOTTISH_GAELIC); - supportedLanguages.add(Languages.SOTHO); - supportedLanguages.add(Languages.SHONA); - supportedLanguages.add(Languages.SINDHI); - supportedLanguages.add(Languages.SLOVENIAN); - supportedLanguages.add(Languages.SOMALI); - supportedLanguages.add(Languages.SUNDANESE); - supportedLanguages.add(Languages.TAJIK); - supportedLanguages.add(Languages.TAMIL); - supportedLanguages.add(Languages.TELUGU); - supportedLanguages.add(Languages.UZBEK); - supportedLanguages.add(Languages.XHOSA); - supportedLanguages.add(Languages.YORUBA); - supportedLanguages.add(Languages.ZULU); - } - return supportedLanguages; - } - - @Override - public String getAppIdDisplay() { - return "应用 ID"; - } - - @Override - public String getAppKeyDisplay() { - return "应用秘钥"; - } - - @Nullable - @Override - public String getApplyAppIdUrl() { - return APPLY_APP_ID_URL; - } - - @Override - public @NotNull String getRequestUrl(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return TRANSLATE_URL; - } - - private String truncate(String q) { - int len = q.length(); - return len <= 20 ? q : (q.substring(0, 10) + len + q.substring(len - 10, len)); - } - - private String getDigest(String string) { - char[] hexDigits = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; - byte[] btInput = string.getBytes(StandardCharsets.UTF_8); - try { - MessageDigest mdInst = MessageDigest.getInstance("SHA-256"); - mdInst.update(btInput); - byte[] md = mdInst.digest(); - int j = md.length; - char[] str = new char[j * 2]; - int k = 0; - for (byte byte0 : md) { - str[k++] = hexDigits[byte0 >>> 4 & 0xf]; - str[k++] = hexDigits[byte0 & 0xf]; - } - return new String(str); - } catch (NoSuchAlgorithmException e) { - return null; - } - } - - @Override - public @NotNull List> getRequestParams(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - String salt = String.valueOf(System.currentTimeMillis()); - String curTime = String.valueOf(System.currentTimeMillis() / 1000); - String appId = getAppId(); - String appKey = getAppKey(); - String sign = getDigest(appId + truncate(text) + salt + curTime + appKey); - List> params = new ArrayList<>(); - params.add(Pair.create("from", fromLang.getTranslationCode())); - params.add(Pair.create("to", toLang.getTranslationCode())); - params.add(Pair.create("signType", "v3")); - params.add(Pair.create("curtime", curTime)); - params.add(Pair.create("appKey", appId)); - params.add(Pair.create("salt", salt)); - params.add(Pair.create("sign", sign)); - params.add(Pair.create("q", text)); - return params; - } - - @Override - public void configureRequestBuilder(@NotNull RequestBuilder requestBuilder) { - requestBuilder.tuner(connection -> connection.setRequestProperty("Referer", HOST_URL)); - } - - @Override - public @NotNull String parsingResult(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text, @NotNull String resultText) { - LOG.info("parsingResult: " + resultText); - YoudaoTranslationResult translationResult = GsonUtil.getInstance().getGson().fromJson(resultText, YoudaoTranslationResult.class); - if (translationResult.isSuccess()) { - return translationResult.getTranslationResult(); - } else { - throw new TranslationException(fromLang, toLang, text, translationResult.getErrorCode()); - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.java b/src/main/java/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.java deleted file mode 100644 index 5d5e643..0000000 --- a/src/main/java/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.interceptors; - -import com.airsaid.localization.translate.services.TranslatorService; -import com.intellij.openapi.util.text.StringUtil; - -import java.util.ArrayList; -import java.util.List; - -/** - * @author airsaid - */ -public class EscapeCharactersInterceptor implements TranslatorService.TranslationInterceptor { - - private final List needEscapeChars = new ArrayList<>(); - - public EscapeCharactersInterceptor() { - needEscapeChars.add('@'); - needEscapeChars.add('?'); - needEscapeChars.add('\''); - needEscapeChars.add('\"'); - } - - @Override - public String process(String text) { - if (StringUtil.isEmpty(text)) { - return text; - } - final StringBuilder result = new StringBuilder(); - final char[] chars = text.toCharArray(); - for (char ch : chars) { - if (needEscapeChars.contains(ch)) { - result.append('\\'); - } - result.append(ch); - } - return result.toString(); - } -} \ No newline at end of file diff --git a/src/main/java/com/airsaid/localization/translate/lang/Lang.java b/src/main/java/com/airsaid/localization/translate/lang/Lang.java deleted file mode 100644 index 1fca7e1..0000000 --- a/src/main/java/com/airsaid/localization/translate/lang/Lang.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.lang; - -import com.intellij.openapi.util.text.StringUtil; - -import java.util.Objects; - -/** - * Language data class, which is an immutable class, - * any modification to it will generate you a new object. - * - * @author airsaid - */ -public final class Lang implements Cloneable { - private final int id; - private final String code; - private final String name; - private final String englishName; - private String translationCode; - - public Lang(int id, String code, String name, String englishName) { - this.id = id; - this.code = code; - this.name = name; - this.englishName = englishName; - } - - public int getId() { - return id; - } - - public String getCode() { - return code; - } - - public String getName() { - return name; - } - - public String getEnglishName() { - return englishName; - } - - public Lang setTranslationCode(String translationCode) { - final Lang newLang = this.clone(); - Objects.requireNonNull(newLang).translationCode = translationCode; - return newLang; - } - - public String getTranslationCode() { - if (!StringUtil.isEmpty(translationCode)) { - return translationCode; - } - return code; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Lang language = (Lang) o; - return id == language.id; - } - - @Override - public int hashCode() { - return Objects.hash(id); - } - - @Override - public Lang clone() { - try { - return (Lang) super.clone(); - } catch (CloneNotSupportedException e) { - e.printStackTrace(); - } - return null; - } - - @Override - public String toString() { - return "Lang{" + - "id=" + id + - ", code='" + code + '\'' + - ", name='" + name + '\'' + - ", englishName='" + englishName + '\'' + - ", translationCode='" + translationCode + '\'' + - '}'; - } -} \ No newline at end of file diff --git a/src/main/java/com/airsaid/localization/translate/lang/Languages.java b/src/main/java/com/airsaid/localization/translate/lang/Languages.java deleted file mode 100644 index 6922d3c..0000000 --- a/src/main/java/com/airsaid/localization/translate/lang/Languages.java +++ /dev/null @@ -1,275 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.lang; - -import java.util.*; - -/** - * @author airsaid - */ -// Some language codes and names cannot pass the compiler check -@SuppressWarnings(value = {"SpellCheckingInspection", "unused"}) -public class Languages { - public static final Lang AUTO = new Lang(0, "auto", "Auto", "Auto"); - public static final Lang ALBANIAN = new Lang(1, "sq", "Shqiptar", "Albanian"); - public static final Lang ARABIC = new Lang(2, "ar", "العربية", "Arabic"); - public static final Lang AMHARIC = new Lang(3, "am", "አማርኛ", "Amharic"); - public static final Lang AZERBAIJANI = new Lang(4, "az", "азәрбајҹан", "Azerbaijani"); - public static final Lang IRISH = new Lang(5, "ga", "Gaeilge", "Irish"); - public static final Lang ESTONIAN = new Lang(6, "et", "Eesti", "Estonian"); - public static final Lang BASQUE = new Lang(7, "eu", "Euskal", "Basque"); - public static final Lang BELARUSIAN = new Lang(8, "be", "беларускі", "Belarusian"); - public static final Lang BULGARIAN = new Lang(9, "bg", "Български", "Bulgarian"); - public static final Lang ICELANDIC = new Lang(10, "is", "Íslenska", "Icelandic"); - public static final Lang POLISH = new Lang(11, "pl", "Polski", "Polish"); - public static final Lang BOSNIAN = new Lang(12, "bs", "Bosanski", "Bosnian"); - public static final Lang PERSIAN = new Lang(13, "fa", "Persian", "Persian"); - public static final Lang AFRIKAANS = new Lang(14, "af", "Afrikaans", "Afrikaans"); - public static final Lang DANISH = new Lang(15, "da", "Dansk", "Danish"); - public static final Lang GERMAN = new Lang(16, "de", "Deutsch", "German"); - public static final Lang RUSSIAN = new Lang(17, "ru", "Русский", "Russian"); - public static final Lang FRENCH = new Lang(18, "fr", "Français", "French"); - public static final Lang FILIPINO = new Lang(19, "fil", "Filipino", "Filipino"); - public static final Lang FINNISH = new Lang(20, "fi", "Suomi", "Finnish"); - public static final Lang FRISIAN = new Lang(21, "fy", "Frysk", "Frisian"); - public static final Lang KHMER = new Lang(22, "km", "ខ្មែរ", "Khmer"); - public static final Lang GEORGIAN = new Lang(23, "ka", "ქართული", "Georgian"); - public static final Lang GUJARATI = new Lang(24, "gu", "ગુજરાતી", "Gujarati"); - public static final Lang KAZAKH = new Lang(25, "kk", "Kazakh", "Kazakh"); - public static final Lang HAITIAN_CREOLE = new Lang(26, "ht", "Haitian Creole", "Haitian Creole"); - public static final Lang KOREAN = new Lang(27, "ko", "한국어", "Korean"); - public static final Lang HAUSA = new Lang(28, "ha", "Hausa", "Hausa"); - public static final Lang DUTCH = new Lang(29, "nl", "Nederlands", "Dutch"); - public static final Lang KYRGYZ = new Lang(30, "ky", "Кыргыз тили", "Kyrgyz"); - public static final Lang GALICIAN = new Lang(31, "gl", "Galego", "Galician"); - public static final Lang CATALAN = new Lang(32, "ca", "Català", "Catalan"); - public static final Lang CZECH = new Lang(33, "cs", "Čeština", "Czech"); - public static final Lang KANNADA = new Lang(34, "kn", "ಕನ್ನಡ", "Kannada"); - public static final Lang CORSICAN = new Lang(35, "co", "Corsa", "Corsican"); - public static final Lang CROATIAN = new Lang(36, "hr", "Hrvatski", "Croatian"); - public static final Lang KURDISH = new Lang(37, "ku", "Kurdî", "Kurdish"); - public static final Lang LATIN = new Lang(38, "la", "Latina", "Latin"); - public static final Lang LATVIAN = new Lang(39, "lv", "Latviešu", "Latvian"); - public static final Lang LAO = new Lang(40, "lo", "ລາວ", "Lao"); - public static final Lang LITHUANIAN = new Lang(41, "lt", "Lietuvių", "Lithuanian"); - public static final Lang LUXEMBOURGISH = new Lang(42, "lb", "Lëtzebuergesch", "Luxembourgish"); - public static final Lang ROMANIAN = new Lang(43, "ro", "Română", "Romanian"); - public static final Lang MALAGASY = new Lang(44, "mg", "Malagasy", "Malagasy"); - public static final Lang MALTESE = new Lang(45, "mt", "Il-Malti", "Maltese"); - public static final Lang MARATHI = new Lang(46, "mr", "मराठी", "Marathi"); - public static final Lang MALAYALAM = new Lang(47, "ml", "മലയാളം", "Malayalam"); - public static final Lang MALAY = new Lang(48, "ms", "Melayu", "Malay"); - public static final Lang MACEDONIAN = new Lang(49, "mk", "Македонски", "Macedonian"); - public static final Lang MAORI = new Lang(50, "mi", "Māori", "Maori"); - public static final Lang MONGOLIAN = new Lang(51, "mn", "Монгол хэл", "Mongolian"); - public static final Lang BANGLA = new Lang(52, "bn", "বাংল", "Bangla"); - public static final Lang BURMESE = new Lang(53, "my", "မြန်မာ", "Burmese"); - public static final Lang HMONG = new Lang(54, "hmn", "Hmoob", "Hmong"); - public static final Lang XHOSA = new Lang(55, "xh", "IsiXhosa", "Xhosa"); - public static final Lang ZULU = new Lang(56, "zu", "Zulu", "Zulu"); - public static final Lang NEPALI = new Lang(57, "ne", "नेपाली", "Nepali"); - public static final Lang NORWEGIAN = new Lang(58, "no", "Norsk", "Norwegian"); - public static final Lang PUNJABI = new Lang(59, "pa", "ਪੰਜਾਬੀ", "Punjabi"); - public static final Lang PORTUGUESE = new Lang(60, "pt", "Português", "Portuguese"); - public static final Lang PASHTO = new Lang(61, "ps", "Pashto", "Pashto"); - public static final Lang CHICHEWA = new Lang(62, "ny", "Chichewa", "Chichewa"); - public static final Lang JAPANESE = new Lang(63, "ja", "日本語", "Japanese"); - public static final Lang SWEDISH = new Lang(64, "sv", "Svenska", "Swedish"); - public static final Lang SAMOAN = new Lang(65, "sm", "Samoa", "Samoan"); - public static final Lang SERBIAN = new Lang(66, "sr", "Српски", "Serbian"); - public static final Lang SOTHO = new Lang(67, "st", "Sesotho", "Sotho"); - public static final Lang SINHALA = new Lang(68, "si", "සිංහල", "Sinhala"); - public static final Lang ESPERANTO = new Lang(69, "eo", "Esperanta", "Esperanto"); - public static final Lang SLOVAK = new Lang(70, "sk", "Slovenčina", "Slovak"); - public static final Lang SLOVENIAN = new Lang(71, "sl", "Slovenščina", "Slovenian"); - public static final Lang SWAHILI = new Lang(72, "sw", "Kiswahili", "Swahili"); - public static final Lang SCOTTISH_GAELIC = new Lang(73, "gd", "Gàidhlig na h-Alba", "Scottish Gaelic"); - public static final Lang CEBUANO = new Lang(74, "ceb", "Cebuano", "Cebuano"); - public static final Lang SOMALI = new Lang(75, "so", "Somali", "Somali"); - public static final Lang TAJIK = new Lang(76, "tg", "Тоҷикӣ", "Tajik"); - public static final Lang TELUGU = new Lang(77, "te", "తెలుగు", "Telugu"); - public static final Lang TAMIL = new Lang(78, "ta", "தமிழ்", "Tamil"); - public static final Lang THAI = new Lang(79, "th", "ไทย", "Thai"); - public static final Lang TURKISH = new Lang(80, "tr", "Türkçe", "Turkish"); - public static final Lang WELSH = new Lang(81, "cy", "Cymraeg", "Welsh"); - public static final Lang URDU = new Lang(82, "ur", "اردو", "Urdu"); - public static final Lang UKRAINIAN = new Lang(83, "uk", "Українська", "Ukrainian"); - public static final Lang UZBEK = new Lang(84, "uz", "O'zbek", "Uzbek"); - public static final Lang SPANISH = new Lang(85, "es", "Español", "Spanish"); - public static final Lang HEBREW = new Lang(86, "iw", "עברית", "Hebrew"); - public static final Lang GREEK = new Lang(87, "el", "Ελληνικά", "Greek"); - public static final Lang HAWAIIAN = new Lang(88, "haw", "Hawaiian", "Hawaiian"); - public static final Lang SINDHI = new Lang(89, "sd", "سنڌي", "Sindhi"); - public static final Lang HUNGARIAN = new Lang(90, "hu", "Magyar", "Hungarian"); - public static final Lang SHONA = new Lang(91, "sn", "Shona", "Shona"); - public static final Lang ARMENIAN = new Lang(92, "hy", "Հայերեն", "Armenian"); - public static final Lang IGBO = new Lang(93, "ig", "Igbo", "Igbo"); - public static final Lang ITALIAN = new Lang(94, "it", "Italiano", "Italian"); - public static final Lang YIDDISH = new Lang(95, "yi", "ייִדיש", "Yiddish"); - public static final Lang HINDI = new Lang(96, "hi", "हिंदी", "Hindi"); - public static final Lang SUNDANESE = new Lang(97, "su", "Sunda", "Sundanese"); - public static final Lang INDONESIAN = new Lang(98, "in-rID", "Indonesia", "Indonesian"); - public static final Lang JAVANESE = new Lang(99, "jv", "Wong Jawa", "Javanese"); - public static final Lang ENGLISH = new Lang(100, "en", "English", "English"); - public static final Lang YORUBA = new Lang(101, "yo", "Yorùbá", "Yoruba"); - public static final Lang VIETNAMESE = new Lang(102, "vi", "Tiếng Việt", "Vietnamese"); - public static final Lang CHINESE_TRADITIONAL = new Lang(103, "zh-rTW", "正體中文", "Chinese Traditional"); - public static final Lang CHINESE_SIMPLIFIED = new Lang(104, "zh-rCN", "简体中文", "Chinese Simplified"); - public static final Lang ASSAMESE = new Lang(105, "as", "Assamese", "Assamese"); - public static final Lang DARI = new Lang(106, "prs", "Dari", "Dari"); - public static final Lang FIJIAN = new Lang(107, "fj", "Fijian", "Fijian"); - public static final Lang HMONG_DAW = new Lang(108, "mww", "Hmong Daw", "Hmong Daw"); - public static final Lang INUKTITUT = new Lang(109, "iu", "ᐃᓄᒃᑎᑐᑦ", "Inuktitut"); - public static final Lang KLINGON_LATIN = new Lang(110, "tlh-Latn", "Klingon (Latin)", "Klingon (Latin)"); - public static final Lang KLINGON_PIQAD = new Lang(111, "tlh-Piqd", "Klingon (pIqaD)", "Klingon (pIqaD)"); - public static final Lang ODIA = new Lang(112, "or", "Odia", "Odia"); - public static final Lang QUERETARO_OTOMI = new Lang(113, "otq", "Querétaro Otomi", "Querétaro Otomi"); - public static final Lang TAHITIAN = new Lang(114, "ty", "Tahitian", "Tahitian"); - public static final Lang TIGRINYA = new Lang(115, "ti", "ትግርኛ", "Tigrinya"); - public static final Lang TONGAN = new Lang(116, "to", "lea fakatonga", "Tongan"); - public static final Lang YUCATEC_MAYA = new Lang(117, "yua", "Yucatec Maya", "Yucatec Maya"); - - private static final Map sLanguages; - - static { - sLanguages = new HashMap<>(); - sLanguages.put(0, AUTO); - sLanguages.put(1, ALBANIAN); - sLanguages.put(2, ARABIC); - sLanguages.put(3, AMHARIC); - sLanguages.put(4, AZERBAIJANI); - sLanguages.put(5, IRISH); - sLanguages.put(6, ESTONIAN); - sLanguages.put(7, BASQUE); - sLanguages.put(8, BELARUSIAN); - sLanguages.put(9, BULGARIAN); - sLanguages.put(10, ICELANDIC); - sLanguages.put(11, POLISH); - sLanguages.put(12, BOSNIAN); - sLanguages.put(13, PERSIAN); - sLanguages.put(14, AFRIKAANS); - sLanguages.put(15, DANISH); - sLanguages.put(16, GERMAN); - sLanguages.put(17, RUSSIAN); - sLanguages.put(18, FRENCH); - sLanguages.put(19, FILIPINO); - sLanguages.put(20, FINNISH); - sLanguages.put(21, FRISIAN); - sLanguages.put(22, KHMER); - sLanguages.put(23, GEORGIAN); - sLanguages.put(24, GUJARATI); - sLanguages.put(25, KAZAKH); - sLanguages.put(26, HAITIAN_CREOLE); - sLanguages.put(27, KOREAN); - sLanguages.put(28, HAUSA); - sLanguages.put(29, DUTCH); - sLanguages.put(30, KYRGYZ); - sLanguages.put(31, GALICIAN); - sLanguages.put(32, CATALAN); - sLanguages.put(33, CZECH); - sLanguages.put(34, KANNADA); - sLanguages.put(35, CORSICAN); - sLanguages.put(36, CROATIAN); - sLanguages.put(37, KURDISH); - sLanguages.put(38, LATIN); - sLanguages.put(39, LATVIAN); - sLanguages.put(40, LAO); - sLanguages.put(41, LITHUANIAN); - sLanguages.put(42, LUXEMBOURGISH); - sLanguages.put(43, ROMANIAN); - sLanguages.put(44, MALAGASY); - sLanguages.put(45, MALTESE); - sLanguages.put(46, MARATHI); - sLanguages.put(47, MALAYALAM); - sLanguages.put(48, MALAY); - sLanguages.put(49, MACEDONIAN); - sLanguages.put(50, MAORI); - sLanguages.put(51, MONGOLIAN); - sLanguages.put(52, BANGLA); - sLanguages.put(53, BURMESE); - sLanguages.put(54, HMONG); - sLanguages.put(55, XHOSA); - sLanguages.put(56, ZULU); - sLanguages.put(57, NEPALI); - sLanguages.put(58, NORWEGIAN); - sLanguages.put(59, PUNJABI); - sLanguages.put(60, PORTUGUESE); - sLanguages.put(61, PASHTO); - sLanguages.put(62, CHICHEWA); - sLanguages.put(63, JAPANESE); - sLanguages.put(64, SWEDISH); - sLanguages.put(65, SAMOAN); - sLanguages.put(66, SERBIAN); - sLanguages.put(67, SOTHO); - sLanguages.put(68, SINHALA); - sLanguages.put(69, ESPERANTO); - sLanguages.put(70, SLOVAK); - sLanguages.put(71, SLOVENIAN); - sLanguages.put(72, SWAHILI); - sLanguages.put(73, SCOTTISH_GAELIC); - sLanguages.put(74, CEBUANO); - sLanguages.put(75, SOMALI); - sLanguages.put(76, TAJIK); - sLanguages.put(77, TELUGU); - sLanguages.put(78, TAMIL); - sLanguages.put(79, THAI); - sLanguages.put(80, TURKISH); - sLanguages.put(81, WELSH); - sLanguages.put(82, URDU); - sLanguages.put(83, UKRAINIAN); - sLanguages.put(84, UZBEK); - sLanguages.put(85, SPANISH); - sLanguages.put(86, HEBREW); - sLanguages.put(87, GREEK); - sLanguages.put(88, HAWAIIAN); - sLanguages.put(89, SINDHI); - sLanguages.put(90, HUNGARIAN); - sLanguages.put(91, SHONA); - sLanguages.put(92, ARMENIAN); - sLanguages.put(93, IGBO); - sLanguages.put(94, ITALIAN); - sLanguages.put(95, YIDDISH); - sLanguages.put(96, HINDI); - sLanguages.put(97, SUNDANESE); - sLanguages.put(98, INDONESIAN); - sLanguages.put(99, JAVANESE); - sLanguages.put(100, ENGLISH); - sLanguages.put(101, YORUBA); - sLanguages.put(102, VIETNAMESE); - sLanguages.put(103, CHINESE_TRADITIONAL); - sLanguages.put(104, CHINESE_SIMPLIFIED); - sLanguages.put(105, ASSAMESE); - sLanguages.put(106, DARI); - sLanguages.put(107, FIJIAN); - sLanguages.put(108, HMONG_DAW); - sLanguages.put(109, INUKTITUT); - sLanguages.put(110, KLINGON_LATIN); - sLanguages.put(111, KLINGON_PIQAD); - sLanguages.put(112, ODIA); - sLanguages.put(113, QUERETARO_OTOMI); - sLanguages.put(114, TAHITIAN); - sLanguages.put(115, TIGRINYA); - sLanguages.put(116, TONGAN); - sLanguages.put(117, YUCATEC_MAYA); - } - - public static List getLanguages() { - return new ArrayList<>(sLanguages.values()); - } - -} diff --git a/src/main/java/com/airsaid/localization/translate/services/TranslationCacheService.java b/src/main/java/com/airsaid/localization/translate/services/TranslationCacheService.java deleted file mode 100644 index 0ff1f3a..0000000 --- a/src/main/java/com/airsaid/localization/translate/services/TranslationCacheService.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.services; - -import com.airsaid.localization.translate.util.GsonUtil; -import com.airsaid.localization.translate.util.LRUCache; -import com.google.gson.reflect.TypeToken; -import com.intellij.openapi.Disposable; -import com.intellij.openapi.components.*; -import com.intellij.util.xmlb.Converter; -import com.intellij.util.xmlb.XmlSerializerUtil; -import com.intellij.util.xmlb.annotations.OptionTag; -import com.intellij.util.xmlb.annotations.Transient; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.lang.reflect.Type; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * Cache the translated text to local disk. - *

- * The maximum number of caches is set by the {@link #setMaxCacheSize(int)} method, - * if exceed this size, remove old data through the LRU algorithm. - * - * @author airsaid - */ -@State( - name = "com.airsaid.localization.translate.services.TranslationCacheService", - storages = {@Storage("androidLocalizeTranslationCaches.xml")} -) -@Service -public final class TranslationCacheService implements PersistentStateComponent, Disposable { - - @Transient - private static final int CACHE_MAX_SIZE = 500; - - @OptionTag(converter = LruCacheConverter.class) - private final LRUCache lruCache = new LRUCache<>(CACHE_MAX_SIZE); - - public static TranslationCacheService getInstance() { - return ServiceManager.getService(TranslationCacheService.class); - } - - public void put(@NotNull String key, @NotNull String value) { - lruCache.put(key, value); - } - - @NotNull - public String get(String key) { - String value = lruCache.get(key); - return value != null ? value : ""; - } - - public void setMaxCacheSize(int maxCacheSize) { - lruCache.setMaxCapacity(maxCacheSize); - } - - @Override - public @NotNull TranslationCacheService getState() { - return this; - } - - @Override - public void loadState(@NotNull TranslationCacheService state) { - XmlSerializerUtil.copyBean(state, this); - } - - @Override - public void dispose() { - lruCache.clear(); - } - - static class LruCacheConverter extends Converter> { - @Override - public @Nullable LRUCache fromString(@NotNull String value) { - Type type = new TypeToken>() {}.getType(); - Map map = GsonUtil.getInstance().getGson().fromJson(value, type); - LRUCache lruCache = new LRUCache<>(CACHE_MAX_SIZE); - for (Map.Entry entry : map.entrySet()) { - lruCache.put(entry.getKey(), entry.getValue()); - } - return lruCache; - } - - @Override - public @Nullable String toString(@NotNull LRUCache lruCache) { - Map values = new LinkedHashMap<>(); - lruCache.forEach(values::put); - return GsonUtil.getInstance().getGson().toJson(values); - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/services/TranslatorService.java b/src/main/java/com/airsaid/localization/translate/services/TranslatorService.java deleted file mode 100644 index 4310052..0000000 --- a/src/main/java/com/airsaid/localization/translate/services/TranslatorService.java +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.services; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.impl.google.GoogleTranslator; -import com.airsaid.localization.translate.interceptors.EscapeCharactersInterceptor; -import com.airsaid.localization.translate.lang.Lang; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.components.Service; -import com.intellij.openapi.components.ServiceManager; -import com.intellij.openapi.diagnostic.Logger; -import org.apache.commons.lang.StringUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.*; -import java.util.function.Consumer; - -/** - * @author airsaid - */ -@Service -public final class TranslatorService { - - private static final Logger LOG = Logger.getInstance(TranslatorService.class); - - private AbstractTranslator selectedTranslator; - private final AbstractTranslator defaultTranslator; - private final TranslationCacheService cacheService; - private final Map translators; - private final List translationInterceptors; - private boolean isEnableCache = true; - private int intervalTime; - - public interface TranslationInterceptor { - String process(String text); - } - - public TranslatorService() { - translators = new LinkedHashMap<>(); - ServiceLoader serviceLoader = ServiceLoader.load( - AbstractTranslator.class, getClass().getClassLoader() - ); - for (AbstractTranslator translator : serviceLoader) { - translators.put(translator.getKey(), translator); - } - defaultTranslator = translators.get(GoogleTranslator.KEY); - - cacheService = TranslationCacheService.getInstance(); - - translationInterceptors = new ArrayList<>(); - translationInterceptors.add(new EscapeCharactersInterceptor()); - } - - @NotNull - public static TranslatorService getInstance() { - return ServiceManager.getService(TranslatorService.class); - } - - public AbstractTranslator getDefaultTranslator() { - return defaultTranslator; - } - - public Map getTranslators() { - return translators; - } - - public void setSelectedTranslator(@NotNull AbstractTranslator selectedTranslator) { - if (this.selectedTranslator != selectedTranslator) { - LOG.info(String.format("setTranslator: %s", selectedTranslator)); - this.selectedTranslator = selectedTranslator; - } - } - - @Nullable - public AbstractTranslator getSelectedTranslator() { - return selectedTranslator; - } - - public void doTranslateByAsync(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text, @NotNull Consumer consumer) { - ApplicationManager.getApplication().executeOnPooledThread(() -> { - final String translatedText = doTranslate(fromLang, toLang, text); - ApplicationManager.getApplication().invokeLater(() -> - consumer.accept(translatedText)); - }); - } - - public String doTranslate(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - LOG.info(String.format("doTranslate fromLang: %s, toLang: %s, text: %s", fromLang, toLang, text)); - - if (isEnableCache) { - String cacheResult = cacheService.get(getCacheKey(fromLang, toLang, text)); - if (!cacheResult.isEmpty()) { - LOG.info(String.format("doTranslate cache result: %s", cacheResult)); - return cacheResult; - } - } - - // Arabic numbers skip translation - if (StringUtils.isNumeric(text)) { - return text; - } - - String result = selectedTranslator.doTranslate(fromLang, toLang, text); - LOG.info(String.format("doTranslate result: %s", result)); - for (TranslationInterceptor interceptor : translationInterceptors) { - result = interceptor.process(result); - LOG.info(String.format("doTranslate interceptor process result: %s", result)); - } - cacheService.put(getCacheKey(fromLang, toLang, text), result); - delay(intervalTime); - return result; - } - - public void setEnableCache(boolean isEnableCache) { - this.isEnableCache = isEnableCache; - } - - public boolean isEnableCache() { - return isEnableCache; - } - - public void setMaxCacheSize(int maxCacheSize) { - cacheService.setMaxCacheSize(maxCacheSize); - } - - public void setTranslationInterval(int intervalTime) { - this.intervalTime = intervalTime; - } - - private String getCacheKey(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) { - return fromLang.getCode() + "_" + toLang.getCode() + "_" + text; - } - - private void delay(int second) { - if (second <= 0) return; - try { - LOG.info(String.format("doTranslate delay time: %d second.", second)); - Thread.sleep(second * 1000L); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } -} diff --git a/src/main/java/com/airsaid/localization/translate/util/AgentUtil.java b/src/main/java/com/airsaid/localization/translate/util/AgentUtil.java deleted file mode 100644 index c492acc..0000000 --- a/src/main/java/com/airsaid/localization/translate/util/AgentUtil.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.util; - -import com.intellij.openapi.util.SystemInfo; - -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -/** - * @author airsaid - */ -public class AgentUtil { - - private static final String CHROME_VERSION = "98.0.4758.102"; - private static final String EDGE_VERSION = "98.0.1108.62"; - - private AgentUtil() { - throw new AssertionError("No com.airsaid.localization.translate.util.AgentUtil instances for you!"); - } - - public static String getUserAgent() { - String arch = System.getProperty("os.arch"); - boolean is64Bit = arch != null && arch.contains("64"); - String systemInformation; - if (SystemInfo.isWindows) { - systemInformation = is64Bit ? "Windows NT " + SystemInfo.OS_VERSION + "; Win64; x64" : "Windows NT " + SystemInfo.OS_VERSION; - } else if (SystemInfo.isMac) { - List parts = Arrays.stream(SystemInfo.OS_VERSION.split("\\.")).collect(Collectors.toList()); - if (parts.size() < 3) { - parts.add("0"); - } - systemInformation = String.format("Macintosh; Intel Mac OS X %s", String.join("_", parts)); - } else { - systemInformation = is64Bit ? "X11; Linux x86_64" : "X11; Linux x86"; - } - return "Mozilla/5.0 (".concat(systemInformation).concat(") AppleWebKit/537.36 (KHTML, like Gecko) Chrome/") - .concat(CHROME_VERSION).concat(" Safari/537.36 Edg/").concat(EDGE_VERSION); - } - -} diff --git a/src/main/java/com/airsaid/localization/translate/util/LRUCache.java b/src/main/java/com/airsaid/localization/translate/util/LRUCache.java deleted file mode 100644 index 0cb5600..0000000 --- a/src/main/java/com/airsaid/localization/translate/util/LRUCache.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.util; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.function.BiConsumer; - -/** - * @author airsaid - */ -public class LRUCache { - - private final Map> caches; - private Node head; - private Node tail; - - private int maxCapacity; - - public LRUCache(int initialCapacity) { - maxCapacity = initialCapacity; - if (initialCapacity <= 0) { - throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity); - } - caches = new LinkedHashMap<>(initialCapacity); - } - - public void put(K key, V value) { - while (isFull()) { - removeTailNode(); - } - Node newNode = new Node<>(key, value); - caches.put(key, newNode); - moveToHeadNode(newNode); - } - - public V get(K key) { - if (caches.containsKey(key)) { - Node newHead = caches.get(key); - moveToHeadNode(newHead); - return newHead.value; - } - return null; - } - - public int size() { - return caches.size(); - } - - public boolean isFull() { - return size() > 0 && size() >= maxCapacity; - } - - public boolean isEmpty() { - return size() <= 0; - } - - public void forEach(BiConsumer consumer) { - for (Map.Entry> entry : caches.entrySet()) { - K key = entry.getKey(); - Node value = entry.getValue(); - consumer.accept(key, value.value); - } - } - - public void clear() { - caches.clear(); - head = null; - tail = null; - } - - private void moveToHeadNode(Node node) { - if (head == null) { - head = node; - tail = node; - return; - } - - node.next = head; - head.prev = node; - head = node; - } - - private void removeTailNode() { - if (tail == null) return; - - caches.remove(tail.key); - Node prev = tail.prev; - if (prev != null) { - prev.next = null; - tail.prev = null; - } - tail = prev; - } - - public void setMaxCapacity(int maxCapacity) { - this.maxCapacity = maxCapacity; - } - - public int getMaxCapacity() { - return maxCapacity; - } - - private static class Node { - public K key; - public V value; - public Node prev; - public Node next; - - public Node(K key, V value) { - this.key = key; - this.value = value; - } - } - -} diff --git a/src/main/java/com/airsaid/localization/translate/util/MD5.java b/src/main/java/com/airsaid/localization/translate/util/MD5.java deleted file mode 100755 index 817be5e..0000000 --- a/src/main/java/com/airsaid/localization/translate/util/MD5.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.util; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -/** - * @author airsaid - */ -public class MD5 { - - private static final char[] hexDigits = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'}; - - private MD5() { - throw new AssertionError("No com.airsaid.localization.translate.util.MD5 instances for you!"); - } - - public static String md5(String input) { - if (input == null) { - return null; - } - - try { - MessageDigest messageDigest = MessageDigest.getInstance("MD5"); - byte[] inputByteArray = input.getBytes(StandardCharsets.UTF_8); - messageDigest.update(inputByteArray); - byte[] resultByteArray = messageDigest.digest(); - return byteArrayToHex(resultByteArray); - } catch (NoSuchAlgorithmException e) { - return null; - } - } - - private static String byteArrayToHex(byte[] byteArray) { - char[] resultCharArray = new char[byteArray.length * 2]; - int index = 0; - for (byte b : byteArray) { - resultCharArray[index++] = hexDigits[b >>> 4 & 0xf]; - resultCharArray[index++] = hexDigits[b & 0xf]; - } - return new String(resultCharArray); - } - -} diff --git a/src/main/java/com/airsaid/localization/translate/util/UrlBuilder.java b/src/main/java/com/airsaid/localization/translate/util/UrlBuilder.java deleted file mode 100644 index 05ae764..0000000 --- a/src/main/java/com/airsaid/localization/translate/util/UrlBuilder.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.util; - -import com.intellij.openapi.util.Pair; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -/** - * @author airsaid - */ -public class UrlBuilder { - - private final String baseUrl; - private final List> queryParameters; - - public UrlBuilder(String baseUrl) { - this.baseUrl = baseUrl; - queryParameters = new ArrayList<>(); - } - - public UrlBuilder addQueryParameter(String key, String value) { - queryParameters.add(Pair.create(key, value)); - return this; - } - - public UrlBuilder addQueryParameters(String key, String... values) { - queryParameters.addAll(Arrays.stream(values).map(value -> Pair.create(key, value)).collect(Collectors.toList())); - return this; - } - - public String build() { - StringBuilder result = new StringBuilder(baseUrl); - for (int i = 0; i < queryParameters.size(); i++) { - if (i == 0) { - result.append("?"); - } else { - result.append("&"); - } - Pair param = queryParameters.get(i); - String key = param.first; - String value = param.second; - result.append(key) - .append("=") - .append(value); - } - return result.toString(); - } - -} diff --git a/src/main/java/com/airsaid/localization/ui/FixedLinkLabel.java b/src/main/java/com/airsaid/localization/ui/FixedLinkLabel.java deleted file mode 100644 index 6258a4a..0000000 --- a/src/main/java/com/airsaid/localization/ui/FixedLinkLabel.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.ui; - -import com.intellij.icons.AllIcons; -import com.intellij.ui.components.labels.LinkLabel; - -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; - -/** - * Fixed the problem that sometimes click does not respond. - * - * @author airsaid - */ -public class FixedLinkLabel extends LinkLabel { - private boolean isDoClick = false; - - public FixedLinkLabel() { - super("", AllIcons.Ide.Link); - addMouseListener(new MouseAdapter() { - @Override - public void mouseReleased(MouseEvent e) { - if (isEnabled() && isInClickableArea(e.getPoint())) { - doClick(); - } - } - - @Override - public void mouseExited(MouseEvent e) { - isDoClick = false; - } - }); - } - - @Override - public void doClick() { - if (!isDoClick) { - isDoClick = true; - super.doClick(); - } - } -} diff --git a/src/main/java/com/airsaid/localization/ui/SelectLanguagesDialog.form b/src/main/java/com/airsaid/localization/ui/SelectLanguagesDialog.form deleted file mode 100644 index a4bbac5..0000000 --- a/src/main/java/com/airsaid/localization/ui/SelectLanguagesDialog.form +++ /dev/null @@ -1,73 +0,0 @@ - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/main/java/com/airsaid/localization/ui/SelectLanguagesDialog.java b/src/main/java/com/airsaid/localization/ui/SelectLanguagesDialog.java deleted file mode 100644 index fe2e967..0000000 --- a/src/main/java/com/airsaid/localization/ui/SelectLanguagesDialog.java +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.ui; - -import com.airsaid.localization.config.SettingsState; -import com.airsaid.localization.constant.Constants; -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.lang.Lang; -import com.airsaid.localization.translate.services.TranslatorService; -import com.airsaid.localization.utils.LanguageUtil; -import com.intellij.ide.util.PropertiesComponent; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.ui.DialogWrapper; -import com.intellij.ui.components.JBCheckBox; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.awt.*; -import java.awt.event.ItemEvent; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; - -/** - * Select the language dialog you want to Translate. - * - * @author airsaid - */ -public class SelectLanguagesDialog extends DialogWrapper { - private JPanel contentPanel; - private JCheckBox overwriteExistingStringCheckBox; - private JCheckBox selectAllCheckBox; - private JPanel languagesPanel; - private JCheckBox openTranslatedFileCheckBox; - private JLabel powerTranslatorLabel; - - private final Project project; - private OnClickListener onClickListener; - private final List selectedLanguages = new ArrayList<>(); - - public interface OnClickListener { - void onClickListener(List selectedLanguage); - } - - public SelectLanguagesDialog(@Nullable Project project) { - super(project, false); - this.project = project; - doCreateCenterPanel(); - setTitle("Select Translated Languages"); - init(); - } - - public void setOnClickListener(OnClickListener listener) { - onClickListener = listener; - } - - @Nullable - @Override - protected JComponent createCenterPanel() { - return contentPanel; - } - - private void doCreateCenterPanel() { - // add languages - selectedLanguages.clear(); - List supportedLanguages = Objects.requireNonNull(TranslatorService.getInstance().getSelectedTranslator()).getSupportedLanguages(); - supportedLanguages.sort(new EnglishNameComparator()); // sort by english name, easy to find - addLanguageList(supportedLanguages); - - // add options - initOverwriteExistingStringOption(); - initOpenTranslatedFileCheckBox(); - initSelectAllOption(); - - // set power ui - AbstractTranslator translator = TranslatorService.getInstance().getSelectedTranslator(); - powerTranslatorLabel.setText("Powered by " + translator.getName()); - powerTranslatorLabel.setIcon(translator.getIcon()); - } - - private void addLanguageList(List supportedLanguages) { - List selectedLanguageIds = LanguageUtil.getSelectedLanguageIds(project); - languagesPanel.setLayout(new GridLayout(supportedLanguages.size() / 4, 4)); - for (Lang language : supportedLanguages) { - String code = language.getCode(); - JBCheckBox checkBoxLanguage = new JBCheckBox(); - checkBoxLanguage.setText(language.getEnglishName() - .concat("(").concat(code).concat(")")); - languagesPanel.add(checkBoxLanguage); - checkBoxLanguage.addItemListener(e -> { - int state = e.getStateChange(); - if (state == ItemEvent.SELECTED) { - selectedLanguages.add(language); - } else { - selectedLanguages.remove(language); - } - // Update the OK button UI - getOKAction().setEnabled(selectedLanguages.size() > 0); - }); - if (selectedLanguageIds != null && selectedLanguageIds.contains(String.valueOf(language.getId()))) { - checkBoxLanguage.setSelected(true); - } - } - } - - private void initOverwriteExistingStringOption() { - boolean isOverwriteExistingString = PropertiesComponent.getInstance(project) - .getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING); - overwriteExistingStringCheckBox.setSelected(isOverwriteExistingString); - overwriteExistingStringCheckBox.addItemListener(e -> { - int state = e.getStateChange(); - PropertiesComponent.getInstance(project) - .setValue(Constants.KEY_IS_OVERWRITE_EXISTING_STRING, state == ItemEvent.SELECTED); - }); - } - - private void initOpenTranslatedFileCheckBox() { - boolean isOpenTranslatedFile = PropertiesComponent.getInstance(project) - .getBoolean(Constants.KEY_IS_OPEN_TRANSLATED_FILE); - openTranslatedFileCheckBox.setSelected(isOpenTranslatedFile); - openTranslatedFileCheckBox.addItemListener(e -> { - int state = e.getStateChange(); - PropertiesComponent.getInstance(project) - .setValue(Constants.KEY_IS_OPEN_TRANSLATED_FILE, state == ItemEvent.SELECTED); - }); - } - - private void initSelectAllOption() { - boolean isSelectAll = PropertiesComponent.getInstance(project) - .getBoolean(Constants.KEY_IS_SELECT_ALL); - selectAllCheckBox.setSelected(isSelectAll); - selectAllCheckBox.addItemListener(e -> { - int state = e.getStateChange(); - selectAll(state == ItemEvent.SELECTED); - PropertiesComponent.getInstance(project) - .setValue(Constants.KEY_IS_SELECT_ALL, state == ItemEvent.SELECTED); - }); - } - - private void selectAll(boolean selectAll) { - for (Component component : languagesPanel.getComponents()) { - if (component instanceof JBCheckBox) { - JBCheckBox checkBox = (JBCheckBox) component; - checkBox.setSelected(selectAll); - } - } - } - - @Override - protected @Nullable String getDimensionServiceKey() { - String key = SettingsState.getInstance().getSelectedTranslator().getKey(); - return "#com.airsaid.localization.ui.SelectLanguagesDialog#".concat(key); - } - - @Override - protected void doOKAction() { - LanguageUtil.saveSelectedLanguage(project, selectedLanguages); - if (onClickListener != null) { - onClickListener.onClickListener(selectedLanguages); - } - super.doOKAction(); - } - - static class EnglishNameComparator implements Comparator { - @Override - public int compare(Lang o1, Lang o2) { - return o1.getEnglishName().compareTo(o2.getEnglishName()); - } - } -} diff --git a/src/main/java/com/airsaid/localization/ui/SupportLanguagesDialog.java b/src/main/java/com/airsaid/localization/ui/SupportLanguagesDialog.java deleted file mode 100644 index 9325011..0000000 --- a/src/main/java/com/airsaid/localization/ui/SupportLanguagesDialog.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.ui; - -import com.airsaid.localization.translate.AbstractTranslator; -import com.airsaid.localization.translate.lang.Lang; -import com.intellij.openapi.ui.DialogWrapper; -import com.intellij.ui.components.JBLabel; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.*; -import java.awt.*; -import java.util.Comparator; -import java.util.List; - -/** - * @author airsaid - */ -public class SupportLanguagesDialog extends DialogWrapper { - - private final AbstractTranslator mTranslator; - - public SupportLanguagesDialog(AbstractTranslator translator) { - super(true); - setTitle(translator.getName() + " Translator Supported Languages"); - mTranslator = translator; - init(); - } - - @Override - protected @Nullable JComponent createCenterPanel() { - List supportedLanguages = mTranslator.getSupportedLanguages(); - supportedLanguages.sort(new EnglishNameComparator()); - JPanel contentPanel = new JPanel(new GridLayout(supportedLanguages.size() / 4, 4, 10, 20)); - for (Lang supportedLanguage : supportedLanguages) { - contentPanel.add(new JBLabel(supportedLanguage.getEnglishName())); - } - return contentPanel; - } - - @Override - protected @Nullable String getDimensionServiceKey() { - String key = mTranslator.getKey(); - return "#com.airsaid.localization.ui.SupportLanguagesDialog#".concat(key); - } - - @Override - protected Action @NotNull [] createActions() { - return new Action[]{}; - } - - static class EnglishNameComparator implements Comparator { - @Override - public int compare(Lang o1, Lang o2) { - return o1.getEnglishName().compareTo(o2.getEnglishName()); - } - } -} diff --git a/src/main/java/com/airsaid/localization/utils/LanguageUtil.java b/src/main/java/com/airsaid/localization/utils/LanguageUtil.java deleted file mode 100644 index 571a1ff..0000000 --- a/src/main/java/com/airsaid/localization/utils/LanguageUtil.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.utils; - -import com.airsaid.localization.constant.Constants; -import com.airsaid.localization.translate.lang.Lang; -import com.intellij.ide.util.PropertiesComponent; -import com.intellij.openapi.project.Project; -import org.apache.http.util.TextUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Arrays; -import java.util.List; -import java.util.Objects; - -/** - * A util class that operates on language data. - * - * @author airsaid - */ -public class LanguageUtil { - - private static final String SEPARATOR_SELECTED_LANGUAGES_CODE = ","; - - private LanguageUtil() { - throw new AssertionError("No com.airsaid.localization.utils.LanguageUtil instances for you!"); - } - - /** - * Save the language data selected in the current project. - * - * @param project current project. - * @param languages selected language. - */ - public static void saveSelectedLanguage(@NotNull Project project, @NotNull List languages) { - Objects.requireNonNull(project); - Objects.requireNonNull(languages); - - PropertiesComponent.getInstance(project) - .setValue(Constants.KEY_SELECTED_LANGUAGES, getLanguageIdString(languages)); - } - - /** - * Get saved language code data in the current project. - * - * @param project current project. - * @return null if not saved, otherwise return the saved language id data. - */ - @Nullable - public static List getSelectedLanguageIds(@NotNull Project project) { - Objects.requireNonNull(project); - - String codeString = PropertiesComponent.getInstance(project) - .getValue(Constants.KEY_SELECTED_LANGUAGES); - - if (TextUtils.isEmpty(codeString)) { - return null; - } - - return Arrays.asList(codeString.split(SEPARATOR_SELECTED_LANGUAGES_CODE)); - } - - @NotNull - private static String getLanguageIdString(@NotNull List language) { - StringBuilder codes = new StringBuilder(language.size()); - for (int i = 0, len = language.size(); i < len; i++) { - Lang lang = language.get(i); - codes.append(lang.getId()); - if (i < len - 1) { - codes.append(SEPARATOR_SELECTED_LANGUAGES_CODE); - } - } - return codes.toString(); - } - -} diff --git a/src/main/java/com/airsaid/localization/utils/NotificationUtil.java b/src/main/java/com/airsaid/localization/utils/NotificationUtil.java deleted file mode 100644 index 60194a5..0000000 --- a/src/main/java/com/airsaid/localization/utils/NotificationUtil.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.utils; - -import com.intellij.notification.NotificationGroup; -import com.intellij.notification.NotificationGroupManager; -import com.intellij.notification.NotificationType; -import com.intellij.openapi.project.Project; -import org.jetbrains.annotations.Nullable; - -/** - * @author airsaid - */ -public class NotificationUtil { - - private static final String NOTIFICATION_GROUP_ID = "Android Localize Plugin"; - - private static final NotificationGroup NOTIFICATION_GROUP = - NotificationGroupManager.getInstance().getNotificationGroup(NOTIFICATION_GROUP_ID); - - private NotificationUtil() { - throw new AssertionError("No com.airsaid.localization.utils.NotificationUtil instances for you!"); - } - - public static void notifyInfo(@Nullable Project project, String content) { - NOTIFICATION_GROUP.createNotification(content, NotificationType.INFORMATION) - .notify(project); - } - - public static void notifyWarning(@Nullable Project project, String content) { - NOTIFICATION_GROUP.createNotification(content, NotificationType.WARNING) - .notify(project); - } - - public static void notifyError(@Nullable Project project, String content) { - NOTIFICATION_GROUP.createNotification(content, NotificationType.ERROR) - .notify(project); - } -} diff --git a/src/main/java/com/airsaid/localization/utils/SecureStorage.java b/src/main/java/com/airsaid/localization/utils/SecureStorage.java deleted file mode 100644 index e4b7027..0000000 --- a/src/main/java/com/airsaid/localization/utils/SecureStorage.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.utils; - -import com.airsaid.localization.constant.Constants; -import com.intellij.credentialStore.CredentialAttributes; -import com.intellij.credentialStore.CredentialAttributesKt; -import com.intellij.credentialStore.Credentials; -import com.intellij.ide.passwordSafe.PasswordSafe; -import org.jetbrains.annotations.NotNull; - -/** - * @author airsaid - */ -public class SecureStorage { - - private final String key; - - public SecureStorage(@NotNull String key) { - this.key = key; - } - - public void save(@NotNull String text) { - CredentialAttributes credentialAttributes = createCredentialAttributes(); - Credentials credentials = new Credentials(key, text); - PasswordSafe.getInstance().set(credentialAttributes, credentials); - } - - @NotNull - public String read() { - String password = PasswordSafe.getInstance().getPassword(createCredentialAttributes()); - return password != null ? password : ""; - } - - @NotNull - private CredentialAttributes createCredentialAttributes() { - return new CredentialAttributes(CredentialAttributesKt.generateServiceName(Constants.PLUGIN_NAME, key)); - } -} diff --git a/src/main/java/icons/PluginIcons.java b/src/main/java/icons/PluginIcons.java deleted file mode 100644 index ffc3733..0000000 --- a/src/main/java/icons/PluginIcons.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2018 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package icons; - -import com.intellij.openapi.util.IconLoader; - -import javax.swing.*; - -/** - * @author airsaid - */ -public interface PluginIcons { - Icon TRANSLATE_ACTION_ICON = load("/icons/icon_translate.svg"); - Icon GOOGLE_ICON = load("/icons/icon_google.svg"); - Icon BAIDU_ICON = load("/icons/icon_baidu.svg"); - Icon YOUDAO_ICON = load("/icons/icon_youdao.svg"); - Icon MICROSOFT_ICON = load("/icons/icon_microsoft.svg"); - Icon ALI_ICON = load("/icons/icon_ali.svg"); - Icon DEEP_L_ICON = load("/icons/icon_deepl.svg"); - Icon OPENAI_ICON = load("/icons/icon_openai.svg"); - - private static Icon load(String path) { - return IconLoader.getIcon(path, PluginIcons.class); - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt b/src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt new file mode 100644 index 0000000..6db0648 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.action + +import com.airsaid.localization.config.SettingsState +import com.airsaid.localization.services.AndroidValuesService +import com.airsaid.localization.task.TranslateTask +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.ui.SelectLanguagesDialog +import com.airsaid.localization.utils.NotificationUtil +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.xml.XmlTag + +/** + * Translate android string value to other languages that can be used to localize your Android APP. + * + * @author airsaid + */ +class TranslateAction : AnAction(), SelectLanguagesDialog.OnClickListener { + + private lateinit var project: Project + private lateinit var valueFile: PsiFile + private lateinit var values: List + private val valueService = AndroidValuesService.getInstance() + + override fun actionPerformed(e: AnActionEvent) { + project = e.getRequiredData(CommonDataKeys.PROJECT) + valueFile = e.getRequiredData(CommonDataKeys.PSI_FILE) + + SettingsState.getInstance().initSetting() + + valueService.loadValuesByAsync(valueFile) { loadedValues -> + if (!isTranslatable(loadedValues)) { + NotificationUtil.notifyInfo(project, "The ${valueFile.name} has no text to translate.") + return@loadValuesByAsync + } + values = loadedValues + showSelectLanguageDialog() + } + } + + // Verify that there is a text in the value file that needs to be translated. + private fun isTranslatable(values: List): Boolean { + for (psiElement in values) { + if (psiElement is XmlTag) { + if (valueService.isTranslatable(psiElement)) { + return true + } + } + } + return false + } + + private fun showSelectLanguageDialog() { + val dialog = SelectLanguagesDialog(project) + dialog.setOnClickListener(this) + dialog.show() + } + + override fun update(e: AnActionEvent) { + // The translation option is only show when xml file from values is selected + val project = e.getData(CommonDataKeys.PROJECT) + val isSelectValueFile = valueService.isValueFile(e.getData(CommonDataKeys.PSI_FILE)) + e.presentation.setEnabledAndVisible(project != null && isSelectValueFile) + } + + override fun onClickListener(selectedLanguage: List) { + val translationTask = TranslateTask(project, "Translating...", selectedLanguage, values, valueFile) + translationTask.setOnTranslateListener(object : TranslateTask.OnTranslateListener { + override fun onTranslateSuccess() { + NotificationUtil.notifyInfo(project, "Translation completed!") + } + + override fun onTranslateError(e: Throwable) { + NotificationUtil.notifyError(project, "Translation failure: ${e.localizedMessage}") + } + }) + translationTask.queue() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt new file mode 100644 index 0000000..fcf238b --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.config + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.services.TranslatorService +import com.airsaid.localization.ui.FixedLinkLabel +import com.airsaid.localization.ui.SupportLanguagesDialog +import com.intellij.ide.BrowserUtil +import com.intellij.ide.HelpTooltip +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.util.text.StringUtil +import com.intellij.ui.CollectionComboBoxModel +import com.intellij.ui.SimpleListCellRenderer +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPasswordField +import com.intellij.ui.components.JBTextField +import javax.swing.* +import java.awt.event.ItemEvent + +/** + * @author airsaid + */ +class SettingsComponent { + companion object { + private val LOG = Logger.getInstance(SettingsComponent::class.java) + } + + private lateinit var contentJPanel: JPanel + private lateinit var translatorsComboBox: ComboBox + private lateinit var appIdLabel: JBLabel + private lateinit var appIdField: JBTextField + private lateinit var appKeyLabel: JBLabel + private lateinit var appKeyField: JBPasswordField + private lateinit var applyLink: FixedLinkLabel + private lateinit var supportLanguagesButton: JButton + private lateinit var maxCacheSizeLabel: JLabel + private lateinit var enableCacheCheckBox: JBCheckBox + private lateinit var maxCacheSizeComboBox: ComboBox + private lateinit var translationIntervalComboBox: ComboBox + + init { + initTranslatorComponents() + initCacheComponents() + } + + private fun initTranslatorComponents() { + translatorsComboBox.renderer = object : SimpleListCellRenderer() { + override fun customize( + list: JList, + value: AbstractTranslator, + index: Int, + selected: Boolean, + hasFocus: Boolean + ) { + text = value.name + icon = value.icon + } + } + + translatorsComboBox.addItemListener { itemEvent -> + if (itemEvent.stateChange == ItemEvent.SELECTED) { + setSelectedTranslator(selectedTranslator) + } + } + + applyLink.setListener({ _, _ -> + val selectedTranslator = selectedTranslator + val applyAppIdUrl = selectedTranslator.applyAppIdUrl + if (!StringUtil.isEmpty(applyAppIdUrl)) { + BrowserUtil.browse(applyAppIdUrl!!) + applyLink.isFocusable = false + } + }, null) + + supportLanguagesButton.addActionListener { + showSupportLanguagesDialog(selectedTranslator) + } + } + + private fun initCacheComponents() { + enableCacheCheckBox.addItemListener { event -> + when (event.stateChange) { + ItemEvent.SELECTED -> setEnableCache(true) + ItemEvent.DESELECTED -> setEnableCache(false) + } + } + } + + val selectedTranslator: AbstractTranslator + get() = translatorsComboBox.selectedItem as AbstractTranslator + + private fun showSupportLanguagesDialog(selectedTranslator: AbstractTranslator) { + SupportLanguagesDialog(selectedTranslator).show() + } + + val content: JPanel + get() = contentJPanel + + val preferredFocusedComponent: JComponent + get() = translatorsComboBox + + fun setTranslators(translators: Map) { + LOG.info("setTranslators: ${translators.keys}") + translatorsComboBox.model = CollectionComboBoxModel(ArrayList(translators.values)) + } + + fun setSelectedTranslator(selected: AbstractTranslator) { + LOG.info("setSelectedTranslator: $selected") + translatorsComboBox.selectedItem = selected + + val isNeedAppId = selected.isNeedAppId + appIdLabel.isVisible = isNeedAppId + appIdField.isVisible = isNeedAppId + if (isNeedAppId) { + appIdLabel.text = "${selected.appIdDisplay}:" + appIdField.text = selected.appId + } + + val isNeedAppKey = selected.isNeedAppKey + appKeyLabel.isVisible = isNeedAppKey + appKeyField.isVisible = isNeedAppKey + if (isNeedAppKey) { + appKeyLabel.text = "${selected.appKeyDisplay}:" + appKeyField.text = selected.appKey + } + + val applyAppIdUrl = selected.applyAppIdUrl + if (!StringUtil.isEmpty(applyAppIdUrl)) { + applyLink.isVisible = true + HelpTooltip() + .setDescription("Apply for ${selected.name} translation API service") + .installOn(applyLink) + } else { + applyLink.isVisible = false + } + } + + val isSelectedDefaultTranslator: Boolean + get() = isSelectedDefaultTranslator(selectedTranslator) + + private fun isSelectedDefaultTranslator(selected: AbstractTranslator): Boolean { + return selected == TranslatorService.getInstance().getDefaultTranslator() + } + + val appId: String + get() = appIdField.text ?: "" + + fun setAppId(appId: String) { + appIdField.text = appId + } + + val appKey: String + get() { + val password = appKeyField.password + return if (password != null) String(password) else "" + } + + fun setAppKey(appKey: String) { + appKeyField.text = appKey + } + + fun setEnableCache(isEnable: Boolean) { + enableCacheCheckBox.isSelected = isEnable + maxCacheSizeComboBox.isVisible = isEnable + maxCacheSizeLabel.isVisible = isEnable + } + + val isEnableCache: Boolean + get() = enableCacheCheckBox.isSelected + + val maxCacheSize: Int + get() = (maxCacheSizeComboBox.selectedItem as String).toInt() + + fun setMaxCacheSize(maxCacheSize: Int) { + maxCacheSizeComboBox.selectedItem = maxCacheSize.toString() + } + + val translationInterval: Int + get() = (translationIntervalComboBox.selectedItem as String).toInt() + + fun setTranslationInterval(intervalTime: Int) { + translationIntervalComboBox.selectedItem = intervalTime.toString() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt new file mode 100644 index 0000000..5541481 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.config + +import com.airsaid.localization.constant.Constants +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.services.TranslatorService +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.options.ConfigurationException +import com.intellij.openapi.util.text.StringUtil +import javax.swing.JComponent + +/** + * @author airsaid + */ +class SettingsConfigurable : Configurable { + companion object { + private val LOG = Logger.getInstance(SettingsConfigurable::class.java) + } + + private var settingsComponent: SettingsComponent? = null + + override fun getDisplayName(): String { + return Constants.PLUGIN_NAME + } + + override fun getPreferredFocusedComponent(): JComponent? { + return settingsComponent?.preferredFocusedComponent + } + + override fun createComponent(): JComponent? { + settingsComponent = SettingsComponent() + initComponents() + return settingsComponent?.content + } + + private fun initComponents() { + val settingsState = SettingsState.getInstance() + val translators = TranslatorService.getInstance().getTranslators() + settingsComponent?.let { component -> + component.setTranslators(translators) + component.setSelectedTranslator(translators[settingsState.selectedTranslator.key]!!) + component.setEnableCache(settingsState.isEnableCache) + component.setMaxCacheSize(settingsState.maxCacheSize) + component.setTranslationInterval(settingsState.translationInterval) + } + } + + override fun isModified(): Boolean { + val settingsState = SettingsState.getInstance() + val selectedTranslator = settingsComponent?.selectedTranslator ?: return false + + var isChanged = settingsState.selectedTranslator != selectedTranslator + isChanged = isChanged || settingsState.getAppId(selectedTranslator.key) != selectedTranslator.appId + isChanged = isChanged || settingsState.getAppKey(selectedTranslator.key) != selectedTranslator.appKey + isChanged = isChanged || settingsState.isEnableCache != (settingsComponent?.isEnableCache ?: false) + isChanged = isChanged || settingsState.maxCacheSize != (settingsComponent?.maxCacheSize ?: 0) + isChanged = isChanged || settingsState.translationInterval != (settingsComponent?.translationInterval ?: 0) + + LOG.info("isModified: $isChanged") + return isChanged + } + + @Throws(ConfigurationException::class) + override fun apply() { + val settingsState = SettingsState.getInstance() + val selectedTranslator = settingsComponent?.selectedTranslator + ?: throw ConfigurationException("No translator selected") + + LOG.info("apply selectedTranslator: ${selectedTranslator.name}") + + // Verify that the required parameters are not configured + if (selectedTranslator.isNeedAppId && StringUtil.isEmpty(settingsComponent?.appId)) { + throw ConfigurationException("${selectedTranslator.appIdDisplay} not configured") + } + if (selectedTranslator.isNeedAppKey && StringUtil.isEmpty(settingsComponent?.appKey)) { + throw ConfigurationException("${selectedTranslator.appKeyDisplay} not configured") + } + + settingsState.selectedTranslator = selectedTranslator + if (selectedTranslator.isNeedAppId) { + settingsComponent?.appId?.let { appId -> + settingsState.setAppId(selectedTranslator.key, appId) + } + } + if (selectedTranslator.isNeedAppKey) { + settingsComponent?.appKey?.let { appKey -> + settingsState.setAppKey(selectedTranslator.key, appKey) + } + } + + settingsComponent?.let { component -> + settingsState.isEnableCache = component.isEnableCache + settingsState.maxCacheSize = component.maxCacheSize + settingsState.translationInterval = component.translationInterval + + val translatorService = TranslatorService.getInstance() + translatorService.setSelectedTranslator(selectedTranslator) + translatorService.setEnableCache(component.isEnableCache) + translatorService.maxCacheSize = component.maxCacheSize + translatorService.translationInterval = component.translationInterval + } + } + + override fun reset() { + LOG.info("reset") + val settingsState = SettingsState.getInstance() + val selectedTranslator = settingsState.selectedTranslator + settingsComponent?.let { component -> + component.setSelectedTranslator(selectedTranslator) + component.setAppId(settingsState.getAppId(selectedTranslator.key)) + component.setAppKey(settingsState.getAppKey(selectedTranslator.key)) + component.setEnableCache(settingsState.isEnableCache) + component.setMaxCacheSize(settingsState.maxCacheSize) + component.setTranslationInterval(settingsState.translationInterval) + } + } + + override fun disposeUIResources() { + settingsComponent = null + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt new file mode 100644 index 0000000..518b3e4 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.config + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.services.TranslatorService +import com.airsaid.localization.utils.SecureStorage +import com.intellij.openapi.components.* +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.text.StringUtil + +/** + * @author airsaid + */ +@State( + name = "com.airsaid.localization.config.SettingsState", + storages = [Storage("androidLocalizeSettings.xml")] +) +@Service +class SettingsState : PersistentStateComponent { + companion object { + private val LOG = Logger.getInstance(SettingsState::class.java) + + fun getInstance(): SettingsState { + return ServiceManager.getService(SettingsState::class.java) + } + } + + private val appKeyStorage: Map + private var state = State() + + init { + val storage = mutableMapOf() + val translatorService = TranslatorService.getInstance() + val translators = translatorService.getTranslators().values + for (translator in translators) { + if (translatorService.getDefaultTranslator() != translator) { + storage[translator.key] = SecureStorage(translator.key) + } + } + appKeyStorage = storage + } + + fun initSetting() { + val translatorService = TranslatorService.getInstance() + val selectedTranslator = translatorService.getSelectedTranslator() + if (selectedTranslator == null) { + LOG.info("initSetting") + translatorService.setSelectedTranslator(this.selectedTranslator) + translatorService.setEnableCache(isEnableCache) + translatorService.maxCacheSize = maxCacheSize + translatorService.translationInterval = translationInterval + } + } + + var selectedTranslator: AbstractTranslator + get() = if (StringUtil.isEmpty(state.selectedTranslatorKey)) { + TranslatorService.getInstance().getDefaultTranslator() + } else { + TranslatorService.getInstance().getTranslators()[state.selectedTranslatorKey] + ?: TranslatorService.getInstance().getDefaultTranslator() + } + set(translator) { + state.selectedTranslatorKey = translator.key + } + + fun setAppId(translatorKey: String, appId: String) { + state.appIds[translatorKey] = appId + } + + fun getAppId(translatorKey: String): String { + return state.appIds[translatorKey] ?: "" + } + + fun setAppKey(translatorKey: String, appKey: String) { + val secureStorage = appKeyStorage[translatorKey] + secureStorage?.save(appKey) + } + + fun getAppKey(translatorKey: String): String { + val secureStorage = appKeyStorage[translatorKey] + return secureStorage?.read() ?: "" + } + + var isEnableCache: Boolean + get() = state.isEnableCache + set(isEnable) { + state.isEnableCache = isEnable + } + + var maxCacheSize: Int + get() = state.maxCacheSize + set(maxCacheSize) { + state.maxCacheSize = maxCacheSize + } + + var translationInterval: Int + get() = state.translationInterval + set(intervalTime) { + state.translationInterval = intervalTime + } + + override fun getState(): State { + return state + } + + override fun loadState(state: State) { + this.state = state + } + + data class State( + var selectedTranslatorKey: String? = null, + var appIds: MutableMap = mutableMapOf(), + var isEnableCache: Boolean = true, + var maxCacheSize: Int = 500, + var translationInterval: Int = 2 // 2 second + ) +} \ No newline at end of file diff --git a/src/main/java/com/airsaid/localization/constant/Constants.java b/src/main/kotlin/com/airsaid/localization/constant/Constants.kt similarity index 57% rename from src/main/java/com/airsaid/localization/constant/Constants.java rename to src/main/kotlin/com/airsaid/localization/constant/Constants.kt index 85ad332..bf72d2b 100644 --- a/src/main/java/com/airsaid/localization/constant/Constants.java +++ b/src/main/kotlin/com/airsaid/localization/constant/Constants.kt @@ -15,25 +15,18 @@ * */ -package com.airsaid.localization.constant; +package com.airsaid.localization.constant /** * Constant Store. * * @author airsaid */ -public interface Constants { - - String PLUGIN_NAME = "AndroidLocalize"; - - String PLUGIN_ID = "com.github.airsaid.androidlocalize"; - - String KEY_SELECTED_LANGUAGES = PLUGIN_ID.concat(".selected_languages"); - - String KEY_IS_OVERWRITE_EXISTING_STRING = PLUGIN_ID.concat(".is_overwrite_existing_string"); - - String KEY_IS_SELECT_ALL = PLUGIN_ID.concat(".is_select_all"); - - String KEY_IS_OPEN_TRANSLATED_FILE = PLUGIN_ID.concat(".is_open_translated_file"); - -} +object Constants { + const val PLUGIN_NAME = "AndroidLocalize" + const val PLUGIN_ID = "com.github.airsaid.androidlocalize" + const val KEY_SELECTED_LANGUAGES = "$PLUGIN_ID.selected_languages" + const val KEY_IS_OVERWRITE_EXISTING_STRING = "$PLUGIN_ID.is_overwrite_existing_string" + const val KEY_IS_SELECT_ALL = "$PLUGIN_ID.is_select_all" + const val KEY_IS_OPEN_TRANSLATED_FILE = "$PLUGIN_ID.is_open_translated_file" +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt b/src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt new file mode 100644 index 0000000..54bb94c --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.services + +import com.airsaid.localization.translate.lang.Lang +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.ServiceManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Computable +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import com.intellij.psi.xml.XmlFile +import com.intellij.psi.xml.XmlTag +import com.intellij.util.Consumer +import java.io.* +import java.nio.charset.StandardCharsets +import java.util.regex.Pattern + +/** + * Operation service for the android value files. eg: strings.xml (or any string resource from values directory). + * + * @author airsaid + */ +@Service +class AndroidValuesService { + companion object { + private val LOG = Logger.getInstance(AndroidValuesService::class.java) + private val STRINGS_FILE_NAME_PATTERN = Pattern.compile(".+\\.xml") + + /** + * Returns the [AndroidValuesService] object instance. + * + * @return the [AndroidValuesService] object instance. + */ + fun getInstance(): AndroidValuesService { + return ServiceManager.getService(AndroidValuesService::class.java) + } + } + + /** + * Asynchronous loading the value file as the [PsiElement] collection. + * + * @param valueFile the value file. + * @param consumer load result. called in the event dispatch thread. + */ + fun loadValuesByAsync(valueFile: PsiFile, consumer: Consumer>) { + ApplicationManager.getApplication().executeOnPooledThread { + val values = loadValues(valueFile) + ApplicationManager.getApplication().invokeLater { + consumer.consume(values) + } + } + } + + /** + * Loading the value file as the [PsiElement] collection. + * + * @param valueFile the value file. + * @return [PsiElement] collection. + */ + fun loadValues(valueFile: PsiFile): List { + return ApplicationManager.getApplication().runReadAction(Computable { + LOG.info("loadValues valueFile: ${valueFile.name}") + val values = parseValuesXml(valueFile) + LOG.info("loadValues parsed ${valueFile.name} result: $values") + values + }) + } + + private fun parseValuesXml(valueFile: PsiFile): List { + val values = mutableListOf() + val xmlFile = valueFile as XmlFile + + val document = xmlFile.document ?: return values + val rootTag = document.rootTag ?: return values + + val subTags = rootTag.children + values.addAll(subTags) + + return values + } + + /** + * Write [PsiElement] collection data to the specified file. + * + * @param values specified [PsiElement] collection data. + * @param valueFile specified file. + */ + fun writeValueFile(values: List, valueFile: File) { + val isCreateSuccess = FileUtil.createIfDoesntExist(valueFile) + if (!isCreateSuccess) { + LOG.error("Failed to write to ${valueFile.path} file: create failed!") + return + } + + ApplicationManager.getApplication().invokeLater { + ApplicationManager.getApplication().runWriteAction { + try { + BufferedWriter(OutputStreamWriter(FileOutputStream(valueFile, false), StandardCharsets.UTF_8)).use { bw -> + for (value in values) { + bw.write(value.text) + } + bw.flush() + } + } catch (e: IOException) { + e.printStackTrace() + LOG.error("Failed to write to ${valueFile.path} file.", e) + } + } + } + } + + /** + * Verify that the specified file is a string resource file in the values directory. + * + * @param file the verify file. + * @return true: the file is a string resource file in the values directory. + */ + fun isValueFile(file: PsiFile?): Boolean { + if (file == null) return false + + val parent = file.parent ?: return false + val parentName = parent.name + if ("values" != parentName) return false + + val fileName = file.name + return STRINGS_FILE_NAME_PATTERN.matcher(fileName).matches() + } + + /** + * Get the value file of the specified language in the specified project resource directory. + * + * @param project current project. + * @param resourceDir specified resource directory. + * @param lang specified language. + * @param fileName the name of value file. + * @return null if not exist, otherwise return the value file. + */ + fun getValuePsiFile( + project: Project, + resourceDir: VirtualFile, + lang: Lang, + fileName: String + ): PsiFile? { + return ApplicationManager.getApplication().runReadAction(Computable { + val virtualFile = LocalFileSystem.getInstance().findFileByIoFile(getValueFile(resourceDir, lang, fileName)) + ?: return@Computable null + PsiManager.getInstance(project).findFile(virtualFile) + }) + } + + /** + * Get the value file in the `values` directory of the specified language in the resource directory. + * + * @param resourceDir specified resource directory. + * @param lang specified language. + * @param fileName the name of value file. + * @return the value file. + */ + fun getValueFile(resourceDir: VirtualFile, lang: Lang, fileName: String): File { + return File(resourceDir.path + File.separator + getValuesDirectoryName(lang), fileName) + } + + private fun getValuesDirectoryName(lang: Lang): String { + return "values-${lang.code}" + } + + /** + * Returns whether the specified xml tag (string entry) needs to be translated. + * + * @param xmlTag the specified xml tag of string entry. + * @return true: need translation. false: no translation is needed. + */ + fun isTranslatable(xmlTag: XmlTag): Boolean { + return ApplicationManager.getApplication().runReadAction(Computable { + val translatableStr = xmlTag.getAttributeValue("translatable") + (translatableStr ?: "true").toBoolean() + }) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt new file mode 100644 index 0000000..ac33eb9 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt @@ -0,0 +1,264 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.task + +import com.airsaid.localization.constant.Constants +import com.airsaid.localization.services.AndroidValuesService +import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.services.TranslatorService +import com.airsaid.localization.utils.TextUtil +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Computable +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.xml.* +import java.io.File +import java.util.* +import java.util.stream.Collectors +import kotlin.collections.ArrayList + +/** + * @author airsaid + */ +class TranslateTask( + project: Project?, + title: String, + private val toLanguages: List, + private val values: List, + valueFile: PsiFile +) : Task.Backgroundable(project, title) { + + companion object { + private const val NAME_TAG_STRING = "string" + private const val NAME_TAG_PLURALS = "plurals" + private const val NAME_TAG_STRING_ARRAY = "string-array" + private val LOG = Logger.getInstance(TranslateTask::class.java) + } + + interface OnTranslateListener { + fun onTranslateSuccess() + fun onTranslateError(e: Throwable) + } + + private val valueFile: VirtualFile = valueFile.virtualFile + private val translatorService = TranslatorService.getInstance() + private val valueService = AndroidValuesService.getInstance() + private var onTranslateListener: OnTranslateListener? = null + private var translationError: TranslationException? = null + + /** + * Set translate result listener. + * + * @param listener callback interface. success or fail. + */ + fun setOnTranslateListener(listener: OnTranslateListener) { + onTranslateListener = listener + } + + override fun run(progressIndicator: ProgressIndicator) { + val isOverwriteExistingString = PropertiesComponent.getInstance(project) + .getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING) + LOG.info("run isOverwriteExistingString: $isOverwriteExistingString") + + for (toLanguage in toLanguages) { + if (progressIndicator.isCanceled) break + + progressIndicator.text = "Translation to ${toLanguage.englishName}..." + + val resourceDir = valueFile.parent.parent + val valueFileName = valueFile.name + val toValuePsiFile = valueService.getValuePsiFile(project, resourceDir, toLanguage, valueFileName) + LOG.info("Translating language: ${toLanguage.englishName}, toValuePsiFile: $toValuePsiFile") + + val translatedValues = if (toValuePsiFile != null) { + val toValues = valueService.loadValues(toValuePsiFile) + val toValuesMap = toValues.stream().collect(Collectors.toMap( + { psiElement -> + if (psiElement is XmlTag) { + ApplicationManager.getApplication().runReadAction(Computable { + psiElement.getAttributeValue("name") ?: UUID.randomUUID().toString() + }) + } else { + UUID.randomUUID().toString() + } + }, + { it } + )) + val translated = doTranslate(progressIndicator, toLanguage, toValuesMap, isOverwriteExistingString) + writeTranslatedValues(progressIndicator, File(toValuePsiFile.virtualFile.path), translated) + translated + } else { + val translated = doTranslate(progressIndicator, toLanguage, null, isOverwriteExistingString) + val valueFile = valueService.getValueFile(resourceDir, toLanguage, valueFileName) + writeTranslatedValues(progressIndicator, valueFile, translated) + translated + } + + // If an exception occurs during the translation of the language, + // the translation of the subsequent languages is terminated. + // This prevents the loss of successfully translated strings in that language. + translationError?.let { throw it } + } + } + + private fun doTranslate( + progressIndicator: ProgressIndicator, + toLanguage: Lang, + toValues: Map?, + isOverwrite: Boolean + ): List { + LOG.info("doTranslate toLanguage: ${toLanguage.englishName}, toValues: $toValues, isOverwrite: $isOverwrite") + + val translatedValues = ArrayList() + for (value in values) { + if (progressIndicator.isCanceled) break + + if (value is XmlTag) { + if (!valueService.isTranslatable(value)) { + translatedValues.add(value) + continue + } + + val name = ApplicationManager.getApplication().runReadAction(Computable { + value.getAttributeValue("name") + }) + + if (!isOverwrite && toValues != null && toValues.containsKey(name)) { + toValues[name]?.let { translatedValues.add(it) } + continue + } + + val translateValue = ApplicationManager.getApplication().runReadAction(Computable { + value.copy() as XmlTag + }) + + translatedValues.add(translateValue) + when (translateValue.name) { + NAME_TAG_STRING -> { + doTranslate(progressIndicator, toLanguage, translateValue) + } + NAME_TAG_STRING_ARRAY, NAME_TAG_PLURALS -> { + val subTags = ApplicationManager.getApplication() + .runReadAction(Computable { translateValue.subTags }) + for (subTag in subTags) { + doTranslate(progressIndicator, toLanguage, subTag) + } + } + } + } else { + translatedValues.add(value) + } + } + return translatedValues + } + + private fun doTranslate( + progressIndicator: ProgressIndicator, + toLanguage: Lang, + xmlTag: XmlTag + ) { + if (progressIndicator.isCanceled || isXliffTag(xmlTag)) return + + val xmlTagValue = ApplicationManager.getApplication() + .runReadAction(Computable { xmlTag.value }) + val children = xmlTagValue.children + + for (child in children) { + when (child) { + is XmlText -> { + val text = ApplicationManager.getApplication() + .runReadAction(Computable { child.value }) + if (TextUtil.isEmptyOrSpacesLineBreak(text)) { + continue + } + try { + val translatedText = translatorService.doTranslate(Languages.AUTO, toLanguage, text) + ApplicationManager.getApplication().runReadAction { + child.setValue(translatedText) + } + } catch (e: TranslationException) { + LOG.warn(e) + // Just catch the error and wait for that file to be translated and released. + translationError = e + } + } + is XmlTag -> { + doTranslate(progressIndicator, toLanguage, child) + } + } + } + } + + private fun writeTranslatedValues( + progressIndicator: ProgressIndicator, + valueFile: File, + translatedValues: List + ) { + LOG.info("writeTranslatedValues valueFile: $valueFile, translatedValues: $translatedValues") + + if (progressIndicator.isCanceled || translatedValues.isEmpty()) return + + progressIndicator.text = "Writing to ${valueFile.parentFile.name} data..." + valueService.writeValueFile(translatedValues, valueFile) + + refreshAndOpenFile(valueFile) + } + + private fun refreshAndOpenFile(file: File) { + val virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) + val isOpenTranslatedFile = PropertiesComponent.getInstance(project) + .getBoolean(Constants.KEY_IS_OPEN_TRANSLATED_FILE) + if (virtualFile != null && isOpenTranslatedFile) { + ApplicationManager.getApplication().invokeLater { + FileEditorManager.getInstance(project).openFile(virtualFile, true) + } + } + } + + private fun isXliffTag(xmlTag: XmlTag?): Boolean { + return xmlTag != null && "xliff:g" == xmlTag.name + } + + override fun onSuccess() { + super.onSuccess() + translateSuccess() + } + + override fun onThrowable(error: Throwable) { + super.onThrowable(error) + translateError(error) + } + + private fun translateSuccess() { + onTranslateListener?.onTranslateSuccess() + } + + private fun translateError(error: Throwable) { + onTranslateListener?.onTranslateError(error) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt new file mode 100644 index 0000000..2d43dae --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate + +import com.airsaid.localization.config.SettingsState +import com.airsaid.localization.translate.lang.Lang +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Pair +import com.intellij.util.io.HttpRequests +import com.intellij.util.io.RequestBuilder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import javax.swing.Icon + +/** + * @author airsaid + */ +abstract class AbstractTranslator : Translator, TranslatorConfigurable { + + abstract override val key: String + abstract override val name: String + + companion object { + protected val LOG = Logger.getInstance(AbstractTranslator::class.java) + private const val CONTENT_TYPE = "application/x-www-form-urlencoded" + } + + @Throws(TranslationException::class) + override fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { + checkSupportedLanguages(fromLang, toLang, text) + + val requestUrl = getRequestUrl(fromLang, toLang, text) + val requestBuilder = HttpRequests.post(requestUrl, CONTENT_TYPE) + // Set the timeout time to 60 seconds. + requestBuilder.connectTimeout(60 * 1000) + configureRequestBuilder(requestBuilder) + + return try { + requestBuilder.connect { request -> + val requestParams = getRequestParams(fromLang, toLang, text) + .joinToString("&") { pair -> + "${pair.first}=${URLEncoder.encode(pair.second, StandardCharsets.UTF_8)}" + } + if (requestParams.isNotEmpty()) { + request.write(requestParams) + } + val requestBody = getRequestBody(fromLang, toLang, text) + if (requestBody.isNotEmpty()) { + request.write(requestBody) + } + + val resultText = request.readString() + parsingResult(fromLang, toLang, text, resultText) + } + } catch (e: Exception) { + e.printStackTrace() + LOG.error(e.message, e) + throw TranslationException(fromLang, toLang, text, e) + } + } + + override val icon: Icon? = null + + override val isNeedAppId: Boolean = true + + override val appId: String? + get() = SettingsState.getInstance().getAppId(key) + + override val appIdDisplay: String = "APP ID" + + override val isNeedAppKey: Boolean = true + + override val appKey: String? + get() = SettingsState.getInstance().getAppKey(key) + + override val appKeyDisplay: String = "APP KEY" + + override val applyAppIdUrl: String? = null + + protected open fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + throw UnsupportedOperationException() + } + + protected open fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + return emptyList() + } + + protected open fun getRequestBody(fromLang: Lang, toLang: Lang, text: String): String { + return "" + } + + protected open fun configureRequestBuilder(requestBuilder: RequestBuilder) { + // Default implementation does nothing + } + + protected open fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + throw UnsupportedOperationException() + } + + protected fun checkSupportedLanguages(fromLang: Lang, toLang: Lang, text: String) { + if (!supportedLanguages.contains(toLang)) { + throw TranslationException(fromLang, toLang, text, "${toLang.englishName} is not supported.") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/TranslationException.kt b/src/main/kotlin/com/airsaid/localization/translate/TranslationException.kt new file mode 100644 index 0000000..ecc142d --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/TranslationException.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate + +import com.airsaid.localization.translate.lang.Lang +import com.intellij.openapi.diagnostic.Logger + +/** + * @author airsaid + */ +class TranslationException : RuntimeException { + + companion object { + private val LOG = Logger.getInstance(TranslationException::class.java) + } + + val fromLang: Lang + val toLang: Lang + val text: String + + constructor(fromLang: Lang, toLang: Lang, text: String, cause: Throwable) : super( + "Failed to translate \"$text\" from ${fromLang.englishName} to ${toLang.englishName} with error: ${cause.message}", + cause + ) { + this.fromLang = fromLang + this.toLang = toLang + this.text = text + cause.printStackTrace() + LOG.error("TranslationException: ${cause.message}", cause) + } + + constructor(fromLang: Lang, toLang: Lang, text: String, message: String) : super( + "Failed to translate \"$text\" from ${fromLang.englishName} to ${toLang.englishName} with error: $message" + ) { + this.fromLang = fromLang + this.toLang = toLang + this.text = text + LOG.error("TranslationException: $message") + } +} \ No newline at end of file diff --git a/src/main/java/com/airsaid/localization/translate/TranslationResult.java b/src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt similarity index 73% rename from src/main/java/com/airsaid/localization/translate/TranslationResult.java rename to src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt index 30c480a..1c8e43f 100644 --- a/src/main/java/com/airsaid/localization/translate/TranslationResult.java +++ b/src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt @@ -15,10 +15,9 @@ * */ -package com.airsaid.localization.translate; +package com.airsaid.localization.translate -import com.airsaid.localization.translate.impl.google.GoogleTranslationResult; -import org.jetbrains.annotations.NotNull; +import com.airsaid.localization.translate.impl.google.GoogleTranslationResult /** * Translation results interface to obtain common translation result. @@ -26,14 +25,12 @@ * @author airsaid * @see GoogleTranslationResult */ -public interface TranslationResult { +interface TranslationResult { - /** - * Get a translation result of the specified text. - * - * @return translation result text. - */ - @NotNull - String getTranslationResult(); - -} + /** + * Get a translation result of the specified text. + * + * @return translation result text. + */ + val translationResult: String +} \ No newline at end of file diff --git a/src/main/java/com/airsaid/localization/translate/Translator.java b/src/main/kotlin/com/airsaid/localization/translate/Translator.kt similarity index 73% rename from src/main/java/com/airsaid/localization/translate/Translator.java rename to src/main/kotlin/com/airsaid/localization/translate/Translator.kt index cee9599..7204dd1 100644 --- a/src/main/java/com/airsaid/localization/translate/Translator.java +++ b/src/main/kotlin/com/airsaid/localization/translate/Translator.kt @@ -15,21 +15,20 @@ * */ -package com.airsaid.localization.translate; +package com.airsaid.localization.translate -import com.airsaid.localization.translate.impl.google.GoogleTranslator; -import com.airsaid.localization.translate.lang.Lang; -import org.jetbrains.annotations.NotNull; +import com.airsaid.localization.translate.impl.google.GoogleTranslator +import com.airsaid.localization.translate.lang.Lang /** - * The translator interface, the direct implementation class is {@link AbstractTranslator}, - * and all translators should extends {@link AbstractTranslator} to avoid writing duplicate code. + * The translator interface, the direct implementation class is [AbstractTranslator], + * and all translators should extends [AbstractTranslator] to avoid writing duplicate code. * * @author airsaid * @see AbstractTranslator * @see GoogleTranslator */ -public interface Translator { +interface Translator { /** * Invoke translation operation. @@ -40,6 +39,7 @@ public interface Translator { * @return the translated text. * @throws TranslationException this exception is thrown if the translation failed. */ - String doTranslate(@NotNull Lang fromLang, @NotNull Lang toLang, @NotNull String text) throws TranslationException; + @Throws(TranslationException::class) + fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String -} +} \ No newline at end of file diff --git a/src/main/java/com/airsaid/localization/translate/util/GsonUtil.java b/src/main/kotlin/com/airsaid/localization/translate/TranslatorConfigurable.kt similarity index 58% rename from src/main/java/com/airsaid/localization/translate/util/GsonUtil.java rename to src/main/kotlin/com/airsaid/localization/translate/TranslatorConfigurable.kt index d52c5d4..9608284 100644 --- a/src/main/java/com/airsaid/localization/translate/util/GsonUtil.java +++ b/src/main/kotlin/com/airsaid/localization/translate/TranslatorConfigurable.kt @@ -15,32 +15,35 @@ * */ -package com.airsaid.localization.translate.util; +package com.airsaid.localization.translate -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; +import com.airsaid.localization.translate.lang.Lang +import javax.swing.Icon /** * @author airsaid */ -public class GsonUtil { +interface TranslatorConfigurable { - private final Gson gson; + val key: String - public GsonUtil() { - gson = new GsonBuilder().create(); - } + val name: String - public static GsonUtil getInstance() { - return GsonUtilHolder.sInstance; - } + val icon: Icon? - public Gson getGson() { - return gson; - } + val supportedLanguages: List - private static class GsonUtilHolder { - private static final GsonUtil sInstance = new GsonUtil(); - } + val isNeedAppId: Boolean -} + val appId: String? + + val appIdDisplay: String + + val isNeedAppKey: Boolean + + val appKey: String? + + val appKeyDisplay: String + + val applyAppIdUrl: String? +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt new file mode 100644 index 0000000..af60f0c --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.ali + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages +import com.aliyun.alimt20181012.Client +import com.aliyun.alimt20181012.models.TranslateGeneralRequest +import com.aliyun.alimt20181012.models.TranslateGeneralResponse +import com.aliyun.teaopenapi.models.Config +import com.aliyun.teautil.models.RuntimeOptions +import com.google.auto.service.AutoService +import icons.PluginIcons +import javax.swing.Icon + +/** + * @author airsaid + */ +@AutoService(AbstractTranslator::class) +class AliTranslator : AbstractTranslator() { + + companion object { + private const val KEY = "Ali" + private const val ENDPOINT = "mt.aliyuncs.com" + private const val APPLY_APP_ID_URL = "https://www.aliyun.com/product/ai/base_alimt" + } + + private val config = Config() + private var _supportedLanguages: MutableList? = null + private var client: Client? = null + + override val key: String = KEY + + override val name: String = "Ali" + + override val icon: Icon? = PluginIcons.ALI_ICON + + override val supportedLanguages: List + get() { + if (_supportedLanguages == null) { + _supportedLanguages = mutableListOf().apply { + val languages = Languages.getLanguages() + for (i in 1 until languages.size) { + var lang = languages[i] + if (lang == Languages.UKRAINIAN || lang == Languages.DARI) { + continue + } + + lang = when (lang) { + Languages.CHINESE_SIMPLIFIED -> lang.setTranslationCode("zh") + Languages.CHINESE_TRADITIONAL -> lang.setTranslationCode("zh-tw") + Languages.INDONESIAN -> lang.setTranslationCode("id") + Languages.CROATIAN -> lang.setTranslationCode("hbs") + Languages.HEBREW -> lang.setTranslationCode("he") + else -> lang + } + add(lang) + } + } + } + return _supportedLanguages!! + } + + override val appIdDisplay: String = "AccessKey ID" + + override val appKeyDisplay: String = "AccessKey Secret" + + override val applyAppIdUrl: String? = APPLY_APP_ID_URL + + @Throws(TranslationException::class) + override fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { + checkSupportedLanguages(fromLang, toLang, text) + + config.setAccessKeyId(appId).setAccessKeySecret(appKey).setEndpoint(ENDPOINT) + + if (client == null) { + try { + client = Client(config) + } catch (e: Exception) { + throw TranslationException(fromLang, toLang, text, e) + } + } + + val request = TranslateGeneralRequest() + .setFormatType("text") + .setSourceLanguage(fromLang.translationCode) + .setTargetLanguage(toLang.translationCode) + .setSourceText(text) + .setScene("general") + + val runtime = RuntimeOptions() + val response: TranslateGeneralResponse + + try { + response = client!!.translateGeneralWithOptions(request, runtime) + } catch (e: Exception) { + throw TranslationException(fromLang, toLang, text, e) + } + + val body = response.body + return if (body.code == 200) { + body.data.translated + } else { + throw TranslationException(fromLang, toLang, text, "${body.message}(${body.code})") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.kt new file mode 100644 index 0000000..02351b3 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.baidu + +import com.airsaid.localization.translate.TranslationResult +import com.google.gson.annotations.SerializedName +import com.intellij.openapi.util.text.StringUtil + +/** + * @author airsaid + */ +data class BaiduTranslationResult( + var from: String? = null, + var to: String? = null, + @SerializedName("trans_result") + var contents: List? = null, + @SerializedName("error_code") + var errorCode: String? = null, + @SerializedName("error_msg") + var errorMsg: String? = null +) : TranslationResult { + + fun isSuccess(): Boolean { + val errorCode = this.errorCode + return StringUtil.isEmpty(errorCode) || "52000" == errorCode + } + + override val translationResult: String + get() { + val contents = this.contents + if (contents.isNullOrEmpty()) { + return "" + } + val dst = contents[0].dst + return dst ?: "" + } + + data class Content( + var src: String? = null, + var dst: String? = null + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt new file mode 100644 index 0000000..931ff01 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.baidu + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.util.GsonUtil +import com.airsaid.localization.translate.util.MD5 +import com.google.auto.service.AutoService +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Pair +import com.intellij.util.io.RequestBuilder +import icons.PluginIcons +import javax.swing.Icon + +/** + * @author airsaid + */ +@AutoService(AbstractTranslator::class) +class BaiduTranslator : AbstractTranslator() { + + companion object { + private val LOG = Logger.getInstance(BaiduTranslator::class.java) + private const val KEY = "Baidu" + private const val HOST_URL = "http://api.fanyi.baidu.com" + private const val TRANSLATE_URL = "$HOST_URL/api/trans/vip/translate" + private const val APPLY_APP_ID_URL = "http://api.fanyi.baidu.com/api/trans/product/desktop?req=developer" + } + + private var _supportedLanguages: MutableList? = null + + override val key: String = KEY + + override val name: String = "Baidu" + + override val icon: Icon? = PluginIcons.BAIDU_ICON + + override val supportedLanguages: List + get() { + if (_supportedLanguages == null) { + _supportedLanguages = mutableListOf().apply { + add(Languages.CHINESE_SIMPLIFIED.setTranslationCode("zh")) + add(Languages.ENGLISH) + add(Languages.JAPANESE.setTranslationCode("jp")) + add(Languages.KOREAN.setTranslationCode("kor")) + add(Languages.FRENCH.setTranslationCode("fra")) + add(Languages.SPANISH.setTranslationCode("spa")) + add(Languages.THAI) + add(Languages.ARABIC.setTranslationCode("ara")) + add(Languages.RUSSIAN) + add(Languages.PORTUGUESE) + add(Languages.GERMAN) + add(Languages.ITALIAN) + add(Languages.GREEK) + add(Languages.DUTCH) + add(Languages.POLISH) + add(Languages.BULGARIAN.setTranslationCode("bul")) + add(Languages.ESTONIAN.setTranslationCode("est")) + add(Languages.DANISH.setTranslationCode("dan")) + add(Languages.FINNISH.setTranslationCode("fin")) + add(Languages.CZECH) + add(Languages.ROMANIAN.setTranslationCode("rom")) + add(Languages.SLOVENIAN.setTranslationCode("slo")) + add(Languages.SWEDISH.setTranslationCode("swe")) + add(Languages.HUNGARIAN) + add(Languages.CHINESE_TRADITIONAL.setTranslationCode("cht")) + add(Languages.VIETNAMESE.setTranslationCode("vie")) + } + } + return _supportedLanguages!! + } + + override val applyAppIdUrl: String? = APPLY_APP_ID_URL + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = TRANSLATE_URL + + override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + val salt = System.currentTimeMillis().toString() + val appId = this.appId + val securityKey = this.appKey + val sign = MD5.md5("$appId$text$salt$securityKey") + + return listOf( + Pair.create("from", fromLang.translationCode), + Pair.create("to", toLang.translationCode), + Pair.create("appid", appId), + Pair.create("salt", salt), + Pair.create("sign", sign), + Pair.create("q", text) + ) + } + + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.tuner { connection -> + connection.setRequestProperty("Referer", HOST_URL) + } + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult: $resultText") + val baiduTranslationResult = GsonUtil.getInstance().gson.fromJson(resultText, BaiduTranslationResult::class.java) + return if (baiduTranslationResult.isSuccess()) { + baiduTranslationResult.translationResult + } else { + val message = "${baiduTranslationResult.errorMsg}(${baiduTranslationResult.errorCode})" + throw TranslationException(fromLang, toLang, text, message) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.kt new file mode 100644 index 0000000..8399081 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.deepl + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.lang.Lang +import com.google.auto.service.AutoService + +/** + * @author airsaid + */ +@AutoService(AbstractTranslator::class) +class DeepLProTranslator : DeepLTranslator() { + + companion object { + private const val KEY = "DeepLPro" + private const val HOST_URL = "https://api.deepl.com/v2" + private const val TRANSLATE_URL = "$HOST_URL/translate" + } + + override val key: String = KEY + + override val name: String = KEY + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = TRANSLATE_URL +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.kt new file mode 100644 index 0000000..5143507 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.deepl + +import com.airsaid.localization.translate.TranslationResult + +/** + * @author musagil + */ +data class DeepLTranslationResult( + var translations: List? = null +) : TranslationResult { + + override val translationResult: String + get() { + return if (!translations.isNullOrEmpty()) { + val result = translations!![0].text + result ?: "" + } else { + "" + } + } + + data class Translation( + var text: String? = null, + var to: String? = null + ) + + data class Error( + var code: String? = null, + var message: String? = null + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt new file mode 100644 index 0000000..a9344c9 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.deepl + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.util.GsonUtil +import com.airsaid.localization.translate.util.UrlBuilder +import com.google.auto.service.AutoService +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Pair +import com.intellij.util.io.RequestBuilder +import icons.PluginIcons +import javax.swing.Icon + +/** + * @author musagil + */ +@AutoService(AbstractTranslator::class) +open class DeepLTranslator : AbstractTranslator() { + + companion object { + private val LOG = Logger.getInstance(DeepLTranslator::class.java) + private const val KEY = "DeepL" + private const val HOST_URL = "https://api-free.deepl.com/v2" + private const val TRANSLATE_URL = "$HOST_URL/translate" + private const val APPLY_APP_ID_URL = "https://www.deepl.com/pro-api?cta=header-pro-api/" + } + + private var _supportedLanguages: MutableList? = null + + override val key: String = KEY + + override val name: String = "DeepL" + + override val icon: Icon? = PluginIcons.DEEP_L_ICON + + override val isNeedAppId: Boolean = false + + override val supportedLanguages: List + get() { + if (_supportedLanguages == null) { + _supportedLanguages = mutableListOf().apply { + add(Languages.BULGARIAN) + add(Languages.CZECH) + add(Languages.DANISH) + add(Languages.GERMAN) + add(Languages.GREEK) + add(Lang(118, "en-gb", "English (British)", "English (British)")) + add(Lang(119, "en-us", "English (American)", "English (American)")) + add(Languages.SPANISH) + add(Languages.ESTONIAN) + add(Languages.FINNISH) + add(Languages.FRENCH) + add(Languages.HUNGARIAN) + add(Lang(98, "id", "Indonesia", "Indonesian")) + add(Languages.ITALIAN) + add(Languages.JAPANESE) + add(Languages.KOREAN.setTranslationCode("KO")) + add(Languages.LITHUANIAN) + add(Languages.LATVIAN) + add(Languages.NORWEGIAN.setTranslationCode("NB")) + add(Languages.DUTCH) + add(Languages.POLISH) + add(Lang(120, "pt-br", "Portuguese (Brazilian)", "Portuguese (Brazilian)")) + add(Lang(121, "pt-pt", "Portuguese (European)", "Portuguese (European)")) + add(Languages.ROMANIAN) + add(Languages.RUSSIAN) + add(Languages.SLOVAK) + add(Languages.SLOVENIAN) + add(Languages.SWEDISH) + add(Languages.TURKISH) + add(Languages.UKRAINIAN) + add(Lang(104, "zh", "简体中文", "Chinese Simplified")) + } + } + return _supportedLanguages!! + } + + override val appKeyDisplay: String = "KEY" + + override val applyAppIdUrl: String? = APPLY_APP_ID_URL + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = + UrlBuilder(TRANSLATE_URL).build() + + override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + return listOf( + Pair.create("text", text), + Pair.create("target_lang", toLang.code) + ) + } + + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.tuner { connection -> + connection.setRequestProperty("Authorization", "DeepL-Auth-Key ${appKey}") + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") + } + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult: $resultText") + return GsonUtil.getInstance().gson.fromJson(resultText, DeepLTranslationResult::class.java).translationResult + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt new file mode 100644 index 0000000..9f7441c --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.google + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages +import icons.PluginIcons +import javax.swing.Icon + +/** + * @author airsaid + */ +abstract class AbsGoogleTranslator : AbstractTranslator() { + + protected var _supportedLanguages: MutableList? = null + + override val icon: Icon = PluginIcons.GOOGLE_ICON + + override val supportedLanguages: List + get() { + if (_supportedLanguages == null) { + val languages = Languages.getLanguages() + _supportedLanguages = mutableListOf().apply { + for (i in 1..104) { + var lang = languages[i] + lang = when (lang) { + Languages.CHINESE_SIMPLIFIED -> lang.setTranslationCode("zh-CN") + Languages.CHINESE_TRADITIONAL -> lang.setTranslationCode("zh-TW") + Languages.FILIPINO -> lang.setTranslationCode("tl") + Languages.INDONESIAN -> lang.setTranslationCode("id") + Languages.JAVANESE -> lang.setTranslationCode("jw") + else -> lang + } + add(lang) + } + } + } + return _supportedLanguages!! + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt new file mode 100644 index 0000000..fbb9fec --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.google + +import com.airsaid.localization.translate.util.AgentUtil +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Pair +import com.intellij.util.io.HttpRequests +import java.util.* +import java.util.regex.Pattern +import kotlin.math.abs + +/** + * @author airsaid + */ +object GoogleToken { + + private val LOG = Logger.getInstance(GoogleToken::class.java) + + private const val MIM = 60 * 60 * 1000 + private val GENERATOR = Random() + private val TKK_PATTERN = Pattern.compile("tkk='(\\d+).(-?\\d+)'") + private const val ELEMENT_URL = "%s/translate_a/element.js" + + private var sInnerValue = Pair.create(0L, 0L) + private var sNeedUpdate = true + + @JvmStatic + fun getToken(text: String): String { + return getToken(text, getDefaultTKK()) + } + + @JvmStatic + fun getToken(text: String, tkk: Pair): String { + val length = text.length + val a = mutableListOf() + var b = 0 + val ch = text.toCharArray() + + while (b < length) { + var c = ch[b].code + when { + 128 > c -> a.add(c.toLong()) + 2048 > c -> a.add((c shr 6 or 192).toLong()) + else -> { + if (55296 == (c and 64512) && b + 1 < length && 56320 == (ch[b + 1].code and 64512)) { + c = 65536 + ((c and 1023) shl 10) + (ch[++b].code and 1023) + a.add((c shr 18 or 240).toLong()) + a.add((c shr 12 and 63 or 128).toLong()) + } else { + a.add((c shr 12 or 224).toLong()) + } + a.add((c shr 6 and 63 or 128).toLong()) + } + } + if (2048 > ch[b].code) { + // Only add the last part if c was not modified in the 55296 branch above + } else { + a.add((c and 63 or 128).toLong()) + } + b++ + } + + val d = tkk.first + val e = tkk.second + var f = d + for (h in a) { + f += h + f = transform(f, "+-a^+6") + } + + f = transform(f, "+-3^+b+-f") + f = f xor e + if (0 > f) { + f = (f and Int.MAX_VALUE.toLong()) + Int.MAX_VALUE + 1 + } + f = (f % 1E6).toLong() + + return "$f.${f xor d}" + } + + private fun transform(a: Long, b: String): Long { + var g = a + val ch = b.toCharArray() + var c = 0 + while (c < ch.size - 1) { + val d = ch[c + 2] + val e = if ('a' <= d) (d.code - 87) else (d.code - '0'.code) + val f = if ('+' == ch[c + 1]) g ushr e else g shl e + g = if ('+' == ch[c]) g + f and (Int.MAX_VALUE.toLong() * 2 + 1) else g xor f + c += 3 + } + return g + } + + private fun getDefaultTKK(): Pair { + val now = System.currentTimeMillis() / MIM + val curVal = sInnerValue.first + if (!sNeedUpdate && now == curVal) { + return sInnerValue + } + + val newTKK = getTKKFromGoogle() + sNeedUpdate = newTKK == null + sInnerValue = newTKK ?: Pair.create(now, abs(GENERATOR.nextInt().toLong()) + GENERATOR.nextInt().toLong()) + + return sInnerValue + } + + private fun getTKKFromGoogle(): Pair? { + return try { + val url = String.format(ELEMENT_URL, GoogleTranslator.HOST_URL) + LOG.info("getTKKFromGoogle url: $url") + val elementJs = HttpRequests.request(url) + .userAgent(AgentUtil.getUserAgent()) + .tuner { connection -> connection.setRequestProperty("Referer", GoogleTranslator.HOST_URL) } + .readString() + val matcher = TKK_PATTERN.matcher(elementJs) + if (matcher.find()) { + val value1 = matcher.group(1)!!.toLong() + val value2 = matcher.group(2)!!.toLong() + LOG.info(String.format("TKK: %d.%d", value1, value2)) + Pair.create(value1, value1) + } else { + null + } + } catch (e: Exception) { + e.printStackTrace() + LOG.warn("TKK get failed.", e) + null + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.kt new file mode 100644 index 0000000..6a1f9f9 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.google + +import com.airsaid.localization.translate.TranslationResult +import com.google.gson.annotations.SerializedName + +/** + * @author airsaid + */ +data class GoogleTranslationResult( + @SerializedName("src") + var sourceCode: String? = null, + var sentences: List? = null +) : TranslationResult { + + override val translationResult: String + get() { + val sentences = this.sentences + if (sentences.isNullOrEmpty()) { + return "" + } + val result = StringBuilder() + for (sentence in sentences) { + val trans = sentence.trans + if (trans != null) result.append(trans) + } + return result.toString() + } + + data class Sentences( + var trans: String? = null, + @SerializedName("orig") + var origin: String? = null, + var backend: Int = 0 + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt new file mode 100644 index 0000000..eeea29c --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.google + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.util.AgentUtil +import com.airsaid.localization.translate.util.GsonUtil +import com.airsaid.localization.translate.util.UrlBuilder +import com.google.auto.service.AutoService +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Pair +import com.intellij.util.io.RequestBuilder + +/** + * @author airsaid + */ +@AutoService(AbstractTranslator::class) +class GoogleTranslator : AbsGoogleTranslator() { + + companion object { + private val LOG = Logger.getInstance(GoogleTranslator::class.java) + const val KEY = "Google" + const val HOST_URL = "https://translate.googleapis.com" + private const val BASE_URL = "$HOST_URL/translate_a/single" + } + + override val key: String = KEY + + override val name: String = "Google" + + override val isNeedAppId: Boolean = false + + override val isNeedAppKey: Boolean = false + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + return UrlBuilder(BASE_URL) + .addQueryParameter("sl", fromLang.translationCode) // source language code (auto for auto detection) + .addQueryParameter("tl", toLang.translationCode) // translation language + .addQueryParameter("client", "gtx") // client of request (guess) + .addQueryParameters("dt", "t") // specify what to return + .addQueryParameter("dj", "1") // json response with names + .addQueryParameter("ie", "UTF-8") // input encoding + .addQueryParameter("oe", "UTF-8") // output encoding + .addQueryParameter("tk", GoogleToken.getToken(text)) // translate token + .build() + } + + override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + return listOf(Pair.create("q", text)) + } + + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.userAgent(AgentUtil.getUserAgent()) + .tuner { connection -> connection.setRequestProperty("Referer", GoogleTranslator.HOST_URL) } + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult: $resultText") + val googleTranslationResult = GsonUtil.getInstance().gson.fromJson(resultText, GoogleTranslationResult::class.java) + return googleTranslationResult.translationResult + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.kt new file mode 100644 index 0000000..40e1ef1 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.kt @@ -0,0 +1,49 @@ +package com.airsaid.localization.translate.impl.googleapi + +import com.airsaid.localization.translate.TranslationResult + +/** + * @author airsaid + */ +data class GoogleApiTranslationResult( + var data: Data? = null, + var error: Error? = null +) : TranslationResult { + + fun isSuccess(): Boolean = data != null && error == null + + override val translationResult: String + get() = data?.translations?.get(0)?.translatedText ?: "" + + data class Data( + var translations: Array? = null + ) { + data class Translation( + var translatedText: String? = null, + var detectedSourceLanguage: String? = null + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Data + + if (translations != null) { + if (other.translations == null) return false + if (!translations.contentEquals(other.translations)) return false + } else if (other.translations != null) return false + + return true + } + + override fun hashCode(): Int { + return translations?.contentHashCode() ?: 0 + } + } + + data class Error( + var code: Int = 0, + var message: String? = null + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt new file mode 100644 index 0000000..47acad8 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt @@ -0,0 +1,66 @@ +package com.airsaid.localization.translate.impl.googleapi + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.impl.google.AbsGoogleTranslator +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.util.GsonUtil +import com.google.auto.service.AutoService +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Pair +import com.intellij.util.io.RequestBuilder + +/** + * @author airsaid + */ +@AutoService(AbstractTranslator::class) +class GoogleApiTranslator : AbsGoogleTranslator() { + + companion object { + private val LOG = Logger.getInstance(GoogleApiTranslator::class.java) + private const val KEY = "GoogleApi" + private const val HOST_URL = "https://translation.googleapis.com" + private const val TRANSLATE_URL = "$HOST_URL/language/translate/v2" + private const val APPLY_APP_ID_URL = "https://cloud.google.com/translate" + } + + override val key: String = KEY + + override val name: String = "Google (API)" + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = TRANSLATE_URL + + override val appKeyDisplay: String = "API Key" + + override val applyAppIdUrl: String? = APPLY_APP_ID_URL + + override val isNeedAppId: Boolean = false + + override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + return listOf( + Pair.create("q", text), + Pair.create("target", toLang.translationCode), + Pair.create("key", appKey), + Pair.create("format", "text") + ) + } + + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.tuner { connection -> connection.setRequestProperty("Referer", HOST_URL) } + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult: $resultText") + val result = GsonUtil.getInstance().gson.fromJson(resultText, GoogleApiTranslationResult::class.java) + return if (result.isSuccess()) { + result.translationResult + } else { + val message = if (result.error != null) { + "${result.error!!.message}(${result.error!!.code})" + } else { + "Unknown error" + } + throw TranslationException(fromLang, toLang, text, message) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.kt new file mode 100644 index 0000000..88697ea --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.microsoft + +import com.airsaid.localization.translate.TranslationResult + +/** + * @author airsaid + */ +data class MicrosoftTranslationResult( + var translations: List? = null +) : TranslationResult { + + override val translationResult: String + get() { + return if (!translations.isNullOrEmpty()) { + val result = translations!![0].text + result ?: "" + } else { + "" + } + } + + data class Translation( + var text: String? = null, + var to: String? = null + ) + + data class Error( + var code: String? = null, + var message: String? = null + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt new file mode 100644 index 0000000..89eb80a --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.microsoft + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.util.GsonUtil +import com.airsaid.localization.translate.util.UrlBuilder +import com.google.auto.service.AutoService +import com.intellij.openapi.diagnostic.Logger +import com.intellij.util.io.RequestBuilder +import icons.PluginIcons +import javax.swing.Icon + +/** + * @author airsaid + */ +@AutoService(AbstractTranslator::class) +class MicrosoftTranslator : AbstractTranslator() { + + companion object { + private val LOG = Logger.getInstance(MicrosoftTranslator::class.java) + private const val KEY = "Microsoft" + private const val HOST_URL = "https://api.cognitive.microsofttranslator.com" + private const val TRANSLATE_URL = "$HOST_URL/translate" + private const val APPLY_APP_ID_URL = "https://docs.microsoft.com/azure/cognitive-services/translator/translator-how-to-signup" + } + + private var _supportedLanguages: MutableList? = null + + override val key: String = KEY + + override val name: String = "Microsoft" + + override val icon: Icon? = PluginIcons.MICROSOFT_ICON + + override val isNeedAppId: Boolean = false + + override val supportedLanguages: List + get() { + if (_supportedLanguages == null) { + _supportedLanguages = mutableListOf().apply { + add(Languages.AFRIKAANS) + add(Languages.ALBANIAN) + add(Languages.AMHARIC) + add(Languages.ARABIC) + add(Languages.ARMENIAN) + add(Languages.ASSAMESE) + add(Languages.AZERBAIJANI) + add(Languages.BANGLA) + add(Languages.BOSNIAN) + add(Languages.BULGARIAN) + add(Languages.CATALAN) + add(Languages.CHINESE_SIMPLIFIED.setTranslationCode("zh-Hans")) + add(Languages.CHINESE_TRADITIONAL.setTranslationCode("zh-Hant")) + add(Languages.CROATIAN) + add(Languages.CZECH) + add(Languages.DANISH) + add(Languages.DARI) + add(Languages.DUTCH) + add(Languages.ENGLISH) + add(Languages.ESTONIAN) + add(Languages.FIJIAN) + add(Languages.FILIPINO.setTranslationCode("fil")) + add(Languages.FINNISH) + add(Languages.FRENCH) + add(Languages.GERMAN) + add(Languages.GREEK) + add(Languages.GUJARATI) + add(Languages.HAITIAN_CREOLE) + add(Languages.HEBREW.setTranslationCode("he")) + add(Languages.HINDI) + add(Languages.HMONG_DAW) + add(Languages.HUNGARIAN) + add(Languages.ICELANDIC) + add(Languages.INDONESIAN.setTranslationCode("id")) + add(Languages.INUKTITUT) + add(Languages.IRISH) + add(Languages.ITALIAN) + add(Languages.JAPANESE) + add(Languages.KANNADA) + add(Languages.KAZAKH) + add(Languages.KHMER) + add(Languages.KLINGON_LATIN) + add(Languages.KLINGON_PIQAD) + add(Languages.KOREAN) + add(Languages.KURDISH) + add(Languages.LAO) + add(Languages.LATVIAN) + add(Languages.LITHUANIAN) + add(Languages.MALAGASY) + add(Languages.MALAY) + add(Languages.MALAYALAM) + add(Languages.MALTESE) + add(Languages.MAORI) + add(Languages.MARATHI) + add(Languages.BURMESE) + add(Languages.NEPALI) + add(Languages.NORWEGIAN.setTranslationCode("nb")) + add(Languages.ODIA) + add(Languages.PASHTO) + add(Languages.PERSIAN) + add(Languages.PORTUGUESE) + add(Languages.PUNJABI) + add(Languages.QUERETARO_OTOMI) + add(Languages.ROMANIAN) + add(Languages.RUSSIAN) + add(Languages.SAMOAN) + add(Languages.SERBIAN) + add(Languages.SLOVAK) + add(Languages.SLOVENIAN) + add(Languages.SPANISH) + add(Languages.SWAHILI) + add(Languages.SWEDISH) + add(Languages.TAHITIAN) + add(Languages.TAMIL) + add(Languages.TELUGU) + add(Languages.THAI) + add(Languages.TIGRINYA) + add(Languages.TONGAN) + add(Languages.TURKISH) + add(Languages.UKRAINIAN) + add(Languages.URDU) + add(Languages.VIETNAMESE) + add(Languages.WELSH) + add(Languages.YUCATEC_MAYA) + } + } + return _supportedLanguages!! + } + + override val appKeyDisplay: String = "KEY" + + override val applyAppIdUrl: String? = APPLY_APP_ID_URL + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + return UrlBuilder(TRANSLATE_URL) + .addQueryParameter("api-version", "3.0") + .addQueryParameter("to", toLang.translationCode) + .build() + } + + override fun getRequestBody(fromLang: Lang, toLang: Lang, text: String): String { + return "[{\"Text\": \"$text\"}]" + } + + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.tuner { connection -> + connection.setRequestProperty("Ocp-Apim-Subscription-Key", appKey) + connection.setRequestProperty("Content-type", "application/json") + } + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult: $resultText") + return GsonUtil.getInstance().gson.fromJson(resultText, Array::class.java)[0].translationResult + } +} \ No newline at end of file diff --git a/src/main/java/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.java b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.kt similarity index 53% rename from src/main/java/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.java rename to src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.kt index b1df8ae..7660172 100644 --- a/src/main/java/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.java +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.kt @@ -15,30 +15,9 @@ * */ -package com.airsaid.localization.translate.impl.openai; +package com.airsaid.localization.translate.impl.openai -public class ChatGPTMessage { - private String role; - private String content; - - public ChatGPTMessage(String role, String content) { - this.role = role; - this.content = content; - } - - public String getRole() { - return role; - } - - public void setRole(String role) { - this.role = role; - } - - public String getContent() { - return content; - } - - public void setContent(String content) { - this.content = content; - } -} +data class ChatGPTMessage( + var role: String, + var content: String +) \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt new file mode 100644 index 0000000..c8b6821 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt @@ -0,0 +1,88 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.openai + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.util.GsonUtil +import com.google.auto.service.AutoService +import com.intellij.openapi.diagnostic.Logger +import com.intellij.util.io.RequestBuilder +import icons.PluginIcons +import javax.swing.Icon + +@AutoService(AbstractTranslator::class) +class ChatGPTTranslator : AbstractTranslator() { + companion object { + private val LOG = Logger.getInstance(ChatGPTTranslator::class.java) + private const val KEY = "ChatGPT" + } + + override val key: String + get() = KEY + + override val name: String + get() = "OpenAI ChatGPT" + + override val icon: Icon? + get() = PluginIcons.OPENAI_ICON + + override val isNeedAppId: Boolean + get() = false + + override val isNeedAppKey: Boolean + get() = true + + override val supportedLanguages: List + get() = Languages.getLanguages() + + override val appKeyDisplay: String + get() = "KEY" + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + return "https://api.openai.com/v1/chat/completions" + } + + override fun getRequestBody(fromLang: Lang, toLang: Lang, text: String): String { + val lang = toLang.englishName + val roleSystem = String.format( + "Translate the user provided text into high quality, well written %s. Apply these 4 translation rules; 1.Keep the exact original formatting and style, 2.Keep translations concise and just repeat the original text for unchanged translations (e.g. 'OK'), 3.Audience: native %s speakers, 4.Text can be used in Android app UI (limited space, concise translations!).", + lang, lang + ) + + val role = ChatGPTMessage("system", roleSystem) + val msg = ChatGPTMessage("user", String.format("Text to translate: %s", text)) + + val body = OpenAIRequest("gpt-3.5-turbo", listOf(role, msg)) + + return GsonUtil.getInstance().gson.toJson(body) + } + + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.tuner { connection -> + connection.setRequestProperty("Authorization", "Bearer ${appKey}") + connection.setRequestProperty("Content-Type", "application/json") + } + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult ChatGPT: $resultText") + return GsonUtil.getInstance().gson.fromJson(resultText, OpenAIResponse::class.java).translation + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIRequest.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIRequest.kt new file mode 100644 index 0000000..006b4a8 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIRequest.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.openai + +data class OpenAIRequest( + var model: String, + var messages: List +) \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt new file mode 100644 index 0000000..2b0db7b --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.openai + +data class OpenAIResponse( + var choices: List?, + var created: Int?, + var id: String?, + var `object`: String?, + var usage: Usage? +) { + val translation: String + get() { + return if (!choices.isNullOrEmpty()) { + val result = choices!![0].message?.content + result?.trim() ?: "" + } else { + "" + } + } + + data class Choice( + var finish_reason: String?, + var index: Int?, + var message: Message? + ) + + data class Message( + var content: String?, + var role: String? + ) + + data class Usage( + var completion_tokens: Int?, + var prompt_tokens: Int?, + var total_tokens: Int? + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslationResult.kt new file mode 100644 index 0000000..2ab543e --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslationResult.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.youdao + +import com.airsaid.localization.translate.TranslationResult +import com.intellij.openapi.util.text.StringUtil + +/** + * @author airsaid + */ +data class YoudaoTranslationResult( + var requestId: String? = null, + var errorCode: String? = null, + var translation: List? = null +) : TranslationResult { + + val isSuccess: Boolean + get() { + val errorCode = this.errorCode + return !StringUtil.isEmpty(errorCode) && "0" == errorCode + } + + override val translationResult: String + get() { + val translation = this.translation + return if (translation != null && translation.isNotEmpty()) { + translation[0] ?: "" + } else { + "" + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val that = other as YoudaoTranslationResult + return requestId == that.requestId + } + + override fun hashCode(): Int { + return requestId?.hashCode() ?: 0 + } + + override fun toString(): String { + return "YoudaoTranslationResult{" + + "requestId='$requestId', " + + "errorCode='$errorCode', " + + "translation=$translation" + + '}' + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt new file mode 100644 index 0000000..3d70226 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt @@ -0,0 +1,228 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.youdao + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.util.GsonUtil +import com.google.auto.service.AutoService +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Pair +import com.intellij.util.io.RequestBuilder +import icons.PluginIcons +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import javax.swing.Icon + +/** + * @author airsaid + */ +@Suppress("SpellCheckingInspection", "unused") +@AutoService(AbstractTranslator::class) +class YoudaoTranslator : AbstractTranslator() { + companion object { + private val LOG = Logger.getInstance(YoudaoTranslator::class.java) + private const val KEY = "Youdao" + private const val HOST_URL = "https://openapi.youdao.com" + private const val TRANSLATE_URL = "$HOST_URL/api" + private const val APPLY_APP_ID_URL = "https://ai.youdao.com/DOCSIRMA/html/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E7%BF%BB%E8%AF%91/API%E6%96%87%E6%A1%A3/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1-API%E6%96%87%E6%A1%A3.html" + } + + private var _supportedLanguages: List? = null + + override val key: String + get() = KEY + + override val name: String + get() = "Youdao" + + override val icon: Icon? + get() = PluginIcons.YOUDAO_ICON + + override val supportedLanguages: List + get() { + if (_supportedLanguages == null) { + _supportedLanguages = mutableListOf().apply { + add(Languages.CHINESE_SIMPLIFIED.setTranslationCode("zh-CHS")) + add(Languages.ENGLISH) + add(Languages.JAPANESE) + add(Languages.KOREAN) + add(Languages.FRENCH) + add(Languages.SPANISH) + add(Languages.ITALIAN) + add(Languages.RUSSIAN) + add(Languages.VIETNAMESE) + add(Languages.GERMAN) + add(Languages.ARABIC) + add(Languages.INDONESIAN.setTranslationCode("id")) + add(Languages.AFRIKAANS) + add(Languages.BOSNIAN) + add(Languages.BULGARIAN) + add(Languages.CATALAN) + add(Languages.CROATIAN) + add(Languages.CZECH) + add(Languages.DANISH) + add(Languages.DUTCH) + add(Languages.ESTONIAN) + add(Languages.FINNISH) + add(Languages.HAITIAN_CREOLE) + add(Languages.HINDI) + add(Languages.HUNGARIAN) + add(Languages.SWAHILI) + add(Languages.LITHUANIAN) + add(Languages.MALAY) + add(Languages.MALTESE) + add(Languages.NORWEGIAN) + add(Languages.POLISH) + add(Languages.ROMANIAN) + add(Languages.SERBIAN.setTranslationCode("sr-Cyrl")) + add(Languages.SLOVAK) + add(Languages.SLOVENIAN) + add(Languages.SWEDISH) + add(Languages.THAI) + add(Languages.TURKISH) + add(Languages.UKRAINIAN) + add(Languages.URDU) + add(Languages.AMHARIC) + add(Languages.AZERBAIJANI) + add(Languages.BANGLA) + add(Languages.BASQUE) + add(Languages.BELARUSIAN) + add(Languages.CEBUANO) + add(Languages.CORSICAN) + add(Languages.ESPERANTO) + add(Languages.FILIPINO.setTranslationCode("tl")) + add(Languages.FRISIAN) + add(Languages.GUJARATI) + add(Languages.HAUSA) + add(Languages.HAWAIIAN) + add(Languages.ICELANDIC) + add(Languages.JAVANESE.setTranslationCode("jw")) + add(Languages.KANNADA) + add(Languages.KAZAKH) + add(Languages.KHMER) + add(Languages.KURDISH) + add(Languages.KYRGYZ) + add(Languages.LAO) + add(Languages.LATIN) + add(Languages.LUXEMBOURGISH) + add(Languages.MACEDONIAN) + add(Languages.MALAGASY) + add(Languages.MALAYALAM) + add(Languages.MARATHI) + add(Languages.MONGOLIAN) + add(Languages.BURMESE) + add(Languages.NEPALI) + add(Languages.CHICHEWA) + add(Languages.PASHTO) + add(Languages.PUNJABI) + add(Languages.SAMOAN) + add(Languages.SCOTTISH_GAELIC) + add(Languages.SOTHO) + add(Languages.SHONA) + add(Languages.SINDHI) + add(Languages.SLOVENIAN) + add(Languages.SOMALI) + add(Languages.SUNDANESE) + add(Languages.TAJIK) + add(Languages.TAMIL) + add(Languages.TELUGU) + add(Languages.UZBEK) + add(Languages.XHOSA) + add(Languages.YORUBA) + add(Languages.ZULU) + } + } + return _supportedLanguages!! + } + + override val appIdDisplay: String + get() = "应用 ID" + + override val appKeyDisplay: String + get() = "应用秘钥" + + override val applyAppIdUrl: String? + get() = APPLY_APP_ID_URL + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + return TRANSLATE_URL + } + + private fun truncate(q: String): String { + val len = q.length + return if (len <= 20) q else (q.substring(0, 10) + len + q.substring(len - 10, len)) + } + + private fun getDigest(string: String): String? { + val hexDigits = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F') + val btInput = string.toByteArray(StandardCharsets.UTF_8) + return try { + val mdInst = MessageDigest.getInstance("SHA-256") + mdInst.update(btInput) + val md = mdInst.digest() + val j = md.size + val str = CharArray(j * 2) + var k = 0 + for (byte0 in md) { + str[k++] = hexDigits[byte0.toInt() ushr 4 and 0xf] + str[k++] = hexDigits[byte0.toInt() and 0xf] + } + String(str) + } catch (e: NoSuchAlgorithmException) { + null + } + } + + override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + val salt = System.currentTimeMillis().toString() + val curTime = (System.currentTimeMillis() / 1000).toString() + val appId = this.appId + val appKey = this.appKey + val sign = getDigest(appId + truncate(text) + salt + curTime + appKey) + val params = mutableListOf>() + params.add(Pair.create("from", fromLang.translationCode)) + params.add(Pair.create("to", toLang.translationCode)) + params.add(Pair.create("signType", "v3")) + params.add(Pair.create("curtime", curTime)) + params.add(Pair.create("appKey", appId)) + params.add(Pair.create("salt", salt)) + params.add(Pair.create("sign", sign)) + params.add(Pair.create("q", text)) + return params + } + + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.tuner { connection -> + connection.setRequestProperty("Referer", HOST_URL) + } + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult: $resultText") + val translationResult = GsonUtil.getInstance().gson.fromJson(resultText, YoudaoTranslationResult::class.java) + return if (translationResult.isSuccess) { + translationResult.translationResult + } else { + throw TranslationException(fromLang, toLang, text, translationResult.errorCode ?: "Unknown error") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.kt b/src/main/kotlin/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.kt new file mode 100644 index 0000000..b553406 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.interceptors + +import com.airsaid.localization.translate.services.TranslatorService +import com.intellij.openapi.util.text.StringUtil + +/** + * @author airsaid + */ +class EscapeCharactersInterceptor : TranslatorService.TranslationInterceptor { + + private val needEscapeChars = mutableListOf() + + init { + needEscapeChars.addAll(listOf('@', '?', '\'', '\"')) + } + + override fun process(text: String?): String? { + if (StringUtil.isEmpty(text)) { + return text + } + val result = StringBuilder() + text!!.forEach { ch -> + if (needEscapeChars.contains(ch)) { + result.append('\\') + } + result.append(ch) + } + return result.toString() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt b/src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt new file mode 100644 index 0000000..17d12d9 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.lang + +import com.intellij.openapi.util.text.StringUtil + +/** + * Language data class, which is an immutable class, + * any modification to it will generate you a new object. + * + * @author airsaid + */ +data class Lang( + val id: Int, + val code: String, + val name: String, + val englishName: String, + private val _translationCode: String? = null +) : Cloneable { + + val translationCode: String + get() = if (!StringUtil.isEmpty(_translationCode)) _translationCode!! else code + + fun setTranslationCode(translationCode: String): Lang { + return this.copy(_translationCode = translationCode) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val language = other as Lang + return id == language.id + } + + override fun hashCode(): Int { + return id.hashCode() + } + + public override fun clone(): Lang { + return try { + super.clone() as Lang + } catch (e: CloneNotSupportedException) { + e.printStackTrace() + this.copy() + } + } + + override fun toString(): String { + return "Lang{" + + "id=$id, " + + "code='$code', " + + "name='$name', " + + "englishName='$englishName', " + + "translationCode='$_translationCode'" + + '}' + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt b/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt new file mode 100644 index 0000000..9c2cd13 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.lang + +/** + * @author airsaid + */ +// Some language codes and names cannot pass the compiler check +@Suppress("SpellCheckingInspection", "unused") +object Languages { + val AUTO = Lang(0, "auto", "Auto", "Auto") + val ALBANIAN = Lang(1, "sq", "Shqiptar", "Albanian") + val ARABIC = Lang(2, "ar", "العربية", "Arabic") + val AMHARIC = Lang(3, "am", "አማርኛ", "Amharic") + val AZERBAIJANI = Lang(4, "az", "азәрбајҹан", "Azerbaijani") + val IRISH = Lang(5, "ga", "Gaeilge", "Irish") + val ESTONIAN = Lang(6, "et", "Eesti", "Estonian") + val BASQUE = Lang(7, "eu", "Euskal", "Basque") + val BELARUSIAN = Lang(8, "be", "беларускі", "Belarusian") + val BULGARIAN = Lang(9, "bg", "Български", "Bulgarian") + val ICELANDIC = Lang(10, "is", "Íslenska", "Icelandic") + val POLISH = Lang(11, "pl", "Polski", "Polish") + val BOSNIAN = Lang(12, "bs", "Bosanski", "Bosnian") + val PERSIAN = Lang(13, "fa", "Persian", "Persian") + val AFRIKAANS = Lang(14, "af", "Afrikaans", "Afrikaans") + val DANISH = Lang(15, "da", "Dansk", "Danish") + val GERMAN = Lang(16, "de", "Deutsch", "German") + val RUSSIAN = Lang(17, "ru", "Русский", "Russian") + val FRENCH = Lang(18, "fr", "Français", "French") + val FILIPINO = Lang(19, "fil", "Filipino", "Filipino") + val FINNISH = Lang(20, "fi", "Suomi", "Finnish") + val FRISIAN = Lang(21, "fy", "Frysk", "Frisian") + val KHMER = Lang(22, "km", "ខ្មែរ", "Khmer") + val GEORGIAN = Lang(23, "ka", "ქართული", "Georgian") + val GUJARATI = Lang(24, "gu", "ગુજરાતી", "Gujarati") + val KAZAKH = Lang(25, "kk", "Kazakh", "Kazakh") + val HAITIAN_CREOLE = Lang(26, "ht", "Haitian Creole", "Haitian Creole") + val KOREAN = Lang(27, "ko", "한국어", "Korean") + val HAUSA = Lang(28, "ha", "Hausa", "Hausa") + val DUTCH = Lang(29, "nl", "Nederlands", "Dutch") + val KYRGYZ = Lang(30, "ky", "Кыргыз тили", "Kyrgyz") + val GALICIAN = Lang(31, "gl", "Galego", "Galician") + val CATALAN = Lang(32, "ca", "Català", "Catalan") + val CZECH = Lang(33, "cs", "Čeština", "Czech") + val KANNADA = Lang(34, "kn", "ಕನ್ನಡ", "Kannada") + val CORSICAN = Lang(35, "co", "Corsa", "Corsican") + val CROATIAN = Lang(36, "hr", "Hrvatski", "Croatian") + val KURDISH = Lang(37, "ku", "Kurdî", "Kurdish") + val LATIN = Lang(38, "la", "Latina", "Latin") + val LATVIAN = Lang(39, "lv", "Latviešu", "Latvian") + val LAO = Lang(40, "lo", "ລາວ", "Lao") + val LITHUANIAN = Lang(41, "lt", "Lietuvių", "Lithuanian") + val LUXEMBOURGISH = Lang(42, "lb", "Lëtzebuergesch", "Luxembourgish") + val ROMANIAN = Lang(43, "ro", "Română", "Romanian") + val MALAGASY = Lang(44, "mg", "Malagasy", "Malagasy") + val MALTESE = Lang(45, "mt", "Il-Malti", "Maltese") + val MARATHI = Lang(46, "mr", "मराठी", "Marathi") + val MALAYALAM = Lang(47, "ml", "മലയാളം", "Malayalam") + val MALAY = Lang(48, "ms", "Melayu", "Malay") + val MACEDONIAN = Lang(49, "mk", "Македонски", "Macedonian") + val MAORI = Lang(50, "mi", "Māori", "Maori") + val MONGOLIAN = Lang(51, "mn", "Монгол хэл", "Mongolian") + val BANGLA = Lang(52, "bn", "বাংল", "Bangla") + val BURMESE = Lang(53, "my", "မြန်မာ", "Burmese") + val HMONG = Lang(54, "hmn", "Hmoob", "Hmong") + val XHOSA = Lang(55, "xh", "IsiXhosa", "Xhosa") + val ZULU = Lang(56, "zu", "Zulu", "Zulu") + val NEPALI = Lang(57, "ne", "नेपाली", "Nepali") + val NORWEGIAN = Lang(58, "no", "Norsk", "Norwegian") + val PUNJABI = Lang(59, "pa", "ਪੰਜਾਬੀ", "Punjabi") + val PORTUGUESE = Lang(60, "pt", "Português", "Portuguese") + val PASHTO = Lang(61, "ps", "Pashto", "Pashto") + val CHICHEWA = Lang(62, "ny", "Chichewa", "Chichewa") + val JAPANESE = Lang(63, "ja", "日本語", "Japanese") + val SWEDISH = Lang(64, "sv", "Svenska", "Swedish") + val SAMOAN = Lang(65, "sm", "Samoa", "Samoan") + val SERBIAN = Lang(66, "sr", "Српски", "Serbian") + val SOTHO = Lang(67, "st", "Sesotho", "Sotho") + val SINHALA = Lang(68, "si", "සිංහල", "Sinhala") + val ESPERANTO = Lang(69, "eo", "Esperanta", "Esperanto") + val SLOVAK = Lang(70, "sk", "Slovenčina", "Slovak") + val SLOVENIAN = Lang(71, "sl", "Slovenščina", "Slovenian") + val SWAHILI = Lang(72, "sw", "Kiswahili", "Swahili") + val SCOTTISH_GAELIC = Lang(73, "gd", "Gàidhlig na h-Alba", "Scottish Gaelic") + val CEBUANO = Lang(74, "ceb", "Cebuano", "Cebuano") + val SOMALI = Lang(75, "so", "Somali", "Somali") + val TAJIK = Lang(76, "tg", "Тоҷикӣ", "Tajik") + val TELUGU = Lang(77, "te", "తెలుగు", "Telugu") + val TAMIL = Lang(78, "ta", "தமிழ்", "Tamil") + val THAI = Lang(79, "th", "ไทย", "Thai") + val TURKISH = Lang(80, "tr", "Türkçe", "Turkish") + val WELSH = Lang(81, "cy", "Cymraeg", "Welsh") + val URDU = Lang(82, "ur", "اردو", "Urdu") + val UKRAINIAN = Lang(83, "uk", "Українська", "Ukrainian") + val UZBEK = Lang(84, "uz", "O'zbek", "Uzbek") + val SPANISH = Lang(85, "es", "Español", "Spanish") + val HEBREW = Lang(86, "iw", "עברית", "Hebrew") + val GREEK = Lang(87, "el", "Ελληνικά", "Greek") + val HAWAIIAN = Lang(88, "haw", "Hawaiian", "Hawaiian") + val SINDHI = Lang(89, "sd", "سنڌي", "Sindhi") + val HUNGARIAN = Lang(90, "hu", "Magyar", "Hungarian") + val SHONA = Lang(91, "sn", "Shona", "Shona") + val ARMENIAN = Lang(92, "hy", "Հայերեն", "Armenian") + val IGBO = Lang(93, "ig", "Igbo", "Igbo") + val ITALIAN = Lang(94, "it", "Italiano", "Italian") + val YIDDISH = Lang(95, "yi", "ייִדיש", "Yiddish") + val HINDI = Lang(96, "hi", "हिंदी", "Hindi") + val SUNDANESE = Lang(97, "su", "Sunda", "Sundanese") + val INDONESIAN = Lang(98, "in-rID", "Indonesia", "Indonesian") + val JAVANESE = Lang(99, "jv", "Wong Jawa", "Javanese") + val ENGLISH = Lang(100, "en", "English", "English") + val YORUBA = Lang(101, "yo", "Yorùbá", "Yoruba") + val VIETNAMESE = Lang(102, "vi", "Tiếng Việt", "Vietnamese") + val CHINESE_TRADITIONAL = Lang(103, "zh-rTW", "正體中文", "Chinese Traditional") + val CHINESE_SIMPLIFIED = Lang(104, "zh-rCN", "简体中文", "Chinese Simplified") + val ASSAMESE = Lang(105, "as", "Assamese", "Assamese") + val DARI = Lang(106, "prs", "Dari", "Dari") + val FIJIAN = Lang(107, "fj", "Fijian", "Fijian") + val HMONG_DAW = Lang(108, "mww", "Hmong Daw", "Hmong Daw") + val INUKTITUT = Lang(109, "iu", "ᐃᓄᒃᑎᑐᑦ", "Inuktitut") + val KLINGON_LATIN = Lang(110, "tlh-Latn", "Klingon (Latin)", "Klingon (Latin)") + val KLINGON_PIQAD = Lang(111, "tlh-Piqd", "Klingon (pIqaD)", "Klingon (pIqaD)") + val ODIA = Lang(112, "or", "Odia", "Odia") + val QUERETARO_OTOMI = Lang(113, "otq", "Querétaro Otomi", "Querétaro Otomi") + val TAHITIAN = Lang(114, "ty", "Tahitian", "Tahitian") + val TIGRINYA = Lang(115, "ti", "ትግርኛ", "Tigrinya") + val TONGAN = Lang(116, "to", "lea fakatonga", "Tongan") + val YUCATEC_MAYA = Lang(117, "yua", "Yucatec Maya", "Yucatec Maya") + + private val languages: Map = mapOf( + 0 to AUTO, 1 to ALBANIAN, 2 to ARABIC, 3 to AMHARIC, 4 to AZERBAIJANI, + 5 to IRISH, 6 to ESTONIAN, 7 to BASQUE, 8 to BELARUSIAN, 9 to BULGARIAN, + 10 to ICELANDIC, 11 to POLISH, 12 to BOSNIAN, 13 to PERSIAN, 14 to AFRIKAANS, + 15 to DANISH, 16 to GERMAN, 17 to RUSSIAN, 18 to FRENCH, 19 to FILIPINO, + 20 to FINNISH, 21 to FRISIAN, 22 to KHMER, 23 to GEORGIAN, 24 to GUJARATI, + 25 to KAZAKH, 26 to HAITIAN_CREOLE, 27 to KOREAN, 28 to HAUSA, 29 to DUTCH, + 30 to KYRGYZ, 31 to GALICIAN, 32 to CATALAN, 33 to CZECH, 34 to KANNADA, + 35 to CORSICAN, 36 to CROATIAN, 37 to KURDISH, 38 to LATIN, 39 to LATVIAN, + 40 to LAO, 41 to LITHUANIAN, 42 to LUXEMBOURGISH, 43 to ROMANIAN, 44 to MALAGASY, + 45 to MALTESE, 46 to MARATHI, 47 to MALAYALAM, 48 to MALAY, 49 to MACEDONIAN, + 50 to MAORI, 51 to MONGOLIAN, 52 to BANGLA, 53 to BURMESE, 54 to HMONG, + 55 to XHOSA, 56 to ZULU, 57 to NEPALI, 58 to NORWEGIAN, 59 to PUNJABI, + 60 to PORTUGUESE, 61 to PASHTO, 62 to CHICHEWA, 63 to JAPANESE, 64 to SWEDISH, + 65 to SAMOAN, 66 to SERBIAN, 67 to SOTHO, 68 to SINHALA, 69 to ESPERANTO, + 70 to SLOVAK, 71 to SLOVENIAN, 72 to SWAHILI, 73 to SCOTTISH_GAELIC, 74 to CEBUANO, + 75 to SOMALI, 76 to TAJIK, 77 to TELUGU, 78 to TAMIL, 79 to THAI, + 80 to TURKISH, 81 to WELSH, 82 to URDU, 83 to UKRAINIAN, 84 to UZBEK, + 85 to SPANISH, 86 to HEBREW, 87 to GREEK, 88 to HAWAIIAN, 89 to SINDHI, + 90 to HUNGARIAN, 91 to SHONA, 92 to ARMENIAN, 93 to IGBO, 94 to ITALIAN, + 95 to YIDDISH, 96 to HINDI, 97 to SUNDANESE, 98 to INDONESIAN, 99 to JAVANESE, + 100 to ENGLISH, 101 to YORUBA, 102 to VIETNAMESE, 103 to CHINESE_TRADITIONAL, + 104 to CHINESE_SIMPLIFIED, 105 to ASSAMESE, 106 to DARI, 107 to FIJIAN, + 108 to HMONG_DAW, 109 to INUKTITUT, 110 to KLINGON_LATIN, 111 to KLINGON_PIQAD, + 112 to ODIA, 113 to QUERETARO_OTOMI, 114 to TAHITIAN, 115 to TIGRINYA, + 116 to TONGAN, 117 to YUCATEC_MAYA + ) + + fun getLanguages(): List { + return ArrayList(languages.values) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/services/TranslationCacheService.kt b/src/main/kotlin/com/airsaid/localization/translate/services/TranslationCacheService.kt new file mode 100644 index 0000000..b7c2ed4 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/services/TranslationCacheService.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.services + +import com.airsaid.localization.translate.util.GsonUtil +import com.airsaid.localization.translate.util.LRUCache +import com.google.gson.reflect.TypeToken +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.* +import com.intellij.util.xmlb.Converter +import com.intellij.util.xmlb.XmlSerializerUtil +import com.intellij.util.xmlb.annotations.OptionTag +import com.intellij.util.xmlb.annotations.Transient +import java.lang.reflect.Type + +/** + * Cache the translated text to local disk. + * + * The maximum number of caches is set by the [setMaxCacheSize] method, + * if exceed this size, remove old data through the LRU algorithm. + * + * @author airsaid + */ +@State( + name = "com.airsaid.localization.translate.services.TranslationCacheService", + storages = [Storage("androidLocalizeTranslationCaches.xml")] +) +@Service +class TranslationCacheService : PersistentStateComponent, Disposable { + + @Transient + private val lruCache = LRUCache(CACHE_MAX_SIZE) + + @OptionTag(converter = LruCacheConverter::class) + fun getLruCache(): LRUCache = lruCache + + fun put(key: String, value: String) { + lruCache.put(key, value) + } + + fun get(key: String?): String { + val value = lruCache.get(key) + return value ?: "" + } + + fun setMaxCacheSize(maxCacheSize: Int) { + lruCache.setMaxCapacity(maxCacheSize) + } + + override fun getState(): TranslationCacheService = this + + override fun loadState(state: TranslationCacheService) { + XmlSerializerUtil.copyBean(state, this) + } + + override fun dispose() { + lruCache.clear() + } + + class LruCacheConverter : Converter>() { + override fun fromString(value: String): LRUCache? { + val type: Type = object : TypeToken>() {}.type + val map: Map = GsonUtil.getInstance().gson.fromJson(value, type) + val lruCache = LRUCache(CACHE_MAX_SIZE) + for ((key, value1) in map) { + lruCache.put(key, value1) + } + return lruCache + } + + override fun toString(lruCache: LRUCache): String? { + val values = linkedMapOf() + lruCache.forEach { key, value -> values[key] = value } + return GsonUtil.getInstance().gson.toJson(values) + } + } + + companion object { + private const val CACHE_MAX_SIZE = 500 + + fun getInstance(): TranslationCacheService = service() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt b/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt new file mode 100644 index 0000000..7688275 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt @@ -0,0 +1,152 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.services + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.impl.google.GoogleTranslator +import com.airsaid.localization.translate.interceptors.EscapeCharactersInterceptor +import com.airsaid.localization.translate.lang.Lang +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import org.apache.commons.lang.StringUtils +import java.util.* +import java.util.function.Consumer + +/** + * @author airsaid + */ +@Service +class TranslatorService { + + interface TranslationInterceptor { + fun process(text: String?): String? + } + + private var selectedTranslator: AbstractTranslator? = null + private val defaultTranslator: AbstractTranslator + private val cacheService: TranslationCacheService + private val translators: Map + private val translationInterceptors: MutableList + private var isEnableCache = true + private var intervalTime = 0 + var maxCacheSize: Int = 1000 + set(value) { + field = value + cacheService.setMaxCacheSize(value) + } + var translationInterval: Int = 0 + set(value) { + field = value + intervalTime = value + } + + init { + val translatorsMap = linkedMapOf() + val serviceLoader = ServiceLoader.load( + AbstractTranslator::class.java, javaClass.classLoader + ) + for (translator in serviceLoader) { + translatorsMap[translator.key] = translator + } + translators = translatorsMap + defaultTranslator = translators[GoogleTranslator.KEY]!! + + cacheService = TranslationCacheService.getInstance() + + translationInterceptors = mutableListOf() + translationInterceptors.add(EscapeCharactersInterceptor()) + } + + fun getDefaultTranslator(): AbstractTranslator = defaultTranslator + + fun getTranslators(): Map = translators + + fun setSelectedTranslator(selectedTranslator: AbstractTranslator) { + if (this.selectedTranslator != selectedTranslator) { + LOG.info("setTranslator: $selectedTranslator") + this.selectedTranslator = selectedTranslator + } + } + + fun getSelectedTranslator(): AbstractTranslator? = selectedTranslator + + fun doTranslateByAsync(fromLang: Lang, toLang: Lang, text: String, consumer: Consumer) { + ApplicationManager.getApplication().executeOnPooledThread { + val translatedText = doTranslate(fromLang, toLang, text) + ApplicationManager.getApplication().invokeLater { + consumer.accept(translatedText) + } + } + } + + fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { + LOG.info("doTranslate fromLang: $fromLang, toLang: $toLang, text: $text") + + if (isEnableCache) { + val cacheResult = cacheService.get(getCacheKey(fromLang, toLang, text)) + if (cacheResult.isNotEmpty()) { + LOG.info("doTranslate cache result: $cacheResult") + return cacheResult + } + } + + // Arabic numbers skip translation + if (StringUtils.isNumeric(text)) { + return text + } + + var result = selectedTranslator!!.doTranslate(fromLang, toLang, text) + LOG.info("doTranslate result: $result") + for (interceptor in translationInterceptors) { + result = interceptor.process(result) ?: result + LOG.info("doTranslate interceptor process result: $result") + } + cacheService.put(getCacheKey(fromLang, toLang, text), result) + delay(intervalTime) + return result + } + + fun setEnableCache(isEnableCache: Boolean) { + this.isEnableCache = isEnableCache + } + + fun isEnableCache(): Boolean = isEnableCache + + + private fun getCacheKey(fromLang: Lang, toLang: Lang, text: String): String { + return "${fromLang.code}_${toLang.code}_$text" + } + + private fun delay(second: Int) { + if (second <= 0) return + try { + LOG.info("doTranslate delay time: $second second.") + Thread.sleep(second * 1000L) + } catch (e: InterruptedException) { + e.printStackTrace() + } + } + + companion object { + private val LOG = Logger.getInstance(TranslatorService::class.java) + + fun getInstance(): TranslatorService = service() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/util/AgentUtil.kt b/src/main/kotlin/com/airsaid/localization/translate/util/AgentUtil.kt new file mode 100644 index 0000000..8ef18c6 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/util/AgentUtil.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.util + +import com.intellij.openapi.util.SystemInfo + +/** + * @author airsaid + */ +object AgentUtil { + + private const val CHROME_VERSION = "98.0.4758.102" + private const val EDGE_VERSION = "98.0.1108.62" + + fun getUserAgent(): String { + val arch = System.getProperty("os.arch") + val is64Bit = arch?.contains("64") == true + val systemInformation = when { + SystemInfo.isWindows -> { + if (is64Bit) "Windows NT ${SystemInfo.OS_VERSION}; Win64; x64" else "Windows NT ${SystemInfo.OS_VERSION}" + } + SystemInfo.isMac -> { + val parts = SystemInfo.OS_VERSION.split(".").toMutableList() + if (parts.size < 3) { + parts.add("0") + } + "Macintosh; Intel Mac OS X ${parts.joinToString("_")}" + } + else -> { + if (is64Bit) "X11; Linux x86_64" else "X11; Linux x86" + } + } + return "Mozilla/5.0 ($systemInformation) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$CHROME_VERSION Safari/537.36 Edg/$EDGE_VERSION" + } +} \ No newline at end of file diff --git a/src/main/java/com/airsaid/localization/utils/TextUtil.java b/src/main/kotlin/com/airsaid/localization/translate/util/GsonUtil.kt similarity index 59% rename from src/main/java/com/airsaid/localization/utils/TextUtil.java rename to src/main/kotlin/com/airsaid/localization/translate/util/GsonUtil.kt index 5986c7b..62cb857 100644 --- a/src/main/java/com/airsaid/localization/utils/TextUtil.java +++ b/src/main/kotlin/com/airsaid/localization/translate/util/GsonUtil.kt @@ -15,26 +15,24 @@ * */ -package com.airsaid.localization.utils; +package com.airsaid.localization.translate.util -import com.intellij.openapi.util.text.StringUtil; -import org.jetbrains.annotations.Nullable; +import com.google.gson.Gson +import com.google.gson.GsonBuilder /** * @author airsaid */ -public class TextUtil { +class GsonUtil private constructor() { - public static boolean isEmptyOrSpacesLineBreak(@Nullable CharSequence s) { - if (StringUtil.isEmpty(s)) { - return true; - } - for (int i = 0; i < s.length(); i++) { - if (s.charAt(i) != ' ' && s.charAt(i) != '\r' && s.charAt(i) != '\n') { - return false; - } + val gson: Gson = GsonBuilder().create() + + companion object { + @JvmStatic + fun getInstance(): GsonUtil = GsonUtilHolder.instance } - return true; - } -} + private object GsonUtilHolder { + val instance = GsonUtil() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/util/LRUCache.kt b/src/main/kotlin/com/airsaid/localization/translate/util/LRUCache.kt new file mode 100644 index 0000000..735cba4 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/util/LRUCache.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.util + +import java.util.function.BiConsumer + +/** + * @author airsaid + */ +class LRUCache(initialCapacity: Int) { + + private val caches: MutableMap> + private var head: Node? = null + private var tail: Node? = null + private var maxCapacity: Int + + init { + maxCapacity = initialCapacity + if (initialCapacity <= 0) { + throw IllegalArgumentException("Illegal Capacity: $initialCapacity") + } + caches = linkedMapOf() + } + + fun put(key: K, value: V) { + while (isFull()) { + removeTailNode() + } + val newNode = Node(key, value) + caches[key] = newNode + moveToHeadNode(newNode) + } + + fun get(key: K?): V? { + if (caches.containsKey(key)) { + val newHead = caches[key]!! + moveToHeadNode(newHead) + return newHead.value + } + return null + } + + fun size(): Int = caches.size + + fun isFull(): Boolean = size() > 0 && size() >= maxCapacity + + fun isEmpty(): Boolean = size() <= 0 + + fun forEach(consumer: BiConsumer) { + for ((key, value) in caches) { + consumer.accept(key, value.value) + } + } + + fun forEach(action: (K, V) -> Unit) { + for ((key, value) in caches) { + action(key, value.value) + } + } + + fun clear() { + caches.clear() + head = null + tail = null + } + + private fun moveToHeadNode(node: Node) { + if (head == null) { + head = node + tail = node + return + } + + node.next = head + head?.prev = node + head = node + } + + private fun removeTailNode() { + val currentTail = tail ?: return + + caches.remove(currentTail.key) + val prev = currentTail.prev + prev?.next = null + currentTail.prev = null + tail = prev + } + + fun setMaxCapacity(maxCapacity: Int) { + this.maxCapacity = maxCapacity + } + + fun getMaxCapacity(): Int = maxCapacity + + private class Node( + val key: K, + val value: V + ) { + var prev: Node? = null + var next: Node? = null + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/util/MD5.kt b/src/main/kotlin/com/airsaid/localization/translate/util/MD5.kt new file mode 100644 index 0000000..04f3afe --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/util/MD5.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.util + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +/** + * @author airsaid + */ +object MD5 { + + private val hexDigits = charArrayOf( + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + ) + + fun md5(input: String?): String? { + if (input == null) { + return null + } + + return try { + val messageDigest = MessageDigest.getInstance("MD5") + val inputByteArray = input.toByteArray(StandardCharsets.UTF_8) + messageDigest.update(inputByteArray) + val resultByteArray = messageDigest.digest() + byteArrayToHex(resultByteArray) + } catch (e: NoSuchAlgorithmException) { + null + } + } + + private fun byteArrayToHex(byteArray: ByteArray): String { + val resultCharArray = CharArray(byteArray.size * 2) + var index = 0 + for (b in byteArray) { + resultCharArray[index++] = hexDigits[b.toInt() ushr 4 and 0xf] + resultCharArray[index++] = hexDigits[b.toInt() and 0xf] + } + return String(resultCharArray) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/util/UrlBuilder.kt b/src/main/kotlin/com/airsaid/localization/translate/util/UrlBuilder.kt new file mode 100644 index 0000000..4962d6c --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/util/UrlBuilder.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.util + +/** + * @author airsaid + */ +class UrlBuilder(private val baseUrl: String) { + + private val queryParameters: MutableList> = mutableListOf() + + fun addQueryParameter(key: String, value: String): UrlBuilder { + queryParameters.add(Pair(key, value)) + return this + } + + fun addQueryParameters(key: String, vararg values: String): UrlBuilder { + queryParameters.addAll(values.map { value -> Pair(key, value) }) + return this + } + + fun build(): String { + val result = StringBuilder(baseUrl) + for (i in queryParameters.indices) { + if (i == 0) { + result.append("?") + } else { + result.append("&") + } + val param = queryParameters[i] + val key = param.first + val value = param.second + result.append(key) + .append("=") + .append(value) + } + return result.toString() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/ui/FixedLinkLabel.kt b/src/main/kotlin/com/airsaid/localization/ui/FixedLinkLabel.kt new file mode 100644 index 0000000..be29529 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/ui/FixedLinkLabel.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.ui + +import com.intellij.icons.AllIcons +import com.intellij.ui.components.labels.LinkLabel +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent + +/** + * Fixed the problem that sometimes click does not respond. + * + * @author airsaid + */ +class FixedLinkLabel : LinkLabel("", AllIcons.Ide.Link) { + + private var isDoClick = false + + init { + addMouseListener(object : MouseAdapter() { + override fun mouseReleased(e: MouseEvent) { + if (isEnabled && isInClickableArea(e.point)) { + doClick() + } + } + + override fun mouseExited(e: MouseEvent) { + isDoClick = false + } + }) + } + + override fun doClick() { + if (!isDoClick) { + isDoClick = true + super.doClick() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt new file mode 100644 index 0000000..495066b --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.ui + +import com.airsaid.localization.config.SettingsState +import com.airsaid.localization.constant.Constants +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.services.TranslatorService +import com.airsaid.localization.utils.LanguageUtil +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBCheckBox +import java.awt.Component +import java.awt.GridLayout +import java.awt.event.ItemEvent +import javax.swing.JCheckBox +import javax.swing.JComponent +import javax.swing.JLabel +import javax.swing.JPanel + +/** + * Select the language dialog you want to Translate. + * + * @author airsaid + */ +class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(project, false) { + + interface OnClickListener { + fun onClickListener(selectedLanguage: List) + } + + private lateinit var contentPanel: JPanel + private lateinit var overwriteExistingStringCheckBox: JCheckBox + private lateinit var selectAllCheckBox: JCheckBox + private lateinit var languagesPanel: JPanel + private lateinit var openTranslatedFileCheckBox: JCheckBox + private lateinit var powerTranslatorLabel: JLabel + + private var onClickListener: OnClickListener? = null + private val selectedLanguages = mutableListOf() + + init { + doCreateCenterPanel() + title = "Select Translated Languages" + init() + } + + fun setOnClickListener(listener: OnClickListener) { + onClickListener = listener + } + + override fun createCenterPanel(): JComponent? { + return contentPanel + } + + private fun doCreateCenterPanel() { + // add languages + selectedLanguages.clear() + val supportedLanguages = TranslatorService.getInstance().getSelectedTranslator()!!.supportedLanguages + val sortedLanguages = supportedLanguages.toMutableList() + sortedLanguages.sortWith(EnglishNameComparator()) // sort by english name, easy to find + addLanguageList(sortedLanguages) + + // add options + initOverwriteExistingStringOption() + initOpenTranslatedFileCheckBox() + initSelectAllOption() + + // set power ui + val translator = TranslatorService.getInstance().getSelectedTranslator()!! + powerTranslatorLabel.text = "Powered by ${translator.name}" + powerTranslatorLabel.icon = translator.icon + } + + private fun addLanguageList(supportedLanguages: List) { + val selectedLanguageIds = LanguageUtil.getSelectedLanguageIds(project) + languagesPanel.layout = GridLayout(supportedLanguages.size / 4, 4) + for (language in supportedLanguages) { + val code = language.code + val checkBoxLanguage = JBCheckBox() + checkBoxLanguage.text = "${language.englishName}($code)" + languagesPanel.add(checkBoxLanguage) + checkBoxLanguage.addItemListener { e -> + val state = e.stateChange + if (state == ItemEvent.SELECTED) { + selectedLanguages.add(language) + } else { + selectedLanguages.remove(language) + } + // Update the OK button UI + okAction.isEnabled = selectedLanguages.size > 0 + } + if (selectedLanguageIds?.contains(language.id.toString()) == true) { + checkBoxLanguage.isSelected = true + } + } + } + + private fun initOverwriteExistingStringOption() { + val isOverwriteExistingString = PropertiesComponent.getInstance(project!!) + .getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING) + overwriteExistingStringCheckBox.isSelected = isOverwriteExistingString + overwriteExistingStringCheckBox.addItemListener { e -> + val state = e.stateChange + PropertiesComponent.getInstance(project!!) + .setValue(Constants.KEY_IS_OVERWRITE_EXISTING_STRING, state == ItemEvent.SELECTED) + } + } + + private fun initOpenTranslatedFileCheckBox() { + val isOpenTranslatedFile = PropertiesComponent.getInstance(project!!) + .getBoolean(Constants.KEY_IS_OPEN_TRANSLATED_FILE) + openTranslatedFileCheckBox.isSelected = isOpenTranslatedFile + openTranslatedFileCheckBox.addItemListener { e -> + val state = e.stateChange + PropertiesComponent.getInstance(project!!) + .setValue(Constants.KEY_IS_OPEN_TRANSLATED_FILE, state == ItemEvent.SELECTED) + } + } + + private fun initSelectAllOption() { + val isSelectAll = PropertiesComponent.getInstance(project!!) + .getBoolean(Constants.KEY_IS_SELECT_ALL) + selectAllCheckBox.isSelected = isSelectAll + selectAllCheckBox.addItemListener { e -> + val state = e.stateChange + selectAll(state == ItemEvent.SELECTED) + PropertiesComponent.getInstance(project!!) + .setValue(Constants.KEY_IS_SELECT_ALL, state == ItemEvent.SELECTED) + } + } + + private fun selectAll(selectAll: Boolean) { + for (component in languagesPanel.components) { + if (component is JBCheckBox) { + component.isSelected = selectAll + } + } + } + + override fun getDimensionServiceKey(): String? { + val key = SettingsState.getInstance().selectedTranslator.key + return "#com.airsaid.localization.ui.SelectLanguagesDialog#$key" + } + + override fun doOKAction() { + LanguageUtil.saveSelectedLanguage(project!!, selectedLanguages) + onClickListener?.onClickListener(selectedLanguages) + super.doOKAction() + } + + class EnglishNameComparator : Comparator { + override fun compare(o1: Lang, o2: Lang): Int { + return o1.englishName.compareTo(o2.englishName) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt new file mode 100644 index 0000000..1dfb1f2 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.ui + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.lang.Lang +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.components.JBLabel +import java.awt.GridLayout +import javax.swing.Action +import javax.swing.JComponent +import javax.swing.JPanel + +/** + * @author airsaid + */ +class SupportLanguagesDialog(private val translator: AbstractTranslator) : DialogWrapper(true) { + + init { + title = "${translator.name} Translator Supported Languages" + init() + } + + override fun createCenterPanel(): JComponent? { + val supportedLanguages = translator.supportedLanguages.toMutableList() + supportedLanguages.sortWith(EnglishNameComparator()) + val contentPanel = JPanel(GridLayout(supportedLanguages.size / 4, 4, 10, 20)) + for (supportedLanguage in supportedLanguages) { + contentPanel.add(JBLabel(supportedLanguage.englishName)) + } + return contentPanel + } + + override fun getDimensionServiceKey(): String? { + val key = translator.key + return "#com.airsaid.localization.ui.SupportLanguagesDialog#$key" + } + + override fun createActions(): Array { + return emptyArray() + } + + class EnglishNameComparator : Comparator { + override fun compare(o1: Lang, o2: Lang): Int { + return o1.englishName.compareTo(o2.englishName) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/utils/LanguageUtil.kt b/src/main/kotlin/com/airsaid/localization/utils/LanguageUtil.kt new file mode 100644 index 0000000..4db7b50 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/utils/LanguageUtil.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.utils + +import com.airsaid.localization.constant.Constants +import com.airsaid.localization.translate.lang.Lang +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.project.Project +import org.apache.http.util.TextUtils + +/** + * A util class that operates on language data. + * + * @author airsaid + */ +object LanguageUtil { + + private const val SEPARATOR_SELECTED_LANGUAGES_CODE = "," + + /** + * Save the language data selected in the current project. + * + * @param project current project. + * @param languages selected language. + */ + fun saveSelectedLanguage(project: Project, languages: List) { + PropertiesComponent.getInstance(project) + .setValue(Constants.KEY_SELECTED_LANGUAGES, getLanguageIdString(languages)) + } + + /** + * Get saved language code data in the current project. + * + * @param project current project. + * @return null if not saved, otherwise return the saved language id data. + */ + fun getSelectedLanguageIds(project: Project?): List? { + val codeString = PropertiesComponent.getInstance(project!!) + .getValue(Constants.KEY_SELECTED_LANGUAGES) + + if (TextUtils.isEmpty(codeString)) { + return null + } + + return codeString!!.split(SEPARATOR_SELECTED_LANGUAGES_CODE) + } + + private fun getLanguageIdString(language: List): String { + return language.joinToString(SEPARATOR_SELECTED_LANGUAGES_CODE) { it.id.toString() } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/utils/NotificationUtil.kt b/src/main/kotlin/com/airsaid/localization/utils/NotificationUtil.kt new file mode 100644 index 0000000..1f83b99 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/utils/NotificationUtil.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.utils + +import com.intellij.notification.NotificationGroup +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.project.Project + +/** + * @author airsaid + */ +object NotificationUtil { + + private const val NOTIFICATION_GROUP_ID = "Android Localize Plugin" + + private val NOTIFICATION_GROUP: NotificationGroup = + NotificationGroupManager.getInstance().getNotificationGroup(NOTIFICATION_GROUP_ID) + + fun notifyInfo(project: Project?, content: String) { + NOTIFICATION_GROUP.createNotification(content, NotificationType.INFORMATION) + .notify(project) + } + + fun notifyWarning(project: Project?, content: String) { + NOTIFICATION_GROUP.createNotification(content, NotificationType.WARNING) + .notify(project) + } + + fun notifyError(project: Project?, content: String) { + NOTIFICATION_GROUP.createNotification(content, NotificationType.ERROR) + .notify(project) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/utils/SecureStorage.kt b/src/main/kotlin/com/airsaid/localization/utils/SecureStorage.kt new file mode 100644 index 0000000..2f2c719 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/utils/SecureStorage.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.utils + +import com.airsaid.localization.constant.Constants +import com.intellij.credentialStore.CredentialAttributes +import com.intellij.credentialStore.Credentials +import com.intellij.credentialStore.generateServiceName +import com.intellij.ide.passwordSafe.PasswordSafe + +/** + * @author airsaid + */ +class SecureStorage(private val key: String) { + + fun save(text: String) { + val credentialAttributes = createCredentialAttributes() + val credentials = Credentials(key, text) + PasswordSafe.instance.set(credentialAttributes, credentials) + } + + fun read(): String { + val password = PasswordSafe.instance.getPassword(createCredentialAttributes()) + return password ?: "" + } + + private fun createCredentialAttributes(): CredentialAttributes { + return CredentialAttributes(generateServiceName(Constants.PLUGIN_NAME, key)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/utils/TextUtil.kt b/src/main/kotlin/com/airsaid/localization/utils/TextUtil.kt new file mode 100644 index 0000000..cf5b852 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/utils/TextUtil.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.utils + +import com.intellij.openapi.util.text.StringUtil + +/** + * @author airsaid + */ +object TextUtil { + + fun isEmptyOrSpacesLineBreak(s: CharSequence?): Boolean { + if (StringUtil.isEmpty(s)) { + return true + } + for (i in s!!.indices) { + if (s[i] != ' ' && s[i] != '\r' && s[i] != '\n') { + return false + } + } + return true + } +} \ No newline at end of file diff --git a/src/main/kotlin/icons/PluginIcons.kt b/src/main/kotlin/icons/PluginIcons.kt new file mode 100644 index 0000000..f55c2a9 --- /dev/null +++ b/src/main/kotlin/icons/PluginIcons.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2018 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package icons + +import com.intellij.openapi.util.IconLoader +import javax.swing.Icon + +/** + * @author airsaid + */ +object PluginIcons { + @JvmField + val TRANSLATE_ACTION_ICON: Icon = load("/icons/icon_translate.svg") + + @JvmField + val GOOGLE_ICON: Icon = load("/icons/icon_google.svg") + + @JvmField + val BAIDU_ICON: Icon = load("/icons/icon_baidu.svg") + + @JvmField + val YOUDAO_ICON: Icon = load("/icons/icon_youdao.svg") + + @JvmField + val MICROSOFT_ICON: Icon = load("/icons/icon_microsoft.svg") + + @JvmField + val ALI_ICON: Icon = load("/icons/icon_ali.svg") + + @JvmField + val DEEP_L_ICON: Icon = load("/icons/icon_deepl.svg") + + @JvmField + val OPENAI_ICON: Icon = load("/icons/icon_openai.svg") + + private fun load(path: String): Icon { + return IconLoader.getIcon(path, PluginIcons::class.java) + } +} \ No newline at end of file diff --git a/src/test/java/com/airsaid/localization/translate/impl/google/GoogleTokenTest.java b/src/test/java/com/airsaid/localization/translate/impl/google/GoogleTokenTest.java deleted file mode 100644 index 65d0ac7..0000000 --- a/src/test/java/com/airsaid/localization/translate/impl/google/GoogleTokenTest.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.airsaid.localization.translate.impl.google; - -import com.intellij.openapi.util.Pair; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -/** - * @author airsaid - */ -public class GoogleTokenTest { - - @Test - public void getToken() { - long a = 202905874L; - long b = 544157181L; - long c = 419689L; - Pair tkk = Pair.create(c, a + b); - Assertions.assertEquals("34939.454418", GoogleToken.getToken("Translate", tkk)); - Assertions.assertEquals("671407.809414", GoogleToken.getToken("Google translate", tkk)); - } - -} \ No newline at end of file diff --git a/src/test/java/com/airsaid/localization/translate/util/LRUCacheTest.java b/src/test/java/com/airsaid/localization/translate/util/LRUCacheTest.java deleted file mode 100644 index 7605a71..0000000 --- a/src/test/java/com/airsaid/localization/translate/util/LRUCacheTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.airsaid.localization.translate.util; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * @author airsaid - */ -class LRUCacheTest { - @Test - void testEmpty() { - LRUCache lruCache = new LRUCache<>(10); - assertTrue(lruCache.isEmpty()); - assertFalse(lruCache.isFull()); - assertNull(lruCache.get("key")); - } - - @Test - void testFull() { - LRUCache lruCache = new LRUCache<>(1); - lruCache.put("key", "value"); - assertFalse(lruCache.isEmpty()); - assertTrue(lruCache.isFull()); - assertNotNull(lruCache.get("key")); - } - - @Test - void testPut() { - LRUCache lruCache = new LRUCache<>(3); - lruCache.put("key1", "value1"); - lruCache.put("key2", "value2"); - lruCache.put("key3", "value3"); - lruCache.put("key4", "value4"); - assertNull(lruCache.get("key1")); - assertEquals("value2", lruCache.get("key2")); - assertEquals("value3", lruCache.get("key3")); - assertEquals("value4", lruCache.get("key4")); - lruCache.put("key5", "value5"); - assertNull(lruCache.get("key2")); - } -} \ No newline at end of file diff --git a/src/test/java/com/airsaid/localization/translate/util/UrlBuilderTest.java b/src/test/java/com/airsaid/localization/translate/util/UrlBuilderTest.java deleted file mode 100644 index 37a197d..0000000 --- a/src/test/java/com/airsaid/localization/translate/util/UrlBuilderTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.airsaid.localization.translate.util; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -/** - * @author airsaid - */ -public class UrlBuilderTest { - @Test - public void testNoParameterBuild() { - String result = new UrlBuilder("https://translate.googleapis.com/translate_a/single") - .build(); - Assertions.assertEquals("https://translate.googleapis.com/translate_a/single", result); - } - - @Test - public void testSingleParameterBuild() { - String result = new UrlBuilder("https://translate.googleapis.com/translate_a/single") - .addQueryParameter("sl", "en") - .build(); - Assertions.assertEquals("https://translate.googleapis.com/translate_a/single?sl=en", result); - } - - @Test - public void testSomeParameterBuild() { - String result = new UrlBuilder("https://translate.googleapis.com/translate_a/single") - .addQueryParameter("sl", "en") - .addQueryParameter("tl", "zh-CN") - .addQueryParameter("client", "gtx") - .addQueryParameter("dt", "t") - .build(); - Assertions.assertEquals("https://translate.googleapis.com/translate_a/single?sl=en&tl=zh-CN&client=gtx&dt=t", result); - } - - @Test - public void testRepeatParameterBuild() { - String result = new UrlBuilder("https://translate.googleapis.com/translate_a/single") - .addQueryParameter("sl", "en") - .addQueryParameter("tl", "zh-CN") - .addQueryParameters("dt", "t", "bd", "ex") - .build(); - Assertions.assertEquals("https://translate.googleapis.com/translate_a/single?sl=en&tl=zh-CN&dt=t&dt=bd&dt=ex", result); - } -} \ No newline at end of file diff --git a/src/test/java/com/airsaid/localization/utils/TextUtilTest.java b/src/test/java/com/airsaid/localization/utils/TextUtilTest.java deleted file mode 100644 index a9db285..0000000 --- a/src/test/java/com/airsaid/localization/utils/TextUtilTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.airsaid.localization.utils; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * @author airsaid - */ -class TextUtilTest { - - @Test - void isEmptyOrSpacesLineBreak() { - assertTrue(TextUtil.isEmptyOrSpacesLineBreak(null)); - assertTrue(TextUtil.isEmptyOrSpacesLineBreak("")); - assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" ")); - assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" ")); - assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\r")); - assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\n")); - assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\r\n")); - assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" \r\n ")); - assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" \r \n ")); - - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text")); - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text ")); - assertFalse(TextUtil.isEmptyOrSpacesLineBreak(" text")); - assertFalse(TextUtil.isEmptyOrSpacesLineBreak(" text ")); - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\ntext")); - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text\n")); - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\rtext")); - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text\r")); - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\r\ntext\r\n")); - } -} \ No newline at end of file diff --git a/src/test/kotlin/com/airsaid/localization/translate/impl/google/GoogleTokenTest.kt b/src/test/kotlin/com/airsaid/localization/translate/impl/google/GoogleTokenTest.kt new file mode 100644 index 0000000..53f2ccc --- /dev/null +++ b/src/test/kotlin/com/airsaid/localization/translate/impl/google/GoogleTokenTest.kt @@ -0,0 +1,21 @@ +package com.airsaid.localization.translate.impl.google + +import com.intellij.openapi.util.Pair +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +/** + * @author airsaid + */ +class GoogleTokenTest { + + @Test + fun getToken() { + val a = 202905874L + val b = 544157181L + val c = 419689L + val tkk = Pair(c, a + b) + Assertions.assertEquals("34939.454418", GoogleToken.getToken("Translate", tkk)) + Assertions.assertEquals("671407.809414", GoogleToken.getToken("Google translate", tkk)) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/airsaid/localization/translate/util/LRUCacheTest.kt b/src/test/kotlin/com/airsaid/localization/translate/util/LRUCacheTest.kt new file mode 100644 index 0000000..f426af5 --- /dev/null +++ b/src/test/kotlin/com/airsaid/localization/translate/util/LRUCacheTest.kt @@ -0,0 +1,42 @@ +package com.airsaid.localization.translate.util + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +/** + * @author airsaid + */ +class LRUCacheTest { + + @Test + fun testEmpty() { + val lruCache = LRUCache(10) + assertTrue(lruCache.isEmpty()) + assertFalse(lruCache.isFull()) + assertNull(lruCache.get("key")) + } + + @Test + fun testFull() { + val lruCache = LRUCache(1) + lruCache.put("key", "value") + assertFalse(lruCache.isEmpty()) + assertTrue(lruCache.isFull()) + assertNotNull(lruCache.get("key")) + } + + @Test + fun testPut() { + val lruCache = LRUCache(3) + lruCache.put("key1", "value1") + lruCache.put("key2", "value2") + lruCache.put("key3", "value3") + lruCache.put("key4", "value4") + assertNull(lruCache.get("key1")) + assertEquals("value2", lruCache.get("key2")) + assertEquals("value3", lruCache.get("key3")) + assertEquals("value4", lruCache.get("key4")) + lruCache.put("key5", "value5") + assertNull(lruCache.get("key2")) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/airsaid/localization/translate/util/UrlBuilderTest.kt b/src/test/kotlin/com/airsaid/localization/translate/util/UrlBuilderTest.kt new file mode 100644 index 0000000..5b61673 --- /dev/null +++ b/src/test/kotlin/com/airsaid/localization/translate/util/UrlBuilderTest.kt @@ -0,0 +1,46 @@ +package com.airsaid.localization.translate.util + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +/** + * @author airsaid + */ +class UrlBuilderTest { + + @Test + fun testNoParameterBuild() { + val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") + .build() + Assertions.assertEquals("https://translate.googleapis.com/translate_a/single", result) + } + + @Test + fun testSingleParameterBuild() { + val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") + .addQueryParameter("sl", "en") + .build() + Assertions.assertEquals("https://translate.googleapis.com/translate_a/single?sl=en", result) + } + + @Test + fun testSomeParameterBuild() { + val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") + .addQueryParameter("sl", "en") + .addQueryParameter("tl", "zh-CN") + .addQueryParameter("client", "gtx") + .addQueryParameter("dt", "t") + .build() + Assertions.assertEquals("https://translate.googleapis.com/translate_a/single?sl=en&tl=zh-CN&client=gtx&dt=t", result) + } + + @Test + fun testRepeatParameterBuild() { + val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") + .addQueryParameter("sl", "en") + .addQueryParameter("tl", "zh-CN") + .addQueryParameters("dt", "t", "bd", "ex") + .build() + Assertions.assertEquals("https://translate.googleapis.com/translate_a/single?sl=en&tl=zh-CN&dt=t&dt=bd&dt=ex", result) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/airsaid/localization/utils/TextUtilTest.kt b/src/test/kotlin/com/airsaid/localization/utils/TextUtilTest.kt new file mode 100644 index 0000000..dc0b2f8 --- /dev/null +++ b/src/test/kotlin/com/airsaid/localization/utils/TextUtilTest.kt @@ -0,0 +1,33 @@ +package com.airsaid.localization.utils + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +/** + * @author airsaid + */ +class TextUtilTest { + + @Test + fun isEmptyOrSpacesLineBreak() { + assertTrue(TextUtil.isEmptyOrSpacesLineBreak(null)) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak("")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" ")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" ")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\r")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\n")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\r\n")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" \r\n ")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" \r \n ")) + + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text ")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak(" text")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak(" text ")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\ntext")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text\n")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\rtext")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text\r")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\r\ntext\r\n")) + } +} \ No newline at end of file From bb6544efcefb0d842bbe74a1a9a1f3e0932a7cf4 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Wed, 17 Sep 2025 10:09:43 +0800 Subject: [PATCH 12/58] Integrate new features from upstream: skip non-translatable strings and region language fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add option to skip non-translatable strings in SettingsState and AndroidValuesService - Fix region langs to generate correct values folder names (e.g., values-es-rES) - Update Kotlin implementation to include latest upstream features: * Skip non-translatable strings functionality from commit 9cf091c * Region language directory naming fix from commit 2d9e35a - Maintain all existing functionality while adding these important improvements - All tests pass successfully Features integrated: - SettingsState: Added isSkipNonTranslatable property and initialization - AndroidValuesService: Added skip logic for non-translatable XML tags - AndroidValuesService: Fixed getValuesDirectoryName to handle region codes properly 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../localization/config/SettingsState.kt | 12 ++++++- .../services/AndroidValuesService.kt | 36 ++++++++++++++++--- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt index 518b3e4..1602535 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt @@ -17,6 +17,7 @@ package com.airsaid.localization.config +import com.airsaid.localization.services.AndroidValuesService import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.utils.SecureStorage @@ -66,6 +67,8 @@ class SettingsState : PersistentStateComponent { translatorService.maxCacheSize = maxCacheSize translatorService.translationInterval = translationInterval } + + AndroidValuesService.getInstance().isSkipNonTranslatable = isSkipNonTranslatable } var selectedTranslator: AbstractTranslator @@ -115,6 +118,12 @@ class SettingsState : PersistentStateComponent { state.translationInterval = intervalTime } + var isSkipNonTranslatable: Boolean + get() = state.isSkipNonTranslatable + set(isSkipNonTranslatable) { + state.isSkipNonTranslatable = isSkipNonTranslatable + } + override fun getState(): State { return state } @@ -128,6 +137,7 @@ class SettingsState : PersistentStateComponent { var appIds: MutableMap = mutableMapOf(), var isEnableCache: Boolean = true, var maxCacheSize: Int = 500, - var translationInterval: Int = 2 // 2 second + var translationInterval: Int = 2, // 2 second + var isSkipNonTranslatable: Boolean = false ) } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt b/src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt index 54bb94c..57c3a4f 100644 --- a/src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt +++ b/src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt @@ -45,6 +45,8 @@ import java.util.regex.Pattern */ @Service class AndroidValuesService { + + var isSkipNonTranslatable: Boolean = false companion object { private val LOG = Logger.getInstance(AndroidValuesService::class.java) private val STRINGS_FILE_NAME_PATTERN = Pattern.compile(".+\\.xml") @@ -90,14 +92,33 @@ class AndroidValuesService { } private fun parseValuesXml(valueFile: PsiFile): List { - val values = mutableListOf() val xmlFile = valueFile as XmlFile - val document = xmlFile.document ?: return values - val rootTag = document.rootTag ?: return values + val document = xmlFile.document ?: return emptyList() + val rootTag = document.rootTag ?: return emptyList() val subTags = rootTag.children - values.addAll(subTags) + + if (!isSkipNonTranslatable) { + return subTags.toList() + } + + val values = mutableListOf() + var skipNext = false + + for (element in subTags) { + if (skipNext) { + skipNext = false + if (element !is XmlTag) { + continue + } + } + if (element is XmlTag && !isTranslatable(element)) { + skipNext = true + } else { + values.add(element) + } + } return values } @@ -184,7 +205,12 @@ class AndroidValuesService { } private fun getValuesDirectoryName(lang: Lang): String { - return "values-${lang.code}" + val parts = lang.code.split("-") + return if (parts.size > 1) { + "values-${parts[0]}-r${parts[1].uppercase()}" + } else { + "values-${lang.code}" + } } /** From 0ac8385507c523fe3a1e71c44b856cbaaa19207b Mon Sep 17 00:00:00 2001 From: Airsaid Date: Wed, 17 Sep 2025 19:06:25 +0800 Subject: [PATCH 13/58] Update Gradle, Kotlin, and IntelliJ plugin versions Upgraded Gradle to 8.5, Kotlin to 1.9.10, and updated org.jetbrains.intellij and changelog plugins. Increased Java version to 17 and IntelliJ platform version to 2023.3.2 in preparation for newer development requirements. Co-Authored-By: Claude --- build.gradle.kts | 6 +++--- gradle.properties | 10 +++++----- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index f384bf5..223c9b7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,13 +6,13 @@ fun properties(key: String) = project.findProperty(key).toString() plugins { // Kotlin support - kotlin("jvm") version "1.8.0" + kotlin("jvm") version "1.9.10" // Java support id("java") // Gradle IntelliJ Plugin - id("org.jetbrains.intellij") version "1.13.3" + id("org.jetbrains.intellij") version "1.17.2" // Gradle Changelog Plugin - id("org.jetbrains.changelog") version "2.1.2" + id("org.jetbrains.changelog") version "2.2.0" } group = properties("pluginGroup") diff --git a/gradle.properties b/gradle.properties index 3bd9e85..f6b107d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,19 +9,19 @@ pluginVersion = 3.0.0 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. -pluginSinceBuild = 203 +pluginSinceBuild = 233 pluginUntilBuild = # IntelliJ Platform Properties -> https://github.com/JetBrains/gradle-intellij-plugin#intellij-platform-properties platformType = IC -platformVersion = 2020.3.4 +platformVersion = 2023.3.2 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22 platformPlugins = com.intellij.java -# Java language level used to compile sources and to generate the files for - Java 11 is required since 2020.3 -javaVersion = 11 +# Java language level used to compile sources and to generate the files for - Java 17 is required since 2022.3 +javaVersion = 17 # Gradle Releases -> https://github.com/gradle/gradle/releases -gradleVersion = 7.5.1 \ No newline at end of file +gradleVersion = 8.5 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661..a595206 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From b3828b85f1239ffff0daeb62c790ad2a07f34ea5 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 19 Sep 2025 19:03:34 +0800 Subject: [PATCH 14/58] Refactor HTTP request handling in translators Introduces HttpRequestFactory to centralize HTTP request creation and configuration. Refactors AbstractTranslator and its implementations to use the new factory, adds support for custom content types and timeouts, and unifies request payload construction. Adds network tests for AbstractTranslator to verify correct request formatting. --- .../translate/AbstractTranslator.kt | 56 ++++-- .../translate/impl/google/GoogleToken.kt | 6 +- .../impl/microsoft/MicrosoftTranslator.kt | 5 +- .../impl/openai/ChatGPTTranslator.kt | 5 +- .../translate/util/HttpRequestFactory.kt | 44 +++++ .../AbstractTranslatorNetworkTest.kt | 175 ++++++++++++++++++ 6 files changed, 270 insertions(+), 21 deletions(-) create mode 100644 src/main/kotlin/com/airsaid/localization/translate/util/HttpRequestFactory.kt create mode 100644 src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt diff --git a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt index 2d43dae..b279ada 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt @@ -19,9 +19,9 @@ package com.airsaid.localization.translate import com.airsaid.localization.config.SettingsState import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.util.HttpRequestFactory import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.util.Pair -import com.intellij.util.io.HttpRequests import com.intellij.util.io.RequestBuilder import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -37,7 +37,8 @@ abstract class AbstractTranslator : Translator, TranslatorConfigurable { companion object { protected val LOG = Logger.getInstance(AbstractTranslator::class.java) - private const val CONTENT_TYPE = "application/x-www-form-urlencoded" + private const val DEFAULT_CONTENT_TYPE = "application/x-www-form-urlencoded" + private const val DEFAULT_TIMEOUT_MS = 60 * 1000 } @Throws(TranslationException::class) @@ -45,25 +46,19 @@ abstract class AbstractTranslator : Translator, TranslatorConfigurable { checkSupportedLanguages(fromLang, toLang, text) val requestUrl = getRequestUrl(fromLang, toLang, text) - val requestBuilder = HttpRequests.post(requestUrl, CONTENT_TYPE) - // Set the timeout time to 60 seconds. - requestBuilder.connectTimeout(60 * 1000) + val requestBuilder = createRequestBuilder(requestUrl) configureRequestBuilder(requestBuilder) return try { + val payload = buildRequestPayload(fromLang, toLang, text) requestBuilder.connect { request -> - val requestParams = getRequestParams(fromLang, toLang, text) - .joinToString("&") { pair -> - "${pair.first}=${URLEncoder.encode(pair.second, StandardCharsets.UTF_8)}" + payload.form?.let { request.write(it) } + payload.body?.let { body -> + if (payload.form != null && body.isNotEmpty()) { + request.write("&") } - if (requestParams.isNotEmpty()) { - request.write(requestParams) + request.write(body) } - val requestBody = getRequestBody(fromLang, toLang, text) - if (requestBody.isNotEmpty()) { - request.write(requestBody) - } - val resultText = request.readString() parsingResult(fromLang, toLang, text, resultText) } @@ -74,6 +69,10 @@ abstract class AbstractTranslator : Translator, TranslatorConfigurable { } } + protected open fun createRequestBuilder(requestUrl: String): RequestBuilder { + return HttpRequestFactory.post(requestUrl, requestContentType, requestTimeoutMs) + } + override val icon: Icon? = null override val isNeedAppId: Boolean = true @@ -108,6 +107,12 @@ abstract class AbstractTranslator : Translator, TranslatorConfigurable { // Default implementation does nothing } + protected open val requestContentType: String + get() = DEFAULT_CONTENT_TYPE + + protected open val requestTimeoutMs: Int + get() = DEFAULT_TIMEOUT_MS + protected open fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { throw UnsupportedOperationException() } @@ -117,4 +122,23 @@ abstract class AbstractTranslator : Translator, TranslatorConfigurable { throw TranslationException(fromLang, toLang, text, "${toLang.englishName} is not supported.") } } -} \ No newline at end of file + + private fun buildRequestPayload( + fromLang: Lang, + toLang: Lang, + text: String, + ): RequestPayload { + val requestParams = getRequestParams(fromLang, toLang, text) + .takeIf { it.isNotEmpty() } + ?.joinToString("&") { pair -> + "${pair.first}=${URLEncoder.encode(pair.second, StandardCharsets.UTF_8)}" + } + + val requestBody = getRequestBody(fromLang, toLang, text) + .takeIf { it.isNotEmpty() } + + return RequestPayload(requestParams, requestBody) + } +} + +private data class RequestPayload(val form: String?, val body: String?) diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt index fbb9fec..ce1922d 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt @@ -18,9 +18,9 @@ package com.airsaid.localization.translate.impl.google import com.airsaid.localization.translate.util.AgentUtil +import com.airsaid.localization.translate.util.HttpRequestFactory import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.util.Pair -import com.intellij.util.io.HttpRequests import java.util.* import java.util.regex.Pattern import kotlin.math.abs @@ -126,7 +126,7 @@ object GoogleToken { return try { val url = String.format(ELEMENT_URL, GoogleTranslator.HOST_URL) LOG.info("getTKKFromGoogle url: $url") - val elementJs = HttpRequests.request(url) + val elementJs = HttpRequestFactory.get(url) .userAgent(AgentUtil.getUserAgent()) .tuner { connection -> connection.setRequestProperty("Referer", GoogleTranslator.HOST_URL) } .readString() @@ -145,4 +145,4 @@ object GoogleToken { null } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt index 89eb80a..c62ba22 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt @@ -149,6 +149,9 @@ class MicrosoftTranslator : AbstractTranslator() { override val applyAppIdUrl: String? = APPLY_APP_ID_URL + override val requestContentType: String + get() = "application/json" + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { return UrlBuilder(TRANSLATE_URL) .addQueryParameter("api-version", "3.0") @@ -171,4 +174,4 @@ class MicrosoftTranslator : AbstractTranslator() { LOG.info("parsingResult: $resultText") return GsonUtil.getInstance().gson.fromJson(resultText, Array::class.java)[0].translationResult } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt index c8b6821..1ff2dad 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt @@ -55,6 +55,9 @@ class ChatGPTTranslator : AbstractTranslator() { override val appKeyDisplay: String get() = "KEY" + override val requestContentType: String + get() = "application/json" + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { return "https://api.openai.com/v1/chat/completions" } @@ -85,4 +88,4 @@ class ChatGPTTranslator : AbstractTranslator() { LOG.info("parsingResult ChatGPT: $resultText") return GsonUtil.getInstance().gson.fromJson(resultText, OpenAIResponse::class.java).translation } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/util/HttpRequestFactory.kt b/src/main/kotlin/com/airsaid/localization/translate/util/HttpRequestFactory.kt new file mode 100644 index 0000000..c40b330 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/util/HttpRequestFactory.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.util + +import com.intellij.util.io.HttpRequests +import com.intellij.util.io.RequestBuilder + +/** + * Factory methods for creating [RequestBuilder] instances that honour the IDE's + * proxy and timeout settings. + */ +object HttpRequestFactory { + + private const val DEFAULT_TIMEOUT_MS = 60 * 1000 + + fun post(url: String, contentType: String, timeoutMs: Int = DEFAULT_TIMEOUT_MS): RequestBuilder { + return HttpRequests.post(url, contentType) + .productNameAsUserAgent() + .connectTimeout(timeoutMs) + .readTimeout(timeoutMs) + } + + fun get(url: String, timeoutMs: Int = DEFAULT_TIMEOUT_MS): RequestBuilder { + return HttpRequests.request(url) + .productNameAsUserAgent() + .connectTimeout(timeoutMs) + .readTimeout(timeoutMs) + } +} diff --git a/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt b/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt new file mode 100644 index 0000000..7f80906 --- /dev/null +++ b/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt @@ -0,0 +1,175 @@ +package com.airsaid.localization.translate + +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages +import com.intellij.openapi.util.Pair +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class AbstractTranslatorNetworkTest { + + private lateinit var server: HttpServer + private lateinit var executor: ExecutorService + private lateinit var baseUrl: String + + @BeforeEach + fun setUp() { + executor = Executors.newSingleThreadExecutor() + val port = findFreePort() + server = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0) + server.executor = executor + baseUrl = "http://127.0.0.1:$port" + server.start() + } + + @AfterEach + fun tearDown() { + server.stop(0) + executor.shutdownNow() + } + + @Test + fun `doTranslate posts form encoded payload`() { + val capturedRequest = AtomicReference() + val latch = CountDownLatch(1) + + server.createContext("/translate") { exchange -> + capturedRequest.set(exchange.captureRequest()) + exchange.respondWith(200, "\"ok\"") + latch.countDown() + } + + val translator = object : TestTranslator("/translate") { + override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + return listOf( + Pair.create("q", text), + Pair.create("lang", toLang.translationCode) + ) + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + return resultText + } + } + + val inputText = "Hello world + test" + val result = translator.doTranslate(Languages.AUTO, Languages.ENGLISH, inputText) + + assertTrue(latch.await(2, TimeUnit.SECONDS)) + assertEquals("\"ok\"", result) + + val request = capturedRequest.get() + assertEquals("POST", request.method) + assertTrue(request.contentType?.contains("application/x-www-form-urlencoded") == true) + + val expectedBody = listOf( + "q" to inputText, + "lang" to Languages.ENGLISH.translationCode, + ).joinToString("&") { (name, value) -> + "${name}=${URLEncoder.encode(value, StandardCharsets.UTF_8)}" + } + assertEquals(expectedBody, request.body) + } + + @Test + fun `doTranslate posts raw json body when content type overrides`() { + val capturedRequest = AtomicReference() + val latch = CountDownLatch(1) + + server.createContext("/json") { exchange -> + capturedRequest.set(exchange.captureRequest()) + exchange.respondWith(200, "{\"translated\":\"ok\"}") + latch.countDown() + } + + val translator = object : TestTranslator("/json") { + override val requestContentType: String + get() = "application/json" + + override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + return emptyList() + } + + override fun getRequestBody(fromLang: Lang, toLang: Lang, text: String): String { + return "{\"text\":\"$text\",\"target\":\"${toLang.translationCode}\"}" + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + return resultText + } + } + + val inputText = "Hello" + val result = translator.doTranslate(Languages.AUTO, Languages.ENGLISH, inputText) + + assertTrue(latch.await(2, TimeUnit.SECONDS)) + assertEquals("{\"translated\":\"ok\"}", result) + + val request = capturedRequest.get() + assertEquals("POST", request.method) + assertTrue(request.contentType?.contains("application/json") == true) + val expectedBody = "{\"text\":\"$inputText\",\"target\":\"${Languages.ENGLISH.translationCode}\"}" + assertEquals(expectedBody, request.body) + } + + private fun findFreePort(): Int { + ServerSocket(0).use { socket -> + socket.reuseAddress = true + return socket.localPort + } + } + + private open inner class TestTranslator(private val endpoint: String) : AbstractTranslator() { + override val key: String = "Test" + override val name: String = "Test" + override val isNeedAppId: Boolean = false + override val isNeedAppKey: Boolean = false + override val supportedLanguages: List = listOf(Languages.ENGLISH) + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + return baseUrl + endpoint + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + throw UnsupportedOperationException("TestTranslator should override parsingResult") + } + } + + private data class CapturedRequest( + val method: String, + val body: String, + val contentType: String?, + ) + + private fun HttpExchange.captureRequest(): CapturedRequest { + val bodyText = requestBody.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } + val contentType = requestHeaders.getFirstIgnoreCase("Content-Type") + return CapturedRequest(requestMethod, bodyText, contentType) + } + + private fun HttpExchange.respondWith(status: Int, payload: String) { + val bytes = payload.toByteArray(StandardCharsets.UTF_8) + sendResponseHeaders(status, bytes.size.toLong()) + responseBody.use { out -> + out.write(bytes) + } + } + + private fun com.sun.net.httpserver.Headers.getFirstIgnoreCase(name: String): String? { + return this.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value?.firstOrNull() + } +} From 81d05a437b197cd2411a0be813f1fd360d197529 Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Fri, 19 Sep 2025 22:58:18 +0800 Subject: [PATCH 15/58] Update build scripts and workflows to 2025 template Align build scripts and GitHub Actions workflows with the latest IntelliJ Platform Plugin Template (2025). Upgrade Gradle wrapper to 9.0, Kotlin toolchain to 2.2.0, and raise minimum supported IntelliJ Platform build to 251 (2025.1). Add Qodana and Codecov configuration files, migrate to new plugin DSL and dependency management, and improve CI/CD reliability and coverage reporting. --- .github/workflows/build.yml | 252 +++++++++++++++-------- .github/workflows/release.yml | 74 ++++--- .github/workflows/run-ui-tests.yml | 26 ++- .gitignore | 11 +- CHANGELOG.md | 8 + build.gradle.kts | 238 +++++++++++---------- codecov.yml | 10 + gradle.properties | 31 ++- gradle/libs.versions.toml | 32 +++ gradle/wrapper/gradle-wrapper.properties | 2 +- qodana.yml | 12 ++ settings.gradle.kts | 6 +- 12 files changed, 451 insertions(+), 251 deletions(-) create mode 100644 codecov.yml create mode 100644 gradle/libs.versions.toml create mode 100644 qodana.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 752e13e..1883e49 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,143 +1,228 @@ -# GitHub Actions Workflow created for testing and preparing the plugin release in following steps: -# - validate Gradle Wrapper, -# - run 'test' and 'verifyPlugin' tasks, -# - run 'buildPlugin' task and prepare artifact for the further tests, -# - run 'runPluginVerifier' task, -# - create a draft release. +# GitHub Actions Workflow is created for testing and preparing the plugin release in the following steps: +# - Validate Gradle Wrapper. +# - Run 'test' and 'verifyPlugin' tasks. +# - Run Qodana inspections. +# - Run the 'buildPlugin' task and prepare artifact for further tests. +# - Run the 'runPluginVerifier' task. +# - Create a draft release. # -# Workflow is triggered on push and pull_request events. +# The workflow is triggered on push and pull_request events. # # GitHub Actions reference: https://help.github.com/en/actions # +## JBIJPPTPL name: Build on: - # Trigger the workflow on pushes to only the 'master' branch (this avoids duplicate checks being run e.g. for dependabot pull requests) + # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g., for dependabot pull requests) push: - branches: [master] + branches: [ main ] # Trigger the workflow on any pull request pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: - # Run Gradle Wrapper Validation Action to verify the wrapper's checksum - # Run verifyPlugin, IntelliJ Plugin Verifier, and test Gradle tasks - # Build plugin and provide the artifact for the next workflow jobs + # Prepare the environment and build the plugin build: name: Build runs-on: ubuntu-latest - outputs: - version: ${{ steps.properties.outputs.version }} - changelog: ${{ steps.properties.outputs.changelog }} steps: # Free GitHub Actions Environment Disk Space - name: Maximize Build Space - run: | - sudo rm -rf /usr/share/dotnet - sudo rm -rf /usr/local/lib/android - sudo rm -rf /opt/ghc + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + large-packages: false - # Check out current repository + # Check out the current repository - name: Fetch Sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - # Validate wrapper - - name: Gradle Wrapper Validation - uses: gradle/wrapper-validation-action@v1.1.0 - - # Setup Java 11 environment for the next steps + # Set up the Java environment for the next steps - name: Setup Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: zulu - java-version: 11 + java-version: 21 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 - # Set environment variables - - name: Export Properties - id: properties + # Build plugin + - name: Build plugin + run: ./gradlew buildPlugin + + # Prepare plugin archive content for creating artifact + - name: Prepare Plugin Artifact + id: artifact shell: bash run: | - PROPERTIES="$(./gradlew properties --console=plain -q)" - VERSION="$(echo "$PROPERTIES" | grep "^version:" | cut -f2- -d ' ')" - NAME="$(echo "$PROPERTIES" | grep "^pluginName:" | cut -f2- -d ' ')" - CHANGELOG="$(./gradlew getChangelog --unreleased --no-header --console=plain -q)" - CHANGELOG="${CHANGELOG//'%'/'%25'}" - CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" - CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" + cd ${{ github.workspace }}/build/distributions + FILENAME=`ls *.zip` + unzip "$FILENAME" -d content + + echo "filename=${FILENAME:0:-4}" >> $GITHUB_OUTPUT + + # Store an already-built plugin as an artifact for downloading + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact.outputs.filename }} + path: ./build/distributions/content/*/* + + # Run tests and upload a code coverage report + test: + name: Test + needs: [ build ] + runs-on: ubuntu-latest + steps: + + # Free GitHub Actions Environment Disk Space + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + large-packages: false - echo "::set-output name=version::$VERSION" - echo "::set-output name=name::$NAME" - echo "::set-output name=changelog::$CHANGELOG" - echo "::set-output name=pluginVerifierHomeDir::~/.pluginVerifier" + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Set up the Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 21 - ./gradlew listProductsReleases # prepare list of IDEs for Plugin Verifier + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: true # Run tests - name: Run Tests - run: ./gradlew test + run: ./gradlew check # Collect Tests Result of failed tests - name: Collect Tests Result if: ${{ failure() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: tests-result path: ${{ github.workspace }}/build/reports/tests - # Cache Plugin Verifier IDEs - - name: Setup Plugin Verifier IDEs Cache - uses: actions/cache@v3 + # Upload the Kover report to CodeCov + - name: Upload Code Coverage Report + uses: codecov/codecov-action@v5 + with: + files: ${{ github.workspace }}/build/reports/kover/report.xml + token: ${{ secrets.CODECOV_TOKEN }} + + # Run Qodana inspections and provide a report + inspectCode: + name: Inspect code + needs: [ build ] + runs-on: ubuntu-latest + permissions: + contents: write + checks: write + pull-requests: write + steps: + + # Free GitHub Actions Environment Disk Space + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + large-packages: false + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + # Set up the Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 21 + + # Run Qodana inspections + - name: Qodana - Code Inspection + uses: JetBrains/qodana-action@v2025.1.1 + with: + cache-default-branch-only: true + + # Run plugin structure verification along with IntelliJ Plugin Verifier + verify: + name: Verify plugin + needs: [ build ] + runs-on: ubuntu-latest + steps: + + # Free GitHub Actions Environment Disk Space + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + large-packages: false + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v4 + + # Set up the Java environment for the next steps + - name: Setup Java + uses: actions/setup-java@v4 with: - path: ${{ steps.properties.outputs.pluginVerifierHomeDir }}/ides - key: plugin-verifier-${{ hashFiles('build/listProductsReleases.txt') }} + distribution: zulu + java-version: 21 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: true # Run Verify Plugin task and IntelliJ Plugin Verifier tool - name: Run Plugin Verification tasks - run: ./gradlew runPluginVerifier -Pplugin.verifier.home.dir=${{ steps.properties.outputs.pluginVerifierHomeDir }} + run: ./gradlew verifyPlugin # Collect Plugin Verifier Result - name: Collect Plugin Verifier Result if: ${{ always() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: pluginVerifier-result path: ${{ github.workspace }}/build/reports/pluginVerifier - # Prepare plugin archive content for creating artifact - - name: Prepare Plugin Artifact - id: artifact - shell: bash - run: | - cd ${{ github.workspace }}/build/distributions - FILENAME=`ls *.zip` - unzip "$FILENAME" -d content - - echo "::set-output name=filename::${FILENAME:0:-4}" - - # Store already-built plugin as an artifact for downloading - - name: Upload artifact - uses: actions/upload-artifact@v3 - with: - name: ${{ steps.artifact.outputs.filename }} - path: ./build/distributions/content/*/* - # Prepare a draft release for GitHub Releases page for the manual verification - # If accepted and published, release workflow would be triggered + # If accepted and published, the release workflow would be triggered releaseDraft: - name: Release Draft + name: Release draft if: github.event_name != 'pull_request' - needs: build + needs: [ build, test, inspectCode, verify ] runs-on: ubuntu-latest permissions: contents: write steps: - # Check out current repository + # Check out the current repository - name: Fetch Sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - # Remove old release drafts by using the curl request for the available releases with draft flag + # Remove old release drafts by using the curl request for the available releases with a draft flag - name: Remove Old Release Drafts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -146,15 +231,16 @@ jobs: --jq '.[] | select(.draft == true) | .id' \ | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} - # Create new release draft - which is not publicly visible and requires manual acceptance + # Create a new release draft which is not publicly visible and requires manual acceptance - name: Create Release Draft env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gh release create v${{ needs.build.outputs.version }} \ + VERSION=$(./gradlew properties --property version --quiet --console=plain | tail -n 1 | cut -f2- -d ' ') + RELEASE_NOTE="./build/tmp/release_note.txt" + ./gradlew getChangelog --unreleased --no-header --quiet --console=plain --output-file=$RELEASE_NOTE + + gh release create $VERSION \ --draft \ - --title "v${{ needs.build.outputs.version }}" \ - --notes "$(cat << 'EOM' - ${{ needs.build.outputs.changelog }} - EOM - )" + --title $VERSION \ + --notes-file $RELEASE_NOTE diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b31a05b..7b988b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,6 @@ -# GitHub Actions Workflow created for handling the release process based on the draft release prepared -# with the Build workflow. Running the publishPlugin task requires the PUBLISH_TOKEN secret provided. +# GitHub Actions Workflow created for handling the release process based on the draft release prepared with the Build workflow. +# Running the publishPlugin task requires all the following secrets to be provided: PUBLISH_TOKEN, PRIVATE_KEY, PRIVATE_KEY_PASSWORD, CERTIFICATE_CHAIN. +# See https://plugins.jetbrains.com/docs/intellij/plugin-signing.html for more information. name: Release on: @@ -8,7 +9,7 @@ on: jobs: - # Prepare and publish the plugin to the Marketplace repository + # Prepare and publish the plugin to JetBrains Marketplace repository release: name: Publish Plugin runs-on: ubuntu-latest @@ -17,44 +18,45 @@ jobs: pull-requests: write steps: - # Check out current repository + # Free GitHub Actions Environment Disk Space + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + large-packages: false + + # Check out the current repository - name: Fetch Sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.event.release.tag_name }} - # Setup Java 11 environment for the next steps + # Set up the Java environment for the next steps - name: Setup Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: zulu - java-version: 11 + java-version: 21 - # Set environment variables - - name: Export Properties - id: properties - shell: bash - run: | - CHANGELOG="$(cat << 'EOM' | sed -e 's/^[[:space:]]*$//g' -e '/./,$!d' - ${{ github.event.release.body }} - EOM - )" - - CHANGELOG="${CHANGELOG//'%'/'%25'}" - CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" - CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" - - echo "::set-output name=changelog::$CHANGELOG" - - # Update Unreleased section with the current release note + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: true + + # Update the Unreleased section with the current release note - name: Patch Changelog - if: ${{ steps.properties.outputs.changelog != '' }} + if: ${{ github.event.release.body != '' }} env: - CHANGELOG: ${{ steps.properties.outputs.changelog }} + CHANGELOG: ${{ github.event.release.body }} run: | - ./gradlew patchChangelog --release-note="$CHANGELOG" + RELEASE_NOTE="./build/tmp/release_note.txt" + mkdir -p "$(dirname "$RELEASE_NOTE")" + echo "$CHANGELOG" > $RELEASE_NOTE + + ./gradlew patchChangelog --release-note-file=$RELEASE_NOTE - # Publish the plugin to the Marketplace + # Publish the plugin to JetBrains Marketplace - name: Publish Plugin env: PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} @@ -63,20 +65,21 @@ jobs: PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} run: ./gradlew publishPlugin - # Upload artifact as a release asset + # Upload an artifact as a release asset - name: Upload Release Asset env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* - # Create pull request + # Create a pull request - name: Create Pull Request - if: ${{ steps.properties.outputs.changelog != '' }} + if: ${{ github.event.release.body != '' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ github.event.release.tag_name }}" BRANCH="changelog-update-$VERSION" + LABEL="release changelog" git config user.email "action@github.com" git config user.name "GitHub Action" @@ -85,8 +88,13 @@ jobs: git commit -am "Changelog update - $VERSION" git push --set-upstream origin $BRANCH + gh label create "$LABEL" \ + --description "Pull requests with release changelog update" \ + --force \ + || true + gh pr create \ --title "Changelog update - \`$VERSION\`" \ --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ - --base master \ + --label "$LABEL" \ --head $BRANCH diff --git a/.github/workflows/run-ui-tests.yml b/.github/workflows/run-ui-tests.yml index 363d9e8..4eb7400 100644 --- a/.github/workflows/run-ui-tests.yml +++ b/.github/workflows/run-ui-tests.yml @@ -1,9 +1,9 @@ # GitHub Actions Workflow for launching UI tests on Linux, Windows, and Mac in the following steps: -# - prepare and launch IDE with your plugin and robot-server plugin, which is needed to interact with UI -# - wait for IDE to start -# - run UI tests with separate Gradle task +# - Prepare and launch IDE with your plugin and robot-server plugin, which is needed to interact with the UI. +# - Wait for IDE to start. +# - Run UI tests with a separate Gradle task. # -# Please check https://github.com/JetBrains/intellij-ui-test-robot for information about UI tests with IntelliJ Platform +# Please check https://github.com/JetBrains/intellij-ui-test-robot for information about UI tests with IntelliJ Platform. # # Workflow is triggered manually. @@ -31,16 +31,22 @@ jobs: steps: - # Check out current repository + # Check out the current repository - name: Fetch Sources - uses: actions/checkout@v3 + uses: actions/checkout@v4 - # Setup Java 11 environment for the next steps + # Set up the Java environment for the next steps - name: Setup Java - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: zulu - java-version: 11 + java-version: 21 + + # Setup Gradle + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: true # Run IDEA prepared for UI testing - name: Run IDE @@ -48,7 +54,7 @@ jobs: # Wait for IDEA to be started - name: Health Check - uses: jtalk/url-health-check-action@v3 + uses: jtalk/url-health-check-action@v4 with: url: http://127.0.0.1:8082 max-attempts: 15 diff --git a/.gitignore b/.gitignore index 863a228..bec1ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,8 @@ -# IDEA -.idea - -# Ignore Gradle project-specific cache directory +.DS_Store .gradle - -# Ignore Gradle build output directory +.idea +.intellijPlatform +.kotlin +.qodana build local.properties diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e4bc6..b6cde98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ## [Unreleased] +### Changed +- Align build scripts and workflows with IntelliJ Platform Plugin Template 2025 updates. +- Upgrade Gradle wrapper to 9.0 and Kotlin toolchain to 2.2.0. +- Raise minimum supported IntelliJ Platform build to 251 (2025.1). + +### Added +- Provide Qodana and Codecov configuration files. + ## [3.0.0] (2023-03-24) ### Added diff --git a/build.gradle.kts b/build.gradle.kts index 223c9b7..61bbe34 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,132 +1,158 @@ +import org.gradle.process.CommandLineArgumentProvider import org.jetbrains.changelog.Changelog -import org.jetbrains.changelog.date import org.jetbrains.changelog.markdownToHTML - -fun properties(key: String) = project.findProperty(key).toString() +import org.jetbrains.intellij.platform.gradle.TestFrameworkType plugins { - // Kotlin support - kotlin("jvm") version "1.9.10" - // Java support - id("java") - // Gradle IntelliJ Plugin - id("org.jetbrains.intellij") version "1.17.2" - // Gradle Changelog Plugin - id("org.jetbrains.changelog") version "2.2.0" + id("java") + alias(libs.plugins.kotlin) + alias(libs.plugins.kotlinKapt) + alias(libs.plugins.intelliJPlatform) + alias(libs.plugins.changelog) + alias(libs.plugins.qodana) + alias(libs.plugins.kover) } -group = properties("pluginGroup") -version = properties("pluginVersion") +group = providers.gradleProperty("pluginGroup").get() +version = providers.gradleProperty("pluginVersion").get() -// Configure project's dependencies -repositories { - mavenCentral() +kotlin { + jvmToolchain(21) } -// Configure Gradle IntelliJ Plugin - read more: https://github.com/JetBrains/gradle-intellij-plugin -intellij { - pluginName.set(properties("pluginName")) - version.set(properties("platformVersion")) - type.set(properties("platformType")) +repositories { + mavenCentral() - // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file. - plugins.set(properties("platformPlugins").split(',').map(String::trim).filter(String::isNotEmpty)) + intellijPlatform { + defaultRepositories() + } } -// Configure Gradle Changelog Plugin - read more: https://github.com/JetBrains/gradle-changelog-plugin -changelog { - groups.empty() - header.set(provider { "${version.get()} (${date()})" }) - repositoryUrl.set(properties("pluginRepositoryUrl")) +dependencies { + implementation(libs.gson) + implementation(libs.alimt) + + compileOnly(libs.autoServiceAnnotations) + kapt(libs.autoService) + + testImplementation(libs.junitJupiterApi) + testRuntimeOnly(libs.junitJupiterEngine) + + intellijPlatform { + create( + providers.gradleProperty("platformType"), + providers.gradleProperty("platformVersion"), + ) + + bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',').filter(String::isNotBlank) }) + bundledModules(providers.gradleProperty("platformBundledModules").map { it.split(',').filter(String::isNotBlank) }) + plugins(providers.gradleProperty("platformPlugins").map { it.split(',').filter(String::isNotBlank) }) + + testFramework(TestFrameworkType.Platform) + } } -tasks { - // Set the JVM compatibility versions - properties("javaVersion").let { - withType { - sourceCompatibility = it - targetCompatibility = it - options.encoding = "UTF-8" +intellijPlatform { + pluginConfiguration { + name = providers.gradleProperty("pluginName") + version = providers.gradleProperty("pluginVersion") + + description = providers.fileContents(layout.projectDirectory.file("README.md")).asText.map { + val start = "" + val end = "" + + with(it.lines()) { + if (!containsAll(listOf(start, end))) { + throw GradleException("Plugin description section not found in README.md:\n$start ... $end") + } + subList(indexOf(start) + 1, indexOf(end)).joinToString("\n").let(::markdownToHTML) + } + } + + val changelog = project.changelog + changeNotes = providers.gradleProperty("pluginVersion").map { pluginVersion -> + with(changelog) { + renderItem( + (getOrNull(pluginVersion) ?: getUnreleased()) + .withHeader(false) + .withEmptySections(false), + Changelog.OutputType.HTML, + ) + } + } + + ideaVersion { + sinceBuild = providers.gradleProperty("pluginSinceBuild") + untilBuild = providers.gradleProperty("pluginUntilBuild").map { it.ifBlank { null } } + } } - withType { - kotlinOptions { - jvmTarget = it - freeCompilerArgs = listOf("-Xjvm-default=all") - } + + signing { + certificateChain = providers.environmentVariable("CERTIFICATE_CHAIN") + privateKey = providers.environmentVariable("PRIVATE_KEY") + password = providers.environmentVariable("PRIVATE_KEY_PASSWORD") } - } - wrapper { - gradleVersion = properties("gradleVersion") - } + publishing { + token = providers.environmentVariable("PUBLISH_TOKEN") + channels = providers.gradleProperty("pluginVersion").map { + listOf(it.substringAfter('-', "").substringBefore('.').ifEmpty { "default" }) + } + } - patchPluginXml { - version.set(properties("pluginVersion")) - sinceBuild.set(properties("pluginSinceBuild")) - untilBuild.set(properties("pluginUntilBuild")) + pluginVerification { + ides { + recommended() + } + } +} - // Extract the section from README.md and provide for the plugin's manifest - pluginDescription.set( - projectDir.resolve("README.md").readText().lines().run { - val start = "" - val end = "" +changelog { + groups.empty() + repositoryUrl = providers.gradleProperty("pluginRepositoryUrl") +} - if (!containsAll(listOf(start, end))) { - throw GradleException("Plugin description section not found in README.md:\n$start ... $end") +kover { + reports { + total { + xml { + onCheck = true + } } - subList(indexOf(start) + 1, indexOf(end)) - }.joinToString("\n").run { markdownToHTML(this) } - ) - - // Get the latest available change notes from the changelog file - changeNotes.set(provider { - with(changelog) { - renderItem( - getOrNull(properties("pluginVersion")) ?: getLatest(), - Changelog.OutputType.HTML, - ) - } - }) - } - - test { - useJUnitPlatform() - } - - // Configure UI tests plugin - // Read more: https://github.com/JetBrains/intellij-ui-test-robot - runIdeForUiTests { - systemProperty("robot-server.port", "8082") - systemProperty("ide.mac.message.dialogs.as.sheets", "false") - systemProperty("jb.privacy.policy.text", "") - systemProperty("jb.consents.confirmation.enabled", "false") - } - - signPlugin { - certificateChain.set(System.getenv("CERTIFICATE_CHAIN")) - privateKey.set(System.getenv("PRIVATE_KEY")) - password.set(System.getenv("PRIVATE_KEY_PASSWORD")) - } - - publishPlugin { - dependsOn("patchChangelog") - token.set(System.getenv("PUBLISH_TOKEN")) - // pluginVersion is based on the SemVer (https://semver.org) and supports pre-release labels, like 2.1.7-alpha.3 - // Specify pre-release label to publish the plugin in a custom Release Channel automatically. Read more: - // https://plugins.jetbrains.com/docs/intellij/deployment.html#specifying-a-release-channel - channels.set(listOf(properties("pluginVersion").split('-').getOrElse(1) { "default" }.split('.').first())) - } + } } -dependencies { - // https://github.com/google/auto/tree/master/service - compileOnly("com.google.auto.service:auto-service-annotations:1.1.1") - annotationProcessor("com.google.auto.service:auto-service:1.1.1") +intellijPlatformTesting { + runIde { + register("runIdeForUiTests") { + task { + jvmArgumentProviders += CommandLineArgumentProvider { + listOf( + "-Drobot-server.port=8082", + "-Dide.mac.message.dialogs.as.sheets=false", + "-Djb.privacy.policy.text=", + "-Djb.consents.confirmation.enabled=false", + ) + } + } + + plugins { + robotServerPlugin() + } + } + } +} - implementation("com.google.code.gson:gson:2.10.1") - implementation("com.aliyun:alimt20181012:1.0.3") +tasks { + wrapper { + gradleVersion = providers.gradleProperty("gradleVersion").get() + } - testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.3") + test { + useJUnitPlatform() + } -} \ No newline at end of file + publishPlugin { + dependsOn(patchChangelog) + } +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..f6e0f07 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + informational: true + threshold: 0% + base: auto + patch: + default: + informational: true diff --git a/gradle.properties b/gradle.properties index f6b107d..8f94338 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,21 +7,30 @@ pluginRepositoryUrl = https://github.com/Airsaid/AndroidLocalizePlugin # SemVer format -> https://semver.org pluginVersion = 3.0.0 -# See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html -# for insight into build numbers and IntelliJ Platform versions. -pluginSinceBuild = 233 +# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html +pluginSinceBuild = 251 pluginUntilBuild = -# IntelliJ Platform Properties -> https://github.com/JetBrains/gradle-intellij-plugin#intellij-platform-properties +# IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-extension.html platformType = IC -platformVersion = 2023.3.2 +platformVersion = 2025.1.5 # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html -# Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22 -platformPlugins = com.intellij.java - -# Java language level used to compile sources and to generate the files for - Java 17 is required since 2022.3 -javaVersion = 17 +# Example: platformBundledPlugins = com.intellij.java +platformBundledPlugins = com.intellij.java +# Example: platformBundledModules = intellij.spellchecker +platformBundledModules = +# Example: platformPlugins = com.jetbrains.php:203.4449.22 +platformPlugins = # Gradle Releases -> https://github.com/gradle/gradle/releases -gradleVersion = 8.5 \ No newline at end of file +gradleVersion = 9.0.0 + +# Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib +kotlin.stdlib.default.dependency = false + +# Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html +org.gradle.configuration-cache = true + +# Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html +org.gradle.caching = true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..8347f5c --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,32 @@ +[versions] +# libraries +junitJupiter = "5.10.2" +opentest4j = "1.3.0" +autoService = "1.1.1" +autoServiceAnnotations = "1.1.1" +gson = "2.10.1" +alimt = "1.0.3" + +# plugins +changelog = "2.4.0" +intelliJPlatform = "2.7.2" +kotlin = "2.2.0" +kover = "0.9.1" +qodana = "2025.1.1" + +[libraries] +junitJupiterApi = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junitJupiter" } +junitJupiterEngine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junitJupiter" } +opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" } +autoService = { group = "com.google.auto.service", name = "auto-service", version.ref = "autoService" } +autoServiceAnnotations = { group = "com.google.auto.service", name = "auto-service-annotations", version.ref = "autoServiceAnnotations" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +alimt = { group = "com.aliyun", name = "alimt20181012", version.ref = "alimt" } + +[plugins] +changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } +intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } +kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlinKapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } +qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a595206..30006bd 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/qodana.yml b/qodana.yml new file mode 100644 index 0000000..81f13b9 --- /dev/null +++ b/qodana.yml @@ -0,0 +1,12 @@ +# Qodana configuration: +# https://www.jetbrains.com/help/qodana/qodana-yaml.html + +version: "1.0" +linter: jetbrains/qodana-jvm-community:2024.3 +projectJDK: "21" +profile: + name: qodana.recommended +exclude: + - name: All + paths: + - .qodana diff --git a/settings.gradle.kts b/settings.gradle.kts index c99b672..128aa32 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,5 @@ -rootProject.name = "AndroidLocalizePlugin" \ No newline at end of file +rootProject.name = "AndroidLocalizePlugin" + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} From b502548879e3425c8a3ccbe4adcf63540fa08870 Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Sat, 20 Sep 2025 01:34:11 +0800 Subject: [PATCH 16/58] Refine translate action and rebuild language dialog --- CHANGELOG.md | 6 + build.gradle.kts | 4 +- gradle/libs.versions.toml | 4 + .../localization/action/TranslateAction.kt | 107 +++++++++++------- .../localization/ui/SelectLanguagesDialog.kt | 51 +++++++-- 5 files changed, 119 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6cde98..8853410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,16 @@ - Align build scripts and workflows with IntelliJ Platform Plugin Template 2025 updates. - Upgrade Gradle wrapper to 9.0 and Kotlin toolchain to 2.2.0. - Raise minimum supported IntelliJ Platform build to 251 (2025.1). +- Refactor TranslateAction to follow IntelliJ action system best practices. +- Configure tests to run on the JUnit 5 framework while retaining required runtime compatibility. ### Added - Provide Qodana and Codecov configuration files. +### Fixed +- Restore visibility of the "Translate to Other Languages" action when selecting resource files from the Project view. +- Prevent Select Languages dialog from failing due to uninitialised UI components. + ## [3.0.0] (2023-03-24) ### Added diff --git a/build.gradle.kts b/build.gradle.kts index 61bbe34..39ef1e2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,8 @@ dependencies { testImplementation(libs.junitJupiterApi) testRuntimeOnly(libs.junitJupiterEngine) + testRuntimeOnly(libs.junitPlatformLauncher) + testRuntimeOnly(libs.junit4) intellijPlatform { create( @@ -48,7 +50,7 @@ dependencies { bundledModules(providers.gradleProperty("platformBundledModules").map { it.split(',').filter(String::isNotBlank) }) plugins(providers.gradleProperty("platformPlugins").map { it.split(',').filter(String::isNotBlank) }) - testFramework(TestFrameworkType.Platform) + testFramework(TestFrameworkType.JUnit5) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8347f5c..a460e57 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,8 @@ [versions] # libraries junitJupiter = "5.10.2" +junitPlatform = "1.10.2" +junittest4 = "4.13.2" opentest4j = "1.3.0" autoService = "1.1.1" autoServiceAnnotations = "1.1.1" @@ -22,6 +24,8 @@ autoService = { group = "com.google.auto.service", name = "auto-service", versio autoServiceAnnotations = { group = "com.google.auto.service", name = "auto-service-annotations", version.ref = "autoServiceAnnotations" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } alimt = { group = "com.aliyun", name = "alimt20181012", version.ref = "alimt" } +junitPlatformLauncher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatform" } +junit4 = { group = "junit", name = "junit", version.ref = "junittest4" } [plugins] changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } diff --git a/src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt b/src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt index 6db0648..4c7cb75 100644 --- a/src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt +++ b/src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt @@ -26,75 +26,98 @@ import com.airsaid.localization.utils.NotificationUtil import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.LangDataKeys +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.application.ReadAction import com.intellij.openapi.project.Project import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.xml.XmlTag +import com.intellij.psi.PsiManager /** * Translate android string value to other languages that can be used to localize your Android APP. * * @author airsaid */ -class TranslateAction : AnAction(), SelectLanguagesDialog.OnClickListener { - - private lateinit var project: Project - private lateinit var valueFile: PsiFile - private lateinit var values: List - private val valueService = AndroidValuesService.getInstance() +class TranslateAction : AnAction() { override fun actionPerformed(e: AnActionEvent) { - project = e.getRequiredData(CommonDataKeys.PROJECT) - valueFile = e.getRequiredData(CommonDataKeys.PSI_FILE) + val project = e.getRequiredData(CommonDataKeys.PROJECT) + val valueFile = e.getRequiredData(CommonDataKeys.PSI_FILE) + val valueService = AndroidValuesService.getInstance() SettingsState.getInstance().initSetting() valueService.loadValuesByAsync(valueFile) { loadedValues -> - if (!isTranslatable(loadedValues)) { + if (!isTranslatable(loadedValues, valueService)) { NotificationUtil.notifyInfo(project, "The ${valueFile.name} has no text to translate.") return@loadValuesByAsync } - values = loadedValues - showSelectLanguageDialog() + showSelectLanguageDialog(project, loadedValues, valueFile) } } - // Verify that there is a text in the value file that needs to be translated. - private fun isTranslatable(values: List): Boolean { - for (psiElement in values) { - if (psiElement is XmlTag) { - if (valueService.isTranslatable(psiElement)) { - return true - } - } + override fun update(e: AnActionEvent) { + val project = e.project + if (project == null) { + e.presentation.isEnabledAndVisible = false + return } - return false + + val psiFile = resolvePsiFile(e) + val isValueFile = AndroidValuesService.getInstance().isValueFile(psiFile) + + e.presentation.isEnabledAndVisible = isValueFile } - private fun showSelectLanguageDialog() { - val dialog = SelectLanguagesDialog(project) - dialog.setOnClickListener(this) - dialog.show() + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + + private fun resolvePsiFile(event: AnActionEvent): PsiFile? { + event.getData(CommonDataKeys.PSI_FILE)?.let { return it } + + val element = event.getData(LangDataKeys.PSI_ELEMENT) + if (element != null) { + element.containingFile?.let { return it } + } + + val project = event.project ?: return null + val virtualFile = event.getData(CommonDataKeys.VIRTUAL_FILE) ?: return null + if (virtualFile.isDirectory) { + return null + } + + return ReadAction.compute { + PsiManager.getInstance(project).findFile(virtualFile) + } } +} - override fun update(e: AnActionEvent) { - // The translation option is only show when xml file from values is selected - val project = e.getData(CommonDataKeys.PROJECT) - val isSelectValueFile = valueService.isValueFile(e.getData(CommonDataKeys.PSI_FILE)) - e.presentation.setEnabledAndVisible(project != null && isSelectValueFile) +private fun isTranslatable(values: List, valueService: AndroidValuesService): Boolean { + for (psiElement in values) { + if (psiElement is XmlTag && valueService.isTranslatable(psiElement)) { + return true + } } + return false +} - override fun onClickListener(selectedLanguage: List) { - val translationTask = TranslateTask(project, "Translating...", selectedLanguage, values, valueFile) - translationTask.setOnTranslateListener(object : TranslateTask.OnTranslateListener { - override fun onTranslateSuccess() { - NotificationUtil.notifyInfo(project, "Translation completed!") - } +private fun showSelectLanguageDialog(project: Project, values: List, valueFile: PsiFile) { + val dialog = SelectLanguagesDialog(project) + dialog.setOnClickListener(object : SelectLanguagesDialog.OnClickListener { + override fun onClickListener(selectedLanguage: List) { + val translationTask = TranslateTask(project, "Translating...", selectedLanguage, values, valueFile) + translationTask.setOnTranslateListener(object : TranslateTask.OnTranslateListener { + override fun onTranslateSuccess() { + NotificationUtil.notifyInfo(project, "Translation completed!") + } - override fun onTranslateError(e: Throwable) { - NotificationUtil.notifyError(project, "Translation failure: ${e.localizedMessage}") - } - }) - translationTask.queue() - } -} \ No newline at end of file + override fun onTranslateError(e: Throwable) { + NotificationUtil.notifyError(project, "Translation failure: ${e.localizedMessage}") + } + }) + translationTask.queue() + } + }) + dialog.show() +} diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt index 495066b..5df8b1e 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -19,7 +19,6 @@ package com.airsaid.localization.ui import com.airsaid.localization.config.SettingsState import com.airsaid.localization.constant.Constants -import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.utils.LanguageUtil @@ -27,6 +26,9 @@ import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI import java.awt.Component import java.awt.GridLayout import java.awt.event.ItemEvent @@ -34,6 +36,7 @@ import javax.swing.JCheckBox import javax.swing.JComponent import javax.swing.JLabel import javax.swing.JPanel +import javax.swing.BoxLayout /** * Select the language dialog you want to Translate. @@ -46,17 +49,18 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje fun onClickListener(selectedLanguage: List) } - private lateinit var contentPanel: JPanel - private lateinit var overwriteExistingStringCheckBox: JCheckBox - private lateinit var selectAllCheckBox: JCheckBox - private lateinit var languagesPanel: JPanel - private lateinit var openTranslatedFileCheckBox: JCheckBox - private lateinit var powerTranslatorLabel: JLabel + private val contentPanel = JPanel() + private val overwriteExistingStringCheckBox = JBCheckBox("Overwrite existing strings") + private val selectAllCheckBox = JBCheckBox("Select all") + private val languagesPanel = JPanel() + private val openTranslatedFileCheckBox = JBCheckBox("Open translated file after completion") + private val powerTranslatorLabel = JBLabel() private var onClickListener: OnClickListener? = null private val selectedLanguages = mutableListOf() init { + setupUi() doCreateCenterPanel() title = "Select Translated Languages" init() @@ -70,12 +74,35 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje return contentPanel } + private fun setupUi() { + contentPanel.layout = java.awt.BorderLayout(0, 12) + contentPanel.border = JBUI.Borders.empty(12) + + languagesPanel.layout = GridLayout(0, 4, 12, 4) + + val scrollPane = JBScrollPane(languagesPanel) + scrollPane.border = JBUI.Borders.empty() + contentPanel.add(scrollPane, java.awt.BorderLayout.CENTER) + + val optionsPanel = JPanel() + optionsPanel.layout = BoxLayout(optionsPanel, BoxLayout.Y_AXIS) + optionsPanel.border = JBUI.Borders.emptyTop(8) + + listOf(selectAllCheckBox, overwriteExistingStringCheckBox, openTranslatedFileCheckBox, powerTranslatorLabel).forEach { + it.alignmentX = Component.LEFT_ALIGNMENT + optionsPanel.add(it) + } + + contentPanel.add(optionsPanel, java.awt.BorderLayout.SOUTH) + } + private fun doCreateCenterPanel() { // add languages selectedLanguages.clear() val supportedLanguages = TranslatorService.getInstance().getSelectedTranslator()!!.supportedLanguages val sortedLanguages = supportedLanguages.toMutableList() sortedLanguages.sortWith(EnglishNameComparator()) // sort by english name, easy to find + languagesPanel.removeAll() addLanguageList(sortedLanguages) // add options @@ -91,7 +118,6 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje private fun addLanguageList(supportedLanguages: List) { val selectedLanguageIds = LanguageUtil.getSelectedLanguageIds(project) - languagesPanel.layout = GridLayout(supportedLanguages.size / 4, 4) for (language in supportedLanguages) { val code = language.code val checkBoxLanguage = JBCheckBox() @@ -100,7 +126,9 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje checkBoxLanguage.addItemListener { e -> val state = e.stateChange if (state == ItemEvent.SELECTED) { - selectedLanguages.add(language) + if (!selectedLanguages.contains(language)) { + selectedLanguages.add(language) + } } else { selectedLanguages.remove(language) } @@ -111,6 +139,9 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje checkBoxLanguage.isSelected = true } } + languagesPanel.revalidate() + languagesPanel.repaint() + okAction.isEnabled = selectedLanguages.isNotEmpty() } private fun initOverwriteExistingStringOption() { @@ -171,4 +202,4 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje return o1.englishName.compareTo(o2.englishName) } } -} \ No newline at end of file +} From 78759dc507a8eb1aba1d6760167a86bfc067e118 Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Sat, 20 Sep 2025 13:41:32 +0800 Subject: [PATCH 17/58] Polish Compose UI to match IDE theme --- CHANGELOG.md | 5 +- build.gradle.kts | 7 + gradle/libs.versions.toml | 6 +- .../localization/config/SettingsComponent.kt | 375 ++++++++++------ .../config/TranslatorCredentialsLoader.kt | 27 ++ .../localization/ui/IdeComposeTheme.kt | 96 ++++ .../localization/ui/SelectLanguagesDialog.kt | 424 ++++++++++++------ .../localization/ui/SupportLanguagesDialog.kt | 78 +++- src/main/resources/META-INF/plugin.xml | 3 +- .../config/SettingsComponentTest.kt | 66 +++ 10 files changed, 803 insertions(+), 284 deletions(-) create mode 100644 src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt create mode 100644 src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt create mode 100644 src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8853410..b8cbbb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,13 @@ ### Changed - Align build scripts and workflows with IntelliJ Platform Plugin Template 2025 updates. -- Upgrade Gradle wrapper to 9.0 and Kotlin toolchain to 2.2.0. +- Upgrade Gradle wrapper to 9.0 and align Kotlin toolchain with Compose-compatible 2.0.21. - Raise minimum supported IntelliJ Platform build to 251 (2025.1). - Refactor TranslateAction to follow IntelliJ action system best practices. - Configure tests to run on the JUnit 5 framework while retaining required runtime compatibility. +- Rebuild plugin UI (settings and dialogs) using Compose. +- Load secure credentials asynchronously to avoid password safe access on the EDT. +- Align Compose typography/colours with IDE themes, add language filtering chips, and polish dialog layouts. ### Added - Provide Qodana and Codecov configuration files. diff --git a/build.gradle.kts b/build.gradle.kts index 39ef1e2..a7f421f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,10 +7,12 @@ plugins { id("java") alias(libs.plugins.kotlin) alias(libs.plugins.kotlinKapt) + alias(libs.plugins.composeCompiler) alias(libs.plugins.intelliJPlatform) alias(libs.plugins.changelog) alias(libs.plugins.qodana) alias(libs.plugins.kover) + alias(libs.plugins.compose) } group = providers.gradleProperty("pluginGroup").get() @@ -22,6 +24,8 @@ kotlin { repositories { mavenCentral() + google() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") intellijPlatform { defaultRepositories() @@ -31,6 +35,9 @@ repositories { dependencies { implementation(libs.gson) implementation(libs.alimt) + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.foundation) compileOnly(libs.autoServiceAnnotations) kapt(libs.autoService) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a460e57..fc13053 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,13 +8,15 @@ autoService = "1.1.1" autoServiceAnnotations = "1.1.1" gson = "2.10.1" alimt = "1.0.3" +compose = "1.7.0-beta02" # plugins changelog = "2.4.0" intelliJPlatform = "2.7.2" -kotlin = "2.2.0" +kotlin = "2.0.21" kover = "0.9.1" qodana = "2025.1.1" +composePlugin = "1.7.0-beta02" [libraries] junitJupiterApi = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junitJupiter" } @@ -32,5 +34,7 @@ changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinKapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } +compose = { id = "org.jetbrains.compose", version.ref = "composePlugin" } diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt index fcf238b..4d006b0 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt @@ -12,191 +12,302 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. - * */ package com.airsaid.localization.config +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.services.TranslatorService -import com.airsaid.localization.ui.FixedLinkLabel +import com.airsaid.localization.ui.IdeTheme import com.airsaid.localization.ui.SupportLanguagesDialog import com.intellij.ide.BrowserUtil -import com.intellij.ide.HelpTooltip import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.ui.ComboBox -import com.intellij.openapi.util.text.StringUtil -import com.intellij.ui.CollectionComboBoxModel -import com.intellij.ui.SimpleListCellRenderer -import com.intellij.ui.components.JBCheckBox -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBPasswordField -import com.intellij.ui.components.JBTextField -import javax.swing.* -import java.awt.event.ItemEvent +import javax.swing.JComponent +import java.awt.Dimension /** - * @author airsaid + * Compose implementation of the settings panel exposed through the IDE Settings. */ -class SettingsComponent { +class SettingsComponent( + private val credentialsLoader: TranslatorCredentialsLoader = TranslatorCredentialsLoader.default() +) { companion object { private val LOG = Logger.getInstance(SettingsComponent::class.java) } - private lateinit var contentJPanel: JPanel - private lateinit var translatorsComboBox: ComboBox - private lateinit var appIdLabel: JBLabel - private lateinit var appIdField: JBTextField - private lateinit var appKeyLabel: JBLabel - private lateinit var appKeyField: JBPasswordField - private lateinit var applyLink: FixedLinkLabel - private lateinit var supportLanguagesButton: JButton - private lateinit var maxCacheSizeLabel: JLabel - private lateinit var enableCacheCheckBox: JBCheckBox - private lateinit var maxCacheSizeComboBox: ComboBox - private lateinit var translationIntervalComboBox: ComboBox + private val composePanel = ComposePanel() - init { - initTranslatorComponents() - initCacheComponents() - } + private val translatorsState = mutableStateListOf() + private val selectedTranslatorState = mutableStateOf(null) + private val appIdState = mutableStateOf("") + private val appKeyState = mutableStateOf("") + private val enableCacheState = mutableStateOf(true) + private val maxCacheSizeState = mutableStateOf("500") + private val translationIntervalState = mutableStateOf("2") - private fun initTranslatorComponents() { - translatorsComboBox.renderer = object : SimpleListCellRenderer() { - override fun customize( - list: JList, - value: AbstractTranslator, - index: Int, - selected: Boolean, - hasFocus: Boolean - ) { - text = value.name - icon = value.icon + init { + composePanel.preferredSize = Dimension(680, 560) + composePanel.setContent { + IdeTheme { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + SettingsContent( + translators = translatorsState, + selectedTranslator = selectedTranslatorState.value, + appIdState = appIdState, + appKeyState = appKeyState, + enableCacheState = enableCacheState, + maxCacheSizeState = maxCacheSizeState, + translationIntervalState = translationIntervalState, + onTranslatorSelected = { translator -> + applySelectedTranslator(translator) + }, + onShowSupportedLanguages = { translator -> SupportLanguagesDialog(translator).show() }, + onNavigateToApplyPage = { url -> BrowserUtil.browse(url) } + ) + } } } + } - translatorsComboBox.addItemListener { itemEvent -> - if (itemEvent.stateChange == ItemEvent.SELECTED) { - setSelectedTranslator(selectedTranslator) - } - } + val content: JComponent + get() = composePanel - applyLink.setListener({ _, _ -> - val selectedTranslator = selectedTranslator - val applyAppIdUrl = selectedTranslator.applyAppIdUrl - if (!StringUtil.isEmpty(applyAppIdUrl)) { - BrowserUtil.browse(applyAppIdUrl!!) - applyLink.isFocusable = false - } - }, null) + val preferredFocusedComponent: JComponent + get() = composePanel + + val selectedTranslator: AbstractTranslator + get() = selectedTranslatorState.value ?: error("Translator not selected") - supportLanguagesButton.addActionListener { - showSupportLanguagesDialog(selectedTranslator) + fun setTranslators(translators: Map) { + LOG.info("setTranslators: ${translators.keys}") + translatorsState.clear() + translatorsState.addAll(translators.values) + if (selectedTranslatorState.value == null && translatorsState.isNotEmpty()) { + applySelectedTranslator(translatorsState.first()) } } - private fun initCacheComponents() { - enableCacheCheckBox.addItemListener { event -> - when (event.stateChange) { - ItemEvent.SELECTED -> setEnableCache(true) - ItemEvent.DESELECTED -> setEnableCache(false) - } - } + fun setSelectedTranslator(selected: AbstractTranslator) { + applySelectedTranslator(selected) } - val selectedTranslator: AbstractTranslator - get() = translatorsComboBox.selectedItem as AbstractTranslator + fun setAppId(appId: String) { + appIdState.value = appId + } - private fun showSupportLanguagesDialog(selectedTranslator: AbstractTranslator) { - SupportLanguagesDialog(selectedTranslator).show() + fun setAppKey(appKey: String) { + appKeyState.value = appKey } - val content: JPanel - get() = contentJPanel + val appId: String + get() = appIdState.value - val preferredFocusedComponent: JComponent - get() = translatorsComboBox + val appKey: String + get() = appKeyState.value - fun setTranslators(translators: Map) { - LOG.info("setTranslators: ${translators.keys}") - translatorsComboBox.model = CollectionComboBoxModel(ArrayList(translators.values)) + fun setEnableCache(isEnable: Boolean) { + enableCacheState.value = isEnable } - fun setSelectedTranslator(selected: AbstractTranslator) { - LOG.info("setSelectedTranslator: $selected") - translatorsComboBox.selectedItem = selected - - val isNeedAppId = selected.isNeedAppId - appIdLabel.isVisible = isNeedAppId - appIdField.isVisible = isNeedAppId - if (isNeedAppId) { - appIdLabel.text = "${selected.appIdDisplay}:" - appIdField.text = selected.appId - } + val isEnableCache: Boolean + get() = enableCacheState.value - val isNeedAppKey = selected.isNeedAppKey - appKeyLabel.isVisible = isNeedAppKey - appKeyField.isVisible = isNeedAppKey - if (isNeedAppKey) { - appKeyLabel.text = "${selected.appKeyDisplay}:" - appKeyField.text = selected.appKey - } + fun setMaxCacheSize(maxCacheSize: Int) { + maxCacheSizeState.value = maxCacheSize.toString() + } - val applyAppIdUrl = selected.applyAppIdUrl - if (!StringUtil.isEmpty(applyAppIdUrl)) { - applyLink.isVisible = true - HelpTooltip() - .setDescription("Apply for ${selected.name} translation API service") - .installOn(applyLink) - } else { - applyLink.isVisible = false - } + val maxCacheSize: Int + get() = maxCacheSizeState.value.toIntOrNull() ?: 0 + + fun setTranslationInterval(intervalTime: Int) { + translationIntervalState.value = intervalTime.toString() } + val translationInterval: Int + get() = translationIntervalState.value.toIntOrNull() ?: 0 + val isSelectedDefaultTranslator: Boolean - get() = isSelectedDefaultTranslator(selectedTranslator) + get() = selectedTranslatorState.value?.let { isSelectedDefaultTranslator(it) } ?: false private fun isSelectedDefaultTranslator(selected: AbstractTranslator): Boolean { return selected == TranslatorService.getInstance().getDefaultTranslator() } - val appId: String - get() = appIdField.text ?: "" + private fun applySelectedTranslator(translator: AbstractTranslator) { + selectedTranslatorState.value = translator + appIdState.value = "" + appKeyState.value = "" - fun setAppId(appId: String) { - appIdField.text = appId + credentialsLoader.load(translator) { appId, appKey -> + if (selectedTranslatorState.value?.key == translator.key) { + appIdState.value = appId + appKeyState.value = appKey + } + } } +} - val appKey: String - get() { - val password = appKeyField.password - return if (password != null) String(password) else "" - } +@Composable +private fun SettingsContent( + translators: SnapshotStateList, + selectedTranslator: AbstractTranslator?, + appIdState: androidx.compose.runtime.MutableState, + appKeyState: androidx.compose.runtime.MutableState, + enableCacheState: androidx.compose.runtime.MutableState, + maxCacheSizeState: androidx.compose.runtime.MutableState, + translationIntervalState: androidx.compose.runtime.MutableState, + onTranslatorSelected: (AbstractTranslator) -> Unit, + onShowSupportedLanguages: (AbstractTranslator) -> Unit, + onNavigateToApplyPage: (String) -> Unit +) { + val scrollState = rememberScrollState() - fun setAppKey(appKey: String) { - appKeyField.text = appKey - } + Surface( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), + tonalElevation = 2.dp, + shadowElevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text( + text = "Translator", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) + ) - fun setEnableCache(isEnable: Boolean) { - enableCacheCheckBox.isSelected = isEnable - maxCacheSizeComboBox.isVisible = isEnable - maxCacheSizeLabel.isVisible = isEnable - } + var dropdownExpanded by remember { mutableStateOf(false) } - val isEnableCache: Boolean - get() = enableCacheCheckBox.isSelected + Box { + OutlinedButton(onClick = { dropdownExpanded = true }) { + Text(selectedTranslator?.name ?: "Select translator") + } - val maxCacheSize: Int - get() = (maxCacheSizeComboBox.selectedItem as String).toInt() + DropdownMenu( + expanded = dropdownExpanded, + onDismissRequest = { dropdownExpanded = false }, + modifier = Modifier.widthIn(min = 240.dp) + ) { + translators.forEach { translator -> + DropdownMenuItem( + text = { Text(translator.name) }, + onClick = { + dropdownExpanded = false + onTranslatorSelected(translator) + } + ) + } + } + } - fun setMaxCacheSize(maxCacheSize: Int) { - maxCacheSizeComboBox.selectedItem = maxCacheSize.toString() - } + val needsAppId = selectedTranslator?.isNeedAppId == true + val needsAppKey = selectedTranslator?.isNeedAppKey == true - val translationInterval: Int - get() = (translationIntervalComboBox.selectedItem as String).toInt() + if (needsAppId) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = appIdState.value, + onValueChange = { appIdState.value = it }, + singleLine = true, + label = { Text(selectedTranslator.appIdDisplay) } + ) + } - fun setTranslationInterval(intervalTime: Int) { - translationIntervalComboBox.selectedItem = intervalTime.toString() + if (needsAppKey) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = appKeyState.value, + onValueChange = { appKeyState.value = it }, + singleLine = true, + label = { Text(selectedTranslator.appKeyDisplay) } + ) + } + + if (!selectedTranslator?.applyAppIdUrl.isNullOrEmpty()) { + TextButton(onClick = { onNavigateToApplyPage(selectedTranslator.applyAppIdUrl!!) }) { + Text("Apply for ${selectedTranslator.name} API credentials") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Enable translation cache") + Switch( + checked = enableCacheState.value, + onCheckedChange = { enableCacheState.value = it }, + colors = SwitchDefaults.colors() + ) + } + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = maxCacheSizeState.value, + onValueChange = { newValue -> + val digits = newValue.filter { it.isDigit() } + maxCacheSizeState.value = digits.ifEmpty { "0" } + }, + label = { Text("Max cache size") }, + enabled = enableCacheState.value, + singleLine = true + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = translationIntervalState.value, + onValueChange = { newValue -> + val digits = newValue.filter { it.isDigit() } + translationIntervalState.value = digits.ifEmpty { "0" } + }, + label = { Text("Translation interval (seconds)") }, + singleLine = true + ) + + selectedTranslator?.let { + OutlinedButton(onClick = { onShowSupportedLanguages(it) }) { + Text("Supported languages") + } + } + } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt new file mode 100644 index 0000000..84ecc1d --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt @@ -0,0 +1,27 @@ +package com.airsaid.localization.config + +import com.airsaid.localization.translate.AbstractTranslator +import com.intellij.openapi.application.ApplicationManager + +fun interface TranslatorCredentialsLoader { + fun load(translator: AbstractTranslator, onLoaded: (appId: String, appKey: String) -> Unit) + + companion object { + fun default(): TranslatorCredentialsLoader = DefaultTranslatorCredentialsLoader + } +} + +private object DefaultTranslatorCredentialsLoader : TranslatorCredentialsLoader { + private val application = ApplicationManager.getApplication() + + override fun load(translator: AbstractTranslator, onLoaded: (String, String) -> Unit) { + application.executeOnPooledThread { + val settingsState = SettingsState.getInstance() + val appId = settingsState.getAppId(translator.key) + val appKey = settingsState.getAppKey(translator.key) + application.invokeLater { + onLoaded(appId, appKey) + } + } + } +} diff --git a/src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt b/src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt new file mode 100644 index 0000000..5747300 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt @@ -0,0 +1,96 @@ +package com.airsaid.localization.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.intellij.ui.JBColor +import com.intellij.util.ui.UIUtil +import java.awt.Color as AwtColor +import javax.swing.UIManager + +@Composable +fun IdeTheme(content: @Composable () -> Unit) { + val isDark = UIUtil.isUnderDarcula() + + val panelBackground = UIUtil.getPanelBackground().toComposeColor() + val surfaceColor = getUiColor("Panel.background", UIUtil.getPanelBackground()).toComposeColor() + val surfaceVariant = getUiColor("EditorPane.background", UIUtil.getPanelBackground()).toComposeColor() + val foreground = UIUtil.getLabelForeground().toComposeColor() + val secondaryForeground = UIUtil.getInactiveTextColor().toComposeColor() + val outline = getUiColor("Component.borderColor", JBColor(0xD7DCE2, 0x3C3F41)).toComposeColor() + val accent = JBColor(0x3574F0, 0x7EA7FF).toComposeColor() + + val colorScheme = if (isDark) { + darkColorScheme( + primary = accent, + onPrimary = Color.White, + secondary = accent, + onSecondary = Color.White, + background = panelBackground, + onBackground = foreground, + surface = panelBackground, + onSurface = foreground, + surfaceVariant = surfaceVariant, + onSurfaceVariant = secondaryForeground, + outline = outline, + ) + } else { + lightColorScheme( + primary = accent, + onPrimary = Color.White, + secondary = accent, + onSecondary = Color.White, + background = panelBackground, + onBackground = foreground, + surface = panelBackground, + onSurface = foreground, + surfaceVariant = surfaceVariant, + onSurfaceVariant = secondaryForeground, + outline = outline, + ) + } + + val typography = buildIdeTypography() + + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + content = content, + ) +} + +private fun buildIdeTypography(): Typography { + val baseFont = UIManager.getFont("Label.font") ?: java.awt.Font("SansSerif", java.awt.Font.PLAIN, 13) + val baseSize = baseFont.size2D + + fun style(multiplier: Float, weight: FontWeight = FontWeight.Normal) = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = weight, + fontSize = (baseSize * multiplier).sp, + ) + + return Typography( + bodyLarge = style(1.05f), + bodyMedium = style(1f), + bodySmall = style(0.9f), + titleLarge = style(1.3f, FontWeight.SemiBold), + titleMedium = style(1.15f, FontWeight.SemiBold), + titleSmall = style(1f, FontWeight.Medium), + headlineSmall = style(1.4f, FontWeight.SemiBold), + labelMedium = style(0.9f, FontWeight.Medium), + labelSmall = style(0.85f, FontWeight.Medium), + ) +} + +private fun getUiColor(key: String, fallback: AwtColor): AwtColor = UIManager.getColor(key) ?: fallback + +private fun AwtColor.toComposeColor(): Color = Color(red / 255f, green / 255f, blue / 255f, alpha / 255f) + +private fun JBColor.toComposeColor(): Color = Color(red / 255f, green / 255f, blue / 255f, alpha / 255f) diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt index 5df8b1e..8a09384 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -12,36 +12,57 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. - * */ package com.airsaid.localization.ui -import com.airsaid.localization.config.SettingsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import com.airsaid.localization.constant.Constants +import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.utils.LanguageUtil import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper -import com.intellij.ui.components.JBCheckBox -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBScrollPane -import com.intellij.util.ui.JBUI -import java.awt.Component -import java.awt.GridLayout -import java.awt.event.ItemEvent -import javax.swing.JCheckBox import javax.swing.JComponent -import javax.swing.JLabel -import javax.swing.JPanel -import javax.swing.BoxLayout /** - * Select the language dialog you want to Translate. - * - * @author airsaid + * Compose-driven dialog used to pick the languages that should be generated. */ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(project, false) { @@ -49,19 +70,19 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje fun onClickListener(selectedLanguage: List) } - private val contentPanel = JPanel() - private val overwriteExistingStringCheckBox = JBCheckBox("Overwrite existing strings") - private val selectAllCheckBox = JBCheckBox("Select all") - private val languagesPanel = JPanel() - private val openTranslatedFileCheckBox = JBCheckBox("Open translated file after completion") - private val powerTranslatorLabel = JBLabel() + private val translatorService = TranslatorService.getInstance() + private val selectedLanguages = mutableStateListOf() + private val selectAllState = mutableStateOf(false) + private val overwriteExistingState = mutableStateOf(false) + private val openTranslatedFileState = mutableStateOf(false) private var onClickListener: OnClickListener? = null - private val selectedLanguages = mutableListOf() + + private lateinit var translator: AbstractTranslator + private lateinit var supportedLanguages: List init { - setupUi() - doCreateCenterPanel() + initState() title = "Select Translated Languages" init() } @@ -70,136 +91,283 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje onClickListener = listener } - override fun createCenterPanel(): JComponent? { - return contentPanel + override fun createCenterPanel(): JComponent { + val panel = ComposePanel() + panel.preferredSize = java.awt.Dimension(680, 560) + panel.setContent { + IdeTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + SelectLanguagesContent( + translator = translator, + supportedLanguages = supportedLanguages, + selectedLanguages = selectedLanguages, + selectAllStateChecked = selectAllState.value, + overwriteExistingChecked = overwriteExistingState.value, + openTranslatedFileChecked = openTranslatedFileState.value, + onSelectAllChanged = { handleSelectAll(it) }, + onOverwriteChanged = { checked -> + overwriteExistingState.value = checked + properties().setValue(Constants.KEY_IS_OVERWRITE_EXISTING_STRING, checked) + }, + onOpenTranslatedFileChanged = { checked -> + openTranslatedFileState.value = checked + properties().setValue(Constants.KEY_IS_OPEN_TRANSLATED_FILE, checked) + }, + onLanguageToggled = { lang, checked -> + if (checked) { + if (!selectedLanguages.contains(lang)) { + selectedLanguages.add(lang) + } + } else { + selectedLanguages.remove(lang) + } + + val allSelected = selectedLanguages.size == supportedLanguages.size && supportedLanguages.isNotEmpty() + if (selectAllState.value != allSelected) { + selectAllState.value = allSelected + properties().setValue(Constants.KEY_IS_SELECT_ALL, allSelected) + } + + okAction.isEnabled = selectedLanguages.isNotEmpty() + }, + ) + } + } + } + return panel } - private fun setupUi() { - contentPanel.layout = java.awt.BorderLayout(0, 12) - contentPanel.border = JBUI.Borders.empty(12) + override fun doOKAction() { + project?.let { LanguageUtil.saveSelectedLanguage(it, selectedLanguages) } + onClickListener?.onClickListener(selectedLanguages.toList()) + super.doOKAction() + } - languagesPanel.layout = GridLayout(0, 4, 12, 4) + override fun getDimensionServiceKey(): String? { + val key = translator.key + return "#com.airsaid.localization.ui.SelectLanguagesDialog#$key" + } - val scrollPane = JBScrollPane(languagesPanel) - scrollPane.border = JBUI.Borders.empty() - contentPanel.add(scrollPane, java.awt.BorderLayout.CENTER) + private fun initState() { + val properties = properties() + translator = translatorService.getSelectedTranslator() ?: error("Translator is not available") + supportedLanguages = translator.supportedLanguages.sortedBy { it.englishName } - val optionsPanel = JPanel() - optionsPanel.layout = BoxLayout(optionsPanel, BoxLayout.Y_AXIS) - optionsPanel.border = JBUI.Borders.emptyTop(8) + val savedLanguageIds = LanguageUtil.getSelectedLanguageIds(project) + selectedLanguages.clear() + if (!savedLanguageIds.isNullOrEmpty()) { + selectedLanguages.addAll(supportedLanguages.filter { savedLanguageIds.contains(it.id.toString()) }) + } - listOf(selectAllCheckBox, overwriteExistingStringCheckBox, openTranslatedFileCheckBox, powerTranslatorLabel).forEach { - it.alignmentX = Component.LEFT_ALIGNMENT - optionsPanel.add(it) + selectAllState.value = properties.getBoolean(Constants.KEY_IS_SELECT_ALL) + if (selectAllState.value) { + selectedLanguages.clear() + selectedLanguages.addAll(supportedLanguages) } - contentPanel.add(optionsPanel, java.awt.BorderLayout.SOUTH) - } + overwriteExistingState.value = properties.getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING) + openTranslatedFileState.value = properties.getBoolean(Constants.KEY_IS_OPEN_TRANSLATED_FILE) - private fun doCreateCenterPanel() { - // add languages - selectedLanguages.clear() - val supportedLanguages = TranslatorService.getInstance().getSelectedTranslator()!!.supportedLanguages - val sortedLanguages = supportedLanguages.toMutableList() - sortedLanguages.sortWith(EnglishNameComparator()) // sort by english name, easy to find - languagesPanel.removeAll() - addLanguageList(sortedLanguages) - - // add options - initOverwriteExistingStringOption() - initOpenTranslatedFileCheckBox() - initSelectAllOption() - - // set power ui - val translator = TranslatorService.getInstance().getSelectedTranslator()!! - powerTranslatorLabel.text = "Powered by ${translator.name}" - powerTranslatorLabel.icon = translator.icon + okAction.isEnabled = selectedLanguages.isNotEmpty() } - private fun addLanguageList(supportedLanguages: List) { - val selectedLanguageIds = LanguageUtil.getSelectedLanguageIds(project) - for (language in supportedLanguages) { - val code = language.code - val checkBoxLanguage = JBCheckBox() - checkBoxLanguage.text = "${language.englishName}($code)" - languagesPanel.add(checkBoxLanguage) - checkBoxLanguage.addItemListener { e -> - val state = e.stateChange - if (state == ItemEvent.SELECTED) { - if (!selectedLanguages.contains(language)) { - selectedLanguages.add(language) - } - } else { - selectedLanguages.remove(language) - } - // Update the OK button UI - okAction.isEnabled = selectedLanguages.size > 0 - } - if (selectedLanguageIds?.contains(language.id.toString()) == true) { - checkBoxLanguage.isSelected = true - } + private fun handleSelectAll(checked: Boolean) { + selectAllState.value = checked + properties().setValue(Constants.KEY_IS_SELECT_ALL, checked) + if (checked) { + selectedLanguages.clear() + selectedLanguages.addAll(supportedLanguages) + } else { + selectedLanguages.clear() } - languagesPanel.revalidate() - languagesPanel.repaint() okAction.isEnabled = selectedLanguages.isNotEmpty() } - private fun initOverwriteExistingStringOption() { - val isOverwriteExistingString = PropertiesComponent.getInstance(project!!) - .getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING) - overwriteExistingStringCheckBox.isSelected = isOverwriteExistingString - overwriteExistingStringCheckBox.addItemListener { e -> - val state = e.stateChange - PropertiesComponent.getInstance(project!!) - .setValue(Constants.KEY_IS_OVERWRITE_EXISTING_STRING, state == ItemEvent.SELECTED) - } +private fun properties(): PropertiesComponent { + return if (project != null) PropertiesComponent.getInstance(project) else PropertiesComponent.getInstance() } +} - private fun initOpenTranslatedFileCheckBox() { - val isOpenTranslatedFile = PropertiesComponent.getInstance(project!!) - .getBoolean(Constants.KEY_IS_OPEN_TRANSLATED_FILE) - openTranslatedFileCheckBox.isSelected = isOpenTranslatedFile - openTranslatedFileCheckBox.addItemListener { e -> - val state = e.stateChange - PropertiesComponent.getInstance(project!!) - .setValue(Constants.KEY_IS_OPEN_TRANSLATED_FILE, state == ItemEvent.SELECTED) - } +@Composable +private fun SelectLanguagesContent( + translator: AbstractTranslator, + supportedLanguages: List, + selectedLanguages: SnapshotStateList, + selectAllStateChecked: Boolean, + overwriteExistingChecked: Boolean, + openTranslatedFileChecked: Boolean, + onSelectAllChanged: (Boolean) -> Unit, + onOverwriteChanged: (Boolean) -> Unit, + onOpenTranslatedFileChanged: (Boolean) -> Unit, + onLanguageToggled: (Lang, Boolean) -> Unit, +) { + var filterText by rememberSaveable { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + Text( + text = "${translator.name} Translator", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + LanguagesCard( + filterText = filterText, + onFilterChange = { filterText = it }, + allLanguages = supportedLanguages, + selectedLanguages = selectedLanguages, + selectAll = selectAllStateChecked, + overwriteExisting = overwriteExistingChecked, + openTranslatedFile = openTranslatedFileChecked, + onSelectAllChanged = onSelectAllChanged, + onOverwriteChanged = onOverwriteChanged, + onOpenTranslatedFileChanged = onOpenTranslatedFileChanged, + onLanguageToggled = onLanguageToggled, + ) } +} - private fun initSelectAllOption() { - val isSelectAll = PropertiesComponent.getInstance(project!!) - .getBoolean(Constants.KEY_IS_SELECT_ALL) - selectAllCheckBox.isSelected = isSelectAll - selectAllCheckBox.addItemListener { e -> - val state = e.stateChange - selectAll(state == ItemEvent.SELECTED) - PropertiesComponent.getInstance(project!!) - .setValue(Constants.KEY_IS_SELECT_ALL, state == ItemEvent.SELECTED) +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +private fun LanguagesCard( + filterText: String, + onFilterChange: (String) -> Unit, + allLanguages: List, + selectedLanguages: SnapshotStateList, + selectAll: Boolean, + overwriteExisting: Boolean, + openTranslatedFile: Boolean, + onSelectAllChanged: (Boolean) -> Unit, + onOverwriteChanged: (Boolean) -> Unit, + onOpenTranslatedFileChanged: (Boolean) -> Unit, + onLanguageToggled: (Lang, Boolean) -> Unit, +) { + val filteredLanguages = remember(filterText, allLanguages) { + if (filterText.isBlank()) allLanguages + else allLanguages.filter { + it.englishName.contains(filterText, ignoreCase = true) || + it.code.contains(filterText, ignoreCase = true) } } - private fun selectAll(selectAll: Boolean) { - for (component in languagesPanel.components) { - if (component is JBCheckBox) { - component.isSelected = selectAll + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + shape = RoundedCornerShape(12.dp), + tonalElevation = 0.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + value = filterText, + onValueChange = onFilterChange, + label = { Text("Filter languages") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = selectAll, + onClick = { onSelectAllChanged(!selectAll) }, + label = { Text("Select all") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + ) + FilterChip( + selected = overwriteExisting, + onClick = { onOverwriteChanged(!overwriteExisting) }, + label = { Text("Overwrite existing") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + ) + FilterChip( + selected = openTranslatedFile, + onClick = { onOpenTranslatedFileChanged(!openTranslatedFile) }, + label = { Text("Open translated file") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + ) } - } - } - override fun getDimensionServiceKey(): String? { - val key = SettingsState.getInstance().selectedTranslator.key - return "#com.airsaid.localization.ui.SelectLanguagesDialog#$key" - } + Text( + text = "Languages (${filteredLanguages.size}/${allLanguages.size})", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) - override fun doOKAction() { - LanguageUtil.saveSelectedLanguage(project!!, selectedLanguages) - onClickListener?.onClickListener(selectedLanguages) - super.doOKAction() + LanguagesGrid( + languages = filteredLanguages, + selectedLanguages = selectedLanguages, + onLanguageToggled = onLanguageToggled, + ) + } } +} - class EnglishNameComparator : Comparator { - override fun compare(o1: Lang, o2: Lang): Int { - return o1.englishName.compareTo(o2.englishName) +@Composable +private fun LanguagesGrid( + languages: List, + selectedLanguages: SnapshotStateList, + onLanguageToggled: (Lang, Boolean) -> Unit, +) { + if (languages.isEmpty()) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text(text = "No languages match your filter", style = MaterialTheme.typography.bodyMedium) + } + } else { + val columns = if (languages.size < 10) GridCells.Fixed(2) else GridCells.Adaptive(180.dp) + LazyVerticalGrid( + columns = columns, + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + items(languages, key = { it.id }) { language -> + val isSelected = language in selectedLanguages + FilterChip( + selected = isSelected, + onClick = { onLanguageToggled(language, !isSelected) }, + shape = RoundedCornerShape(12.dp), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + label = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(text = language.englishName, style = MaterialTheme.typography.bodyMedium) + Text( + text = language.code.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + ) + } } } } diff --git a/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt index 1dfb1f2..8d89a96 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt @@ -12,38 +12,53 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. - * */ package com.airsaid.localization.ui +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.unit.dp import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.lang.Lang import com.intellij.openapi.ui.DialogWrapper -import com.intellij.ui.components.JBLabel -import java.awt.GridLayout import javax.swing.Action import javax.swing.JComponent -import javax.swing.JPanel -/** - * @author airsaid - */ class SupportLanguagesDialog(private val translator: AbstractTranslator) : DialogWrapper(true) { + private val supportedLanguages: List = translator.supportedLanguages.sortedBy { it.englishName } + init { title = "${translator.name} Translator Supported Languages" init() } - override fun createCenterPanel(): JComponent? { - val supportedLanguages = translator.supportedLanguages.toMutableList() - supportedLanguages.sortWith(EnglishNameComparator()) - val contentPanel = JPanel(GridLayout(supportedLanguages.size / 4, 4, 10, 20)) - for (supportedLanguage in supportedLanguages) { - contentPanel.add(JBLabel(supportedLanguage.englishName)) + override fun createCenterPanel(): JComponent { + val panel = ComposePanel() + panel.preferredSize = java.awt.Dimension(520, 420) + panel.setContent { + IdeTheme { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.background, + ) { + SupportLanguagesContent(languages = supportedLanguages) + } + } } - return contentPanel + return panel } override fun getDimensionServiceKey(): String? { @@ -51,13 +66,34 @@ class SupportLanguagesDialog(private val translator: AbstractTranslator) : Dialo return "#com.airsaid.localization.ui.SupportLanguagesDialog#$key" } - override fun createActions(): Array { - return emptyArray() - } + override fun createActions(): Array = emptyArray() +} - class EnglishNameComparator : Comparator { - override fun compare(o1: Lang, o2: Lang): Int { - return o1.englishName.compareTo(o2.englishName) +@Composable +private fun SupportLanguagesContent(languages: List) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + shape = RoundedCornerShape(12.dp), + tonalElevation = 1.dp, + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(languages, key = { it.id }) { language -> + Column(modifier = Modifier.fillMaxWidth()) { + Text(text = language.englishName, style = MaterialTheme.typography.bodyMedium) + Text( + text = language.code, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } } -} \ No newline at end of file +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 8d448a4..72d9922 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -7,6 +7,7 @@ @@ -23,4 +24,4 @@ - \ No newline at end of file + diff --git a/src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt b/src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt new file mode 100644 index 0000000..9588895 --- /dev/null +++ b/src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt @@ -0,0 +1,66 @@ +package com.airsaid.localization.config + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.lang.Lang +import com.intellij.openapi.application.ApplicationManager +import com.intellij.testFramework.junit5.TestApplication +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +@TestApplication +class SettingsComponentTest { + + @Test + fun `loads credentials via loader without touching password safe on EDT`() { + val loader = RecordingCredentialsLoader() + + ApplicationManager.getApplication().invokeAndWait { + val component = SettingsComponent(loader) + component.setTranslators(mapOf(TEST_TRANSLATOR.key to TEST_TRANSLATOR)) + component.setSelectedTranslator(TEST_TRANSLATOR) + + assertTrue(loader.loadCalledOnEdt, "Credentials loader should be triggered on EDT") + + loader.complete("test-app-id", "test-app-key") + + assertEquals("test-app-id", component.appId) + assertEquals("test-app-key", component.appKey) + } + } + + private class RecordingCredentialsLoader : TranslatorCredentialsLoader { + @Volatile + var loadCalledOnEdt: Boolean = false + private var callback: ((String, String) -> Unit)? = null + + override fun load(translator: AbstractTranslator, onLoaded: (String, String) -> Unit) { + loadCalledOnEdt = ApplicationManager.getApplication().isDispatchThread + callback = onLoaded + } + + fun complete(appId: String, appKey: String) { + ApplicationManager.getApplication().invokeAndWait { + callback?.invoke(appId, appKey) + } + } + } + + private companion object { + val TEST_TRANSLATOR: AbstractTranslator = object : AbstractTranslator() { + override val key: String = "test" + override val name: String = "Test" + override val supportedLanguages: List = emptyList() + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = + throw UnsupportedOperationException() + + override fun parsingResult( + fromLang: Lang, + toLang: Lang, + text: String, + resultText: String, + ): String = throw UnsupportedOperationException() + } + } +} From f3d35cb5245d5e0a8fa1f9237d5ebabb1424dfe1 Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Sat, 20 Sep 2025 19:20:22 +0800 Subject: [PATCH 18/58] Add flag emojis to language selectors --- .../translate/lang/LanguageFlags.kt | 155 ++++++++++++++++++ .../localization/ui/SelectLanguagesDialog.kt | 11 +- .../localization/ui/SupportLanguagesDialog.kt | 16 +- 3 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/airsaid/localization/translate/lang/LanguageFlags.kt diff --git a/src/main/kotlin/com/airsaid/localization/translate/lang/LanguageFlags.kt b/src/main/kotlin/com/airsaid/localization/translate/lang/LanguageFlags.kt new file mode 100644 index 0000000..0ea9b20 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/lang/LanguageFlags.kt @@ -0,0 +1,155 @@ +package com.airsaid.localization.translate.lang + +import java.util.Locale + +private val LANGUAGE_FLAG_OVERRIDES: Map = mapOf( + "sq" to "\uD83C\uDDE6\uD83C\uDDF1", // 🇦🇱 Albania + "ar" to "\uD83C\uDDF8\uD83C\uDDE6", // 🇸🇦 Saudi Arabia + "am" to "\uD83C\uDDEA\uD83C\uDDF9", // 🇪🇹 Ethiopia + "az" to "\uD83C\uDDE6\uD83C\uDDFF", // 🇦🇿 Azerbaijan + "ga" to "\uD83C\uDDEE\uD83C\uDDEA", // 🇮🇪 Ireland + "et" to "\uD83C\uDDEA\uD83C\uDDEA", // 🇪🇪 Estonia + "eu" to "\uD83C\uDDEA\uD83C\uDDF8", // 🇪🇸 Spain (Basque Country) + "be" to "\uD83C\uDDE7\uD83C\uDDFE", // 🇧🇾 Belarus + "bg" to "\uD83C\uDDE7\uD83C\uDDEC", // 🇧🇬 Bulgaria + "is" to "\uD83C\uDDEE\uD83C\uDDF8", // 🇮🇸 Iceland + "pl" to "\uD83C\uDDF5\uD83C\uDDF1", // 🇵🇱 Poland + "bs" to "\uD83C\uDDE7\uD83C\uDDE6", // 🇧🇦 Bosnia and Herzegovina + "fa" to "\uD83C\uDDEE\uD83C\uDDF7", // 🇮🇷 Iran + "af" to "\uD83C\uDDFF\uD83C\uDDE6", // 🇿🇦 South Africa + "da" to "\uD83C\uDDE9\uD83C\uDDF0", // 🇩🇰 Denmark + "de" to "\uD83C\uDDE9\uD83C\uDDEA", // 🇩🇪 Germany + "ru" to "\uD83C\uDDF7\uD83C\uDDFA", // 🇷🇺 Russia + "fr" to "\uD83C\uDDEB\uD83C\uDDF7", // 🇫🇷 France + "fil" to "\uD83C\uDDF5\uD83C\uDDED", // 🇵🇭 Philippines + "fi" to "\uD83C\uDDEB\uD83C\uDDEE", // 🇫🇮 Finland + "fy" to "\uD83C\uDDF3\uD83C\uDDF1", // 🇳🇱 Netherlands + "km" to "\uD83C\uDDF0\uD83C\uDDED", // 🇰🇭 Cambodia + "ka" to "\uD83C\uDDEC\uD83C\uDDEA", // 🇬🇪 Georgia + "gu" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Gujarati) + "kk" to "\uD83C\uDDF0\uD83C\uDDFF", // 🇰🇿 Kazakhstan + "ht" to "\uD83C\uDDED\uD83C\uDDF9", // 🇭🇹 Haiti + "ko" to "\uD83C\uDDF0\uD83C\uDDF7", // 🇰🇷 South Korea + "ha" to "\uD83C\uDDF3\uD83C\uDDEC", // 🇳🇬 Nigeria + "nl" to "\uD83C\uDDF3\uD83C\uDDF1", // 🇳🇱 Netherlands + "ky" to "\uD83C\uDDF0\uD83C\uDDEC", // 🇰🇬 Kyrgyzstan + "gl" to "\uD83C\uDDEA\uD83C\uDDF8", // 🇪🇸 Spain (Galicia) + "ca" to "\uD83C\uDDEA\uD83C\uDDF8", // 🇪🇸 Spain (Catalonia) + "cs" to "\uD83C\uDDE8\uD83C\uDDFF", // 🇨🇿 Czech Republic + "kn" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Kannada) + "co" to "\uD83C\uDDEB\uD83C\uDDF7", // 🇫🇷 France (Corsica) + "hr" to "\uD83C\uDDED\uD83C\uDDF7", // 🇭🇷 Croatia + "ku" to "\uD83C\uDDEE\uD83C\uDDEC", // 🇮🇶 Iraq (Kurdish) + "la" to "\uD83C\uDDFB\uD83C\uDDE6", // 🇻🇦 Vatican City + "lv" to "\uD83C\uDDF1\uD83C\uDDFB", // 🇱🇻 Latvia + "lo" to "\uD83C\uDDF1\uD83C\uDDE6", // 🇱🇦 Laos + "lt" to "\uD83C\uDDF1\uD83C\uDDF9", // 🇱🇹 Lithuania + "lb" to "\uD83C\uDDF1\uD83C\uDDEA", // 🇱🇺 Luxembourg + "ro" to "\uD83C\uDDF7\uD83C\uDDF4", // 🇷🇴 Romania + "mg" to "\uD83C\uDDF2\uD83C\uDDEC", // 🇲🇬 Madagascar + "mt" to "\uD83C\uDDF2\uD83C\uDDF9", // 🇲🇹 Malta + "mr" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Marathi) + "ml" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Malayalam) + "ms" to "\uD83C\uDDF2\uD83C\uDDFE", // 🇲🇾 Malaysia + "mk" to "\uD83C\uDDF2\uD83C\uDDF0", // 🇲🇰 North Macedonia + "mi" to "\uD83C\uDDF3\uD83C\uDDFF", // 🇳🇿 New Zealand (Maori) + "mn" to "\uD83C\uDDF2\uD83C\uDDF3", // 🇲🇳 Mongolia + "bn" to "\uD83C\uDDFA\uD83C\uDDEC", // 🇧🇩 Bangladesh + "my" to "\uD83C\uDDF2\uD83C\uDDF2", // 🇲🇲 Myanmar + "hmn" to "\uD83C\uDDE8\uD83C\uDDF3", // 🇨🇳 China (Hmong) + "xh" to "\uD83C\uDDFF\uD83C\uDDE6", // 🇿🇦 South Africa + "zu" to "\uD83C\uDDFF\uD83C\uDDE6", // 🇿🇦 South Africa + "ne" to "\uD83C\uDDF3\uD83C\uDDF5", // 🇳🇵 Nepal + "no" to "\uD83C\uDDF3\uD83C\uDDF4", // 🇳🇴 Norway + "pa" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Punjabi) + "pt" to "\uD83C\uDDF5\uD83C\uDDF9", // 🇵🇹 Portugal + "ps" to "\uD83C\uDDE6\uD83C\uDDEB", // 🇦🇫 Afghanistan + "ny" to "\uD83C\uDDF2\uD83C\uDDFC", // 🇲🇼 Malawi + "ja" to "\uD83C\uDDEF\uD83C\uDDF5", // 🇯🇵 Japan + "sv" to "\uD83C\uDDF8\uD83C\uDDEA", // 🇸🇪 Sweden + "sm" to "\uD83C\uDDFC\uD83C\uDDF8", // 🇼🇸 Samoa + "sr" to "\uD83C\uDDF7\uD83C\uDDF8", // 🇷🇸 Serbia + "st" to "\uD83C\uDDF1\uD83C\uDDF8", // 🇱🇸 Lesotho + "si" to "\uD83C\uDDF1\uD83C\uDDF0", // 🇱🇰 Sri Lanka + "eo" to "\uD83C\uDDFA\uD83C\uDDF3", // 🇺🇳 United Nations flag approximation + "sk" to "\uD83C\uDDF8\uD83C\uDDF0", // 🇸🇰 Slovakia + "sl" to "\uD83C\uDDF8\uD83C\uDDEE", // 🇸🇮 Slovenia + "sw" to "\uD83C\uDDF9\uD83C\uDDF5", // 🇹🇿 Tanzania + "gd" to "\uD83C\uDDEC\uD83C\uDDE7", // 🇬🇧 United Kingdom (Scottish Gaelic) + "ceb" to "\uD83C\uDDF5\uD83C\uDDED", // 🇵🇭 Philippines + "so" to "\uD83C\uDDF8\uD83C\uDDF4", // 🇸🇴 Somalia + "tg" to "\uD83C\uDDF9\uD83C\uDDEF", // 🇹🇯 Tajikistan + "te" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Telugu) + "ta" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Tamil) + "th" to "\uD83C\uDDF9\uD83C\uDDED", // 🇹🇭 Thailand + "tr" to "\uD83C\uDDF9\uD83C\uDDF7", // 🇹🇷 Turkey + "cy" to "\uD83C\uDDEC\uD83C\uDDE7", // 🇬🇧 United Kingdom (Welsh) + "ur" to "\uD83C\uDDF5\uD83C\uDDF0", // 🇵🇰 Pakistan + "uk" to "\uD83C\uDDFA\uD83C\uDDE6", // 🇺🇦 Ukraine + "uz" to "\uD83C\uDDFA\uD83C\uDDFF", // 🇺🇿 Uzbekistan + "es" to "\uD83C\uDDEA\uD83C\uDDF8", // 🇪🇸 Spain + "iw" to "\uD83C\uDDEE\uD83C\uDDF1", // 🇮🇱 Israel + "el" to "\uD83C\uDDEC\uD83C\uDDF7", // 🇬🇷 Greece + "haw" to "\uD83C\uDDFA\uD83C\uDDF8", // 🇺🇸 United States (Hawaii) + "sd" to "\uD83C\uDDF5\uD83C\uDDF0", // 🇵🇰 Pakistan (Sindhi) + "hu" to "\uD83C\uDDED\uD83C\uDDFA", // 🇭🇺 Hungary + "sn" to "\uD83C\uDDFF\uD83C\uDDFC", // 🇿🇼 Zimbabwe + "hy" to "\uD83C\uDDE6\uD83C\uDDF2", // 🇦🇲 Armenia + "ig" to "\uD83C\uDDF3\uD83C\uDDEC", // 🇳🇬 Nigeria + "it" to "\uD83C\uDDEE\uD83C\uDDF9", // 🇮🇹 Italy + "yi" to "\uD83C\uDDEE\uD83C\uDDF1", // 🇮🇱 Israel + "hi" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India + "su" to "\uD83C\uDDEE\uD83C\uDDE9", // 🇮🇩 Indonesia + "jv" to "\uD83C\uDDEE\uD83C\uDDE9", // 🇮🇩 Indonesia + "en" to "\uD83C\uDDFA\uD83C\uDDF8", // 🇺🇸 United States + "yo" to "\uD83C\uDDF3\uD83C\uDDEC", // 🇳🇬 Nigeria + "vi" to "\uD83C\uDDFB\uD83C\uDDF3", // 🇻🇳 Vietnam + "as" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Assamese) + "prs" to "\uD83C\uDDE6\uD83C\uDDEB", // 🇦🇫 Afghanistan (Dari) + "fj" to "\uD83C\uDDEB\uD83C\uDDEF", // 🇫🇯 Fiji + "mww" to "\uD83C\uDDE8\uD83C\uDDF3", // 🇨🇳 China (Hmong Daw) + "iu" to "\uD83C\uDDE8\uD83C\uDDE6", // 🇨🇦 Canada (Inuktitut) + "or" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Odia) + "otq" to "\uD83C\uDDF2\uD83C\uDDFD", // 🇲🇽 Mexico (Querétaro Otomi) + "ty" to "\uD83C\uDDF5\uD83C\uDDEB", // 🇵🇫 French Polynesia (Tahitian) + "ti" to "\uD83C\uDDEA\uD83C\uDDF7", // 🇪🇷 Eritrea (Tigrinya) + "to" to "\uD83C\uDDF9\uD83C\uDDF4", // 🇹🇴 Tonga + "yua" to "\uD83C\uDDF2\uD83C\uDDFD" // 🇲🇽 Mexico (Yucatec Maya) +) + +private val REGION_CODE_PATTERN = Regex("(?:-|_)(?:r)?([A-Za-z]{2})$") + +val Lang.flagEmoji: String? + get() { + extractRegion(code)?.let { region -> + regionToFlag(region)?.let { return it } + } + + val translation = translationCode + extractRegion(translation)?.let { region -> + regionToFlag(region)?.let { return it } + } + + val normalizedCode = code.lowercase(Locale.US) + LANGUAGE_FLAG_OVERRIDES[normalizedCode]?.let { return it } + + val normalizedTranslation = translation.lowercase(Locale.US) + LANGUAGE_FLAG_OVERRIDES[normalizedTranslation]?.let { return it } + + return null + } + +private fun extractRegion(value: String?): String? { + if (value.isNullOrBlank()) return null + val match = REGION_CODE_PATTERN.find(value) + return match?.groupValues?.getOrNull(1)?.uppercase(Locale.US) +} + +private fun regionToFlag(region: String): String? { + val code = region.uppercase(Locale.US) + if (code.length != 2 || code.any { it !in 'A'..'Z' }) return null + return buildString { + append(Character.toChars(0x1F1E6 + (code[0].code - 'A'.code))) + append(Character.toChars(0x1F1E6 + (code[1].code - 'A'.code))) + } +} diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt index 8a09384..697cd03 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -22,7 +22,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn @@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp import com.airsaid.localization.constant.Constants import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.flagEmoji import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.utils.LanguageUtil import com.intellij.ide.util.PropertiesComponent @@ -358,7 +359,13 @@ private fun LanguagesGrid( ), label = { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(text = language.englishName, style = MaterialTheme.typography.bodyMedium) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + val flag = language.flagEmoji + if (flag != null) { + Text(text = flag, style = MaterialTheme.typography.bodyMedium) + } + Text(text = language.englishName, style = MaterialTheme.typography.bodyMedium) + } Text( text = language.code.uppercase(), style = MaterialTheme.typography.labelSmall, diff --git a/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt index 8d89a96..cd668db 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt @@ -18,11 +18,13 @@ package com.airsaid.localization.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Alignment import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -32,6 +34,7 @@ import androidx.compose.ui.awt.ComposePanel import androidx.compose.ui.unit.dp import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.flagEmoji import com.intellij.openapi.ui.DialogWrapper import javax.swing.Action import javax.swing.JComponent @@ -86,9 +89,18 @@ private fun SupportLanguagesContent(languages: List) { ) { items(languages, key = { it.id }) { language -> Column(modifier = Modifier.fillMaxWidth()) { - Text(text = language.englishName, style = MaterialTheme.typography.bodyMedium) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val flag = language.flagEmoji + if (flag != null) { + Text(text = flag, style = MaterialTheme.typography.bodyMedium) + } + Text(text = language.englishName, style = MaterialTheme.typography.bodyMedium) + } Text( - text = language.code, + text = language.code.uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) From e902b1494ef6da701331d941c29bd269e5bb6940 Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Sat, 20 Sep 2025 19:36:06 +0800 Subject: [PATCH 19/58] Create AGENTS.md --- AGENTS.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e51df58 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Repository Guidelines + +## Project Structure & Module Organization +Core plugin code lives in `src/main/kotlin/com/airsaid/localization`, split by feature (actions, translate services, Compose UI, utilities). IDE registrations and icons reside in `src/main/resources/META-INF/plugin.xml` and `src/main/resources/icons`. Tests mirror production packages in `src/test/kotlin`. Screenshots and demos stay in `preview/`. Build inputs sit at the root (`build.gradle.kts`, `settings.gradle.kts`, `gradle/`, `gradle.properties`, `qodana.yml`, `codecov.yml`)—update them together when changing tooling or release metadata. + +## Build, Test & Development Commands +- `./gradlew buildPlugin` – produce the distributable ZIP in `build/distributions/`. +- `./gradlew check` – execute unit tests, static checks, and coverage verification. +- `./gradlew runIde` – start a sandbox IDE with the plugin; use `./gradlew runIdeForUiTests` for Robot scenarios. +- `./gradlew qodanaScan` – run JetBrains Qodana and review results under `build/reports/qodana/`. +- `./gradlew publishPlugin` – push to Marketplace (requires `CERTIFICATE_CHAIN`, `PRIVATE_KEY`, `PRIVATE_KEY_PASSWORD`, `PUBLISH_TOKEN`). + +## Coding Style & Naming Conventions +Follow JetBrains Kotlin style: four-space indentation, trailing commas in multiline calls, `UpperCamelCase` classes, `lowerCamelCase` members, `SCREAMING_SNAKE_CASE` constants. Compose UI components belong under `ui/` and should keep state hoisted for previewability. Translator implementations stay in `translate/impl/` with provider-specific config surfaced through `config/`. Keep resource bundle keys descriptive (`language.selector.title`) and mirror Android string identifiers when bridging. + +## Testing Guidelines +JUnit 5 powers tests; suffix files with `Test` and place them in matching packages under `src/test/kotlin`. Translator suites should extend `AbstractTranslatorNetworkTest` with mocked HTTP clients to avoid external calls. Run `./gradlew test` before pushing and generate coverage with `./gradlew koverHtmlReport` when altering translation flows or caching. Refresh fixtures in `src/test/resources` if request/response formats change. + +## Commit & Pull Request Guidelines +Write imperative, concise commit subjects (e.g., “Add flag emojis to language selectors”) and expand on breaking changes or migrations in the body. Every PR should describe the motivation, user impact, linked issues, and attach screenshots or recordings for UI tweaks. Confirm `./gradlew check` (and `qodanaScan` when touching inspection-sensitive code) before review. Coordinate version bumps via `gradle.properties` and document changes in `CHANGELOG.md`. + +## Security & Configuration Tips +Keep translation service credentials out of Git; configure them through IDE settings or environment variables. Publishing secrets belong in local key stores or CI secrets, never in commits. Double-check `plugin.xml` edits, since misconfigured actions or services can prevent IDE startup. From a84af05e20579ade69cc2dbc155f5d7b27f9b1db Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Sat, 20 Sep 2025 21:50:38 +0800 Subject: [PATCH 20/58] Refresh language selection dialog layout --- .../localization/ui/SelectLanguagesDialog.kt | 380 ++++++++++++++---- 1 file changed, 298 insertions(+), 82 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt index 697cd03..c1d3292 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -17,23 +17,35 @@ package com.airsaid.localization.ui import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.TooltipArea +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface @@ -49,8 +61,13 @@ import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.airsaid.localization.constant.Constants import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.lang.Lang @@ -60,7 +77,10 @@ import com.airsaid.localization.utils.LanguageUtil import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper +import java.awt.Dimension +import java.awt.Toolkit import javax.swing.JComponent +import kotlin.math.roundToInt /** * Compose-driven dialog used to pick the languages that should be generated. @@ -94,7 +114,9 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje override fun createCenterPanel(): JComponent { val panel = ComposePanel() - panel.preferredSize = java.awt.Dimension(680, 560) + val (preferredSize, minimumSize) = calculateDialogSize() + panel.preferredSize = preferredSize + panel.minimumSize = minimumSize panel.setContent { IdeTheme { Surface( @@ -111,11 +133,9 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje onSelectAllChanged = { handleSelectAll(it) }, onOverwriteChanged = { checked -> overwriteExistingState.value = checked - properties().setValue(Constants.KEY_IS_OVERWRITE_EXISTING_STRING, checked) }, onOpenTranslatedFileChanged = { checked -> openTranslatedFileState.value = checked - properties().setValue(Constants.KEY_IS_OPEN_TRANSLATED_FILE, checked) }, onLanguageToggled = { lang, checked -> if (checked) { @@ -129,7 +149,6 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje val allSelected = selectedLanguages.size == supportedLanguages.size && supportedLanguages.isNotEmpty() if (selectAllState.value != allSelected) { selectAllState.value = allSelected - properties().setValue(Constants.KEY_IS_SELECT_ALL, allSelected) } okAction.isEnabled = selectedLanguages.isNotEmpty() @@ -143,6 +162,9 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje override fun doOKAction() { project?.let { LanguageUtil.saveSelectedLanguage(it, selectedLanguages) } + properties().setValue(Constants.KEY_IS_SELECT_ALL, selectAllState.value) + properties().setValue(Constants.KEY_IS_OVERWRITE_EXISTING_STRING, overwriteExistingState.value) + properties().setValue(Constants.KEY_IS_OPEN_TRANSLATED_FILE, openTranslatedFileState.value) onClickListener?.onClickListener(selectedLanguages.toList()) super.doOKAction() } @@ -177,7 +199,6 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje private fun handleSelectAll(checked: Boolean) { selectAllState.value = checked - properties().setValue(Constants.KEY_IS_SELECT_ALL, checked) if (checked) { selectedLanguages.clear() selectedLanguages.addAll(supportedLanguages) @@ -187,9 +208,30 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje okAction.isEnabled = selectedLanguages.isNotEmpty() } -private fun properties(): PropertiesComponent { + private fun properties(): PropertiesComponent { return if (project != null) PropertiesComponent.getInstance(project) else PropertiesComponent.getInstance() } + + private fun calculateDialogSize(): Pair { + val screen = Toolkit.getDefaultToolkit().screenSize + val aspectRatio = 1.45 + val maxWidth = (screen.width * 0.85).roundToInt() + val minWidth = 900 + var width = (screen.width * 0.62).roundToInt().coerceIn(minWidth, maxWidth) + + val maxHeight = (screen.height * 0.8).roundToInt() + val minHeight = 620 + var height = (width / aspectRatio).roundToInt().coerceAtLeast(minHeight) + + if (height > maxHeight) { + height = maxHeight + width = (height * aspectRatio).roundToInt().coerceAtMost(maxWidth) + } + + val preferred = Dimension(width, height) + val minimum = Dimension(minWidth, minHeight) + return preferred to minimum + } } @Composable @@ -213,12 +255,6 @@ private fun SelectLanguagesContent( .padding(20.dp), verticalArrangement = Arrangement.spacedBy(18.dp) ) { - Text( - text = "${translator.name} Translator", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - LanguagesCard( filterText = filterText, onFilterChange = { filterText = it }, @@ -231,11 +267,14 @@ private fun SelectLanguagesContent( onOverwriteChanged = onOverwriteChanged, onOpenTranslatedFileChanged = onOpenTranslatedFileChanged, onLanguageToggled = onLanguageToggled, + modifier = Modifier.weight(1f, fill = true), ) + + TranslatorFooter(translator = translator) } } -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class) @Composable private fun LanguagesCard( filterText: String, @@ -249,6 +288,7 @@ private fun LanguagesCard( onOverwriteChanged: (Boolean) -> Unit, onOpenTranslatedFileChanged: (Boolean) -> Unit, onLanguageToggled: (Lang, Boolean) -> Unit, + modifier: Modifier = Modifier, ) { val filteredLanguages = remember(filterText, allLanguages) { if (filterText.isBlank()) allLanguages @@ -259,9 +299,9 @@ private fun LanguagesCard( } Surface( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .heightIn(max = 420.dp), + .heightIn(min = 260.dp), shape = RoundedCornerShape(12.dp), tonalElevation = 0.dp, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)), @@ -273,6 +313,13 @@ private fun LanguagesCard( .padding(18.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + OptionsSection( + overwriteExisting = overwriteExisting, + onOverwriteChanged = onOverwriteChanged, + openTranslatedFile = openTranslatedFile, + onOpenTranslatedFileChanged = onOpenTranslatedFileChanged, + ) + OutlinedTextField( value = filterText, onValueChange = onFilterChange, @@ -281,43 +328,11 @@ private fun LanguagesCard( modifier = Modifier.fillMaxWidth(), ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - FilterChip( - selected = selectAll, - onClick = { onSelectAllChanged(!selectAll) }, - label = { Text("Select all") }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - ) - FilterChip( - selected = overwriteExisting, - onClick = { onOverwriteChanged(!overwriteExisting) }, - label = { Text("Overwrite existing") }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - ) - FilterChip( - selected = openTranslatedFile, - onClick = { onOpenTranslatedFileChanged(!openTranslatedFile) }, - label = { Text("Open translated file") }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - ) - } - - Text( - text = "Languages (${filteredLanguages.size}/${allLanguages.size})", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + LanguagesHeader( + total = allLanguages.size, + selected = selectedLanguages.size, + selectAll = selectAll, + onSelectAllChanged = onSelectAllChanged, ) LanguagesGrid( @@ -329,6 +344,32 @@ private fun LanguagesCard( } } +@Composable +private fun LanguagesHeader( + total: Int, + selected: Int, + selectAll: Boolean, + onSelectAllChanged: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Languages ($selected/$total)", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + OptionItem( + text = "Select all", + tooltip = "Select every supported language.", + checked = selectAll, + onCheckedChange = onSelectAllChanged, + ) + } +} + @Composable private fun LanguagesGrid( languages: List, @@ -340,41 +381,216 @@ private fun LanguagesGrid( Text(text = "No languages match your filter", style = MaterialTheme.typography.bodyMedium) } } else { - val columns = if (languages.size < 10) GridCells.Fixed(2) else GridCells.Adaptive(180.dp) LazyVerticalGrid( - columns = columns, + columns = GridCells.Fixed(4), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxSize() ) { items(languages, key = { it.id }) { language -> - val isSelected = language in selectedLanguages - FilterChip( - selected = isSelected, - onClick = { onLanguageToggled(language, !isSelected) }, - shape = RoundedCornerShape(12.dp), - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - label = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - val flag = language.flagEmoji - if (flag != null) { - Text(text = flag, style = MaterialTheme.typography.bodyMedium) - } - Text(text = language.englishName, style = MaterialTheme.typography.bodyMedium) - } - Text( - text = language.code.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, + LanguageOption( + language = language, + isSelected = language in selectedLanguages, + onToggle = { checked -> onLanguageToggled(language, checked) }, ) } } } } + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun OptionsSection( + overwriteExisting: Boolean, + onOverwriteChanged: (Boolean) -> Unit, + openTranslatedFile: Boolean, + onOpenTranslatedFileChanged: (Boolean) -> Unit, +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OptionItem( + text = "Overwrite existing", + tooltip = "Replace existing strings when a translation already exists.", + checked = overwriteExisting, + onCheckedChange = onOverwriteChanged, + ) + OptionItem( + text = "Open translated file", + tooltip = "Open the generated translation file after the task finishes.", + checked = openTranslatedFile, + onCheckedChange = onOpenTranslatedFileChanged, + ) + } +} + +@Composable +private fun OptionItem( + text: String, + tooltip: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.toggleable( + value = checked, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Checkbox, + onValueChange = onCheckedChange, + ) + ) { + IdeCheckbox(checked = checked) + Text(text = text, style = MaterialTheme.typography.bodyMedium) + TooltipIcon(text = tooltip) + } +} + +@Composable +private fun IdeCheckbox( + checked: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val shape = RoundedCornerShape(3.dp) + val colors = MaterialTheme.colorScheme + val backgroundColor = when { + !enabled -> colors.surface + checked -> colors.primary + else -> colors.surface + } + val borderColor = when { + !enabled -> colors.outline.copy(alpha = 0.3f) + checked -> colors.primary + else -> colors.outline.copy(alpha = 0.7f) + } + + Box( + modifier = modifier + .size(16.dp) + .border(1.dp, borderColor, shape) + .background(backgroundColor, shape), + contentAlignment = Alignment.Center, + ) { + if (checked) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = colors.onPrimary, + modifier = Modifier.size(10.dp), + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun TooltipIcon(text: String) { + TooltipArea( + tooltip = { + Surface( + shape = RoundedCornerShape(6.dp), + shadowElevation = 4.dp, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + delayMillis = 300, + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun LanguageOption( + language: Lang, + isSelected: Boolean, + onToggle: (Boolean) -> Unit, +) { + val flag = language.flagEmoji + val displayName = remember(language) { "${language.englishName} (${language.code.uppercase()})" } + val backgroundColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant + val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) + + Row( + modifier = Modifier + .defaultMinSize(minHeight = 64.dp) + .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(12.dp)) + .background(backgroundColor, RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + .toggleable( + value = isSelected, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Checkbox, + onValueChange = onToggle, + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IdeCheckbox(checked = isSelected) + if (flag != null) { + Text( + text = flag, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 20.sp), + ) + } + Text( + text = displayName, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp), + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun TranslatorFooter(translator: AbstractTranslator) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + TranslatorIcon(icon = translator.icon) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${translator.name} Translator", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun TranslatorIcon(icon: javax.swing.Icon?, modifier: Modifier = Modifier) { + if (icon == null) return + val imageBitmap = remember(icon) { icon.toImageBitmap() } + Image( + painter = remember(imageBitmap) { BitmapPainter(imageBitmap) }, + contentDescription = null, + modifier = modifier.size(20.dp), + ) +} + +private fun javax.swing.Icon.toImageBitmap(): ImageBitmap { + val image = java.awt.image.BufferedImage(iconWidth, iconHeight, java.awt.image.BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + graphics.background = java.awt.Color(0, 0, 0, 0) + paintIcon(null, graphics, 0, 0) + graphics.dispose() + return image.toComposeImageBitmap() +} From 073ed6f36372fb220674fea356d041da68f0efb2 Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Sun, 21 Sep 2025 12:10:51 +0800 Subject: [PATCH 21/58] Polish settings UI layout --- .../localization/config/SettingsComponent.kt | 481 ++++++++++++++---- 1 file changed, 377 insertions(+), 104 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt index 4d006b0..fc7c3b1 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt @@ -16,37 +16,56 @@ package com.airsaid.localization.config -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement as LayoutArrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposePanel +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.services.TranslatorService @@ -54,8 +73,13 @@ import com.airsaid.localization.ui.IdeTheme import com.airsaid.localization.ui.SupportLanguagesDialog import com.intellij.ide.BrowserUtil import com.intellij.openapi.diagnostic.Logger -import javax.swing.JComponent +import com.intellij.util.ui.UIUtil import java.awt.Dimension +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import javax.swing.Icon +import javax.swing.JComponent +import kotlin.math.max /** * Compose implementation of the settings panel exposed through the IDE Settings. @@ -79,24 +103,25 @@ class SettingsComponent( init { composePanel.preferredSize = Dimension(680, 560) + composePanel.isOpaque = true + composePanel.background = UIUtil.getPanelBackground() composePanel.setContent { IdeTheme { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - SettingsContent( - translators = translatorsState, - selectedTranslator = selectedTranslatorState.value, - appIdState = appIdState, - appKeyState = appKeyState, - enableCacheState = enableCacheState, - maxCacheSizeState = maxCacheSizeState, - translationIntervalState = translationIntervalState, - onTranslatorSelected = { translator -> - applySelectedTranslator(translator) - }, - onShowSupportedLanguages = { translator -> SupportLanguagesDialog(translator).show() }, - onNavigateToApplyPage = { url -> BrowserUtil.browse(url) } - ) - } + SettingsContent( + translators = translatorsState, + selectedTranslator = selectedTranslatorState.value, + defaultTranslatorKey = TranslatorService.getInstance().getDefaultTranslator()?.key, + appIdState = appIdState, + appKeyState = appKeyState, + enableCacheState = enableCacheState, + maxCacheSizeState = maxCacheSizeState, + translationIntervalState = translationIntervalState, + onTranslatorSelected = { translator -> + applySelectedTranslator(translator) + }, + onShowSupportedLanguages = { translator -> SupportLanguagesDialog(translator).show() }, + onNavigateToApplyPage = { url -> BrowserUtil.browse(url) } + ) } } } @@ -179,10 +204,16 @@ class SettingsComponent( } } +private val LabelColumnWidth = 176.dp +private val FieldMinWidth = 160.dp +private val CompactFieldHeight = 36.dp + +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun SettingsContent( translators: SnapshotStateList, selectedTranslator: AbstractTranslator?, + defaultTranslatorKey: String?, appIdState: androidx.compose.runtime.MutableState, appKeyState: androidx.compose.runtime.MutableState, enableCacheState: androidx.compose.runtime.MutableState, @@ -194,120 +225,362 @@ private fun SettingsContent( ) { val scrollState = rememberScrollState() - Surface( + Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), - shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), - tonalElevation = 2.dp, - shadowElevation = 1.dp, + .background(MaterialTheme.colorScheme.background) + .verticalScroll(scrollState) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = LayoutArrangement.spacedBy(20.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - Text( - text = "Translator", - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) + SectionHeader(title = "Translator") + + SettingsFormRow(label = "Provider") { + TranslatorDropdown( + modifier = Modifier + .weight(1f) + .heightIn(min = CompactFieldHeight), + translators = translators, + selectedTranslator = selectedTranslator, + defaultTranslatorKey = defaultTranslatorKey, + onTranslatorSelected = onTranslatorSelected ) - var dropdownExpanded by remember { mutableStateOf(false) } + selectedTranslator?.let { + TextButton(onClick = { onShowSupportedLanguages(it) }) { + Text("View supported languages") + } + } + } + + selectedTranslator?.let { provider -> + if (provider.isNeedAppId) { + SettingsFormRow(label = provider.appIdDisplay) { + IdeTextField( + modifier = Modifier + .fillMaxWidth() + .widthIn(min = FieldMinWidth), + value = appIdState.value, + onValueChange = { appIdState.value = it.trimStart() }, + singleLine = true + ) + } + } - Box { - OutlinedButton(onClick = { dropdownExpanded = true }) { - Text(selectedTranslator?.name ?: "Select translator") + if (provider.isNeedAppKey) { + SettingsFormRow(label = provider.appKeyDisplay) { + IdeTextField( + modifier = Modifier + .fillMaxWidth() + .widthIn(min = FieldMinWidth), + value = appKeyState.value, + onValueChange = { appKeyState.value = it.trimStart() }, + singleLine = true + ) } + } - DropdownMenu( - expanded = dropdownExpanded, - onDismissRequest = { dropdownExpanded = false }, - modifier = Modifier.widthIn(min = 240.dp) + provider.applyAppIdUrl?.takeUnless { it.isBlank() }?.let { url -> + SettingsFormRow( + label = "Need credentials?", + helperText = "Click to request keys from ${provider.name}." ) { - translators.forEach { translator -> - DropdownMenuItem( - text = { Text(translator.name) }, - onClick = { - dropdownExpanded = false - onTranslatorSelected(translator) - } - ) + TextButton(onClick = { onNavigateToApplyPage(url) }) { + Text("Apply for API key") } } } + } - val needsAppId = selectedTranslator?.isNeedAppId == true - val needsAppKey = selectedTranslator?.isNeedAppKey == true - - if (needsAppId) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = appIdState.value, - onValueChange = { appIdState.value = it }, - singleLine = true, - label = { Text(selectedTranslator.appIdDisplay) } - ) - } + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)) - if (needsAppKey) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = appKeyState.value, - onValueChange = { appKeyState.value = it }, - singleLine = true, - label = { Text(selectedTranslator.appKeyDisplay) } - ) - } + SectionHeader(title = "Caching") - if (!selectedTranslator?.applyAppIdUrl.isNullOrEmpty()) { - TextButton(onClick = { onNavigateToApplyPage(selectedTranslator.applyAppIdUrl!!) }) { - Text("Apply for ${selectedTranslator.name} API credentials") - } - } + SettingsFormRow(label = "Use cache") { + Switch( + checked = enableCacheState.value, + onCheckedChange = { enableCacheState.value = it } + ) + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = "Enable translation cache") - Switch( - checked = enableCacheState.value, - onCheckedChange = { enableCacheState.value = it }, - colors = SwitchDefaults.colors() - ) + SettingsFormRow( + label = "Max cache size", + helperText = if (enableCacheState.value) { + "Maximum cached translations before older ones are removed." + } else { + "Enable cache to edit the number of stored translations." } - - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), + ) { + IdeTextField( + modifier = Modifier + .widthIn(min = FieldMinWidth, max = 220.dp), value = maxCacheSizeState.value, onValueChange = { newValue -> val digits = newValue.filter { it.isDigit() } maxCacheSizeState.value = digits.ifEmpty { "0" } }, - label = { Text("Max cache size") }, - enabled = enableCacheState.value, - singleLine = true + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), + enabled = enableCacheState.value ) + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), + SectionHeader(title = "Requests") + + SettingsFormRow( + label = "Interval (seconds)", + helperText = "Delay between translation requests to avoid provider rate limits." + ) { + IdeTextField( + modifier = Modifier + .widthIn(min = FieldMinWidth, max = 220.dp), value = translationIntervalState.value, onValueChange = { newValue -> val digits = newValue.filter { it.isDigit() } translationIntervalState.value = digits.ifEmpty { "0" } }, - label = { Text("Translation interval (seconds)") }, - singleLine = true + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) ) + } + } +} - selectedTranslator?.let { - OutlinedButton(onClick = { onShowSupportedLanguages(it) }) { - Text("Supported languages") +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface + ) +} + +@Composable +private fun SettingsFormRow( + label: String, + helperText: String? = null, + content: @Composable RowScope.() -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .width(LabelColumnWidth) + .padding(end = 12.dp) + ) + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = LayoutArrangement.spacedBy(12.dp), + content = content + ) + } + + helperText?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = LabelColumnWidth + 12.dp, top = 4.dp) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun IdeTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + placeholder: (@Composable (() -> Unit))? = null, + leadingIcon: (@Composable (() -> Unit))? = null, + trailingIcon: (@Composable (() -> Unit))? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + singleLine: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f), + disabledBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + cursorColor = MaterialTheme.colorScheme.primary, + focusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .heightIn(min = CompactFieldHeight) + .defaultMinSize(minHeight = CompactFieldHeight), + enabled = enabled, + readOnly = readOnly, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), + singleLine = singleLine, + keyboardOptions = keyboardOptions, + interactionSource = interactionSource, + ) { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = value, + visualTransformation = VisualTransformation.None, + innerTextField = innerTextField, + placeholder = placeholder, + label = null, + prefix = null, + suffix = null, + supportingText = null, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + singleLine = singleLine, + enabled = enabled, + isError = false, + interactionSource = interactionSource, + colors = colors, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + container = { + OutlinedTextFieldDefaults.Container( + enabled = enabled, + isError = false, + interactionSource = interactionSource, + colors = colors + ) + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TranslatorDropdown( + modifier: Modifier = Modifier, + translators: List, + selectedTranslator: AbstractTranslator?, + defaultTranslatorKey: String?, + onTranslatorSelected: (AbstractTranslator) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val selectedPainter = selectedTranslator?.let { translatorIconPainter(it) } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + .heightIn(min = CompactFieldHeight), + value = selectedTranslator?.name.orEmpty(), + onValueChange = {}, + readOnly = true, + label = { Text("Translator") }, + placeholder = { Text("Select translator") }, + singleLine = true, + leadingIcon = { + selectedPainter?.let { + Icon( + painter = it, + contentDescription = selectedTranslator?.name, + modifier = Modifier.size(18.dp), + tint = Color.Unspecified + ) } + }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) } + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + translators.forEach { translator -> + val itemPainter = translatorIconPainter(translator) + val isDefault = translator.key == defaultTranslatorKey + + DropdownMenuItem( + text = { Text(translator.name) }, + leadingIcon = { + itemPainter?.let { + Icon( + painter = it, + contentDescription = translator.name, + modifier = Modifier.size(18.dp), + tint = Color.Unspecified + ) + } + }, + trailingIcon = { + if (isDefault) { + DefaultBadge() + } + }, + onClick = { + expanded = false + onTranslatorSelected(translator) + }, + modifier = Modifier.widthIn(min = 240.dp) + ) } } } } + +@Composable +private fun DefaultBadge() { + Text( + text = "Default", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier + .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) +} + +@Composable +private fun translatorIconPainter(translator: AbstractTranslator): Painter? { + val icon = translator.icon ?: return null + return remember(icon) { + val buffered = icon.toBufferedImageSafely() + BitmapPainter(buffered.toComposeImageBitmap()) + } +} + +private fun Icon.toBufferedImageSafely(): BufferedImage { + val width = max(1, iconWidth) + val height = max(1, iconHeight) + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC) + paintIcon(null, graphics, 0, 0) + graphics.dispose() + return image +} From b4184f08b466f4239981f46f066917c522179c19 Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Sun, 21 Sep 2025 12:50:02 +0800 Subject: [PATCH 22/58] Polish supported languages dialog UI --- .../localization/ui/SupportLanguagesDialog.kt | 72 ++++++++++++------- 1 file changed, 48 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt index cd668db..7d33d4e 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt @@ -16,14 +16,19 @@ package com.airsaid.localization.ui +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.ui.Alignment import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -32,6 +37,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposePanel import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.flagEmoji @@ -50,7 +56,7 @@ class SupportLanguagesDialog(private val translator: AbstractTranslator) : Dialo override fun createCenterPanel(): JComponent { val panel = ComposePanel() - panel.preferredSize = java.awt.Dimension(520, 420) + panel.preferredSize = java.awt.Dimension(460, 420) panel.setContent { IdeTheme { Surface( @@ -74,38 +80,56 @@ class SupportLanguagesDialog(private val translator: AbstractTranslator) : Dialo @Composable private fun SupportLanguagesContent(languages: List) { - Surface( + val listState = rememberLazyListState() + + Column( modifier = Modifier .fillMaxWidth() - .padding(20.dp), - shape = RoundedCornerShape(12.dp), - tonalElevation = 1.dp, + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 20.dp, vertical = 24.dp) ) { - LazyColumn( + Text( + text = "Supported languages (${languages.size})", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground + ) + Box( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + .padding(top = 12.dp) ) { - items(languages, key = { it.id }) { language -> - Column(modifier = Modifier.fillMaxWidth()) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val flag = language.flagEmoji - if (flag != null) { - Text(text = flag, style = MaterialTheme.typography.bodyMedium) + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(languages, key = { it.id }) { language -> + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val flag = language.flagEmoji + if (flag != null) { + Text( + text = flag, + style = MaterialTheme.typography.headlineSmall.copy(fontSize = 24.sp) + ) + } + Text(text = language.englishName, style = MaterialTheme.typography.bodyMedium) } - Text(text = language.englishName, style = MaterialTheme.typography.bodyMedium) + Text( + text = language.code.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } - Text( - text = language.code.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } } + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd), + adapter = rememberScrollbarAdapter(listState) + ) } } } From f05cee01da2f032126c314199698293a635f9723 Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Sun, 21 Sep 2025 15:15:07 +0800 Subject: [PATCH 23/58] Rework translator credential management --- .../localization/config/SettingsComponent.kt | 91 ++++++++------- .../config/SettingsConfigurable.kt | 51 +++++---- .../localization/config/SettingsState.kt | 107 ++++++++++++------ .../config/TranslatorCredentialsLoader.kt | 9 +- .../translate/AbstractTranslator.kt | 25 ++-- .../translate/TranslatorConfigurable.kt | 18 +-- .../TranslatorCredentialDescriptor.kt | 10 ++ .../translate/impl/ali/AliTranslator.kt | 48 +++++--- .../translate/impl/baidu/BaiduTranslator.kt | 14 ++- .../translate/impl/deepl/DeepLTranslator.kt | 15 +-- .../impl/google/AbsGoogleTranslator.kt | 5 +- .../translate/impl/google/GoogleTranslator.kt | 6 +- .../impl/googleapi/GoogleApiTranslator.kt | 13 ++- .../impl/microsoft/MicrosoftTranslator.kt | 13 ++- .../impl/openai/ChatGPTTranslator.kt | 15 +-- .../translate/impl/youdao/YoudaoTranslator.kt | 19 ++-- .../translate/services/TranslatorService.kt | 29 ++++- .../localization/ui/SelectLanguagesDialog.kt | 2 +- .../services/TranslatorServiceTest.kt | 54 +++++++++ 19 files changed, 345 insertions(+), 199 deletions(-) create mode 100644 src/main/kotlin/com/airsaid/localization/translate/TranslatorCredentialDescriptor.kt create mode 100644 src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt index fc7c3b1..14e6967 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt @@ -51,9 +51,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -65,9 +67,11 @@ import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.ui.IdeTheme import com.airsaid.localization.ui.SupportLanguagesDialog @@ -95,8 +99,8 @@ class SettingsComponent( private val translatorsState = mutableStateListOf() private val selectedTranslatorState = mutableStateOf(null) - private val appIdState = mutableStateOf("") - private val appKeyState = mutableStateOf("") + private val credentialDefinitionsState = mutableStateListOf() + private val credentialValuesState = mutableStateMapOf() private val enableCacheState = mutableStateOf(true) private val maxCacheSizeState = mutableStateOf("500") private val translationIntervalState = mutableStateOf("2") @@ -111,14 +115,17 @@ class SettingsComponent( translators = translatorsState, selectedTranslator = selectedTranslatorState.value, defaultTranslatorKey = TranslatorService.getInstance().getDefaultTranslator()?.key, - appIdState = appIdState, - appKeyState = appKeyState, + credentialDefinitions = credentialDefinitionsState, + credentialValues = credentialValuesState, enableCacheState = enableCacheState, maxCacheSizeState = maxCacheSizeState, translationIntervalState = translationIntervalState, onTranslatorSelected = { translator -> applySelectedTranslator(translator) }, + onCredentialValueChanged = { id, value -> + credentialValuesState[id] = value + }, onShowSupportedLanguages = { translator -> SupportLanguagesDialog(translator).show() }, onNavigateToApplyPage = { url -> BrowserUtil.browse(url) } ) @@ -148,19 +155,21 @@ class SettingsComponent( applySelectedTranslator(selected) } - fun setAppId(appId: String) { - appIdState.value = appId + fun setCredentialValues(values: Map) { + credentialValuesState.clear() + credentialDefinitionsState.forEach { descriptor -> + credentialValuesState[descriptor.id] = values[descriptor.id] ?: "" + } } - fun setAppKey(appKey: String) { - appKeyState.value = appKey + fun setCredentialValue(id: String, value: String) { + credentialValuesState[id] = value } - val appId: String - get() = appIdState.value - - val appKey: String - get() = appKeyState.value + val credentialValues: Map + get() = credentialDefinitionsState.associate { descriptor -> + descriptor.id to (credentialValuesState[descriptor.id] ?: "") + } fun setEnableCache(isEnable: Boolean) { enableCacheState.value = isEnable @@ -192,13 +201,18 @@ class SettingsComponent( private fun applySelectedTranslator(translator: AbstractTranslator) { selectedTranslatorState.value = translator - appIdState.value = "" - appKeyState.value = "" + credentialDefinitionsState.clear() + credentialDefinitionsState.addAll(translator.credentialDefinitions) + credentialValuesState.clear() + translator.credentialDefinitions.forEach { descriptor -> + credentialValuesState[descriptor.id] = "" + } - credentialsLoader.load(translator) { appId, appKey -> + credentialsLoader.load(translator) { loaded -> if (selectedTranslatorState.value?.key == translator.key) { - appIdState.value = appId - appKeyState.value = appKey + translator.credentialDefinitions.forEach { descriptor -> + credentialValuesState[descriptor.id] = loaded[descriptor.id] ?: "" + } } } } @@ -214,12 +228,13 @@ private fun SettingsContent( translators: SnapshotStateList, selectedTranslator: AbstractTranslator?, defaultTranslatorKey: String?, - appIdState: androidx.compose.runtime.MutableState, - appKeyState: androidx.compose.runtime.MutableState, + credentialDefinitions: SnapshotStateList, + credentialValues: SnapshotStateMap, enableCacheState: androidx.compose.runtime.MutableState, maxCacheSizeState: androidx.compose.runtime.MutableState, translationIntervalState: androidx.compose.runtime.MutableState, onTranslatorSelected: (AbstractTranslator) -> Unit, + onCredentialValueChanged: (String, String) -> Unit = { _, _ -> }, onShowSupportedLanguages: (AbstractTranslator) -> Unit, onNavigateToApplyPage: (String) -> Unit ) { @@ -254,33 +269,23 @@ private fun SettingsContent( } selectedTranslator?.let { provider -> - if (provider.isNeedAppId) { - SettingsFormRow(label = provider.appIdDisplay) { + credentialDefinitions.forEach { descriptor -> + SettingsFormRow(label = descriptor.label, helperText = descriptor.description) { IdeTextField( modifier = Modifier .fillMaxWidth() .widthIn(min = FieldMinWidth), - value = appIdState.value, - onValueChange = { appIdState.value = it.trimStart() }, - singleLine = true + value = credentialValues[descriptor.id] ?: "", + onValueChange = { newValue -> + onCredentialValueChanged(descriptor.id, newValue.trimStart()) + }, + singleLine = true, + secureInput = descriptor.isSecret ) } } - if (provider.isNeedAppKey) { - SettingsFormRow(label = provider.appKeyDisplay) { - IdeTextField( - modifier = Modifier - .fillMaxWidth() - .widthIn(min = FieldMinWidth), - value = appKeyState.value, - onValueChange = { appKeyState.value = it.trimStart() }, - singleLine = true - ) - } - } - - provider.applyAppIdUrl?.takeUnless { it.isBlank() }?.let { url -> + provider.credentialHelpUrl?.takeUnless { it.isBlank() }?.let { url -> SettingsFormRow( label = "Need credentials?", helperText = "Click to request keys from ${provider.name}." @@ -412,6 +417,7 @@ private fun IdeTextField( leadingIcon: (@Composable (() -> Unit))? = null, trailingIcon: (@Composable (() -> Unit))? = null, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + secureInput: Boolean = false, singleLine: Boolean = true, ) { val interactionSource = remember { MutableInteractionSource() } @@ -447,9 +453,14 @@ private fun IdeTextField( keyboardOptions = keyboardOptions, interactionSource = interactionSource, ) { innerTextField -> + val visualTransformation = if (secureInput) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + } OutlinedTextFieldDefaults.DecorationBox( value = value, - visualTransformation = VisualTransformation.None, + visualTransformation = visualTransformation, innerTextField = innerTextField, placeholder = placeholder, label = null, diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt index 5541481..dad1aa7 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt @@ -23,7 +23,6 @@ import com.airsaid.localization.translate.services.TranslatorService import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.ConfigurationException -import com.intellij.openapi.util.text.StringUtil import javax.swing.JComponent /** @@ -53,9 +52,13 @@ class SettingsConfigurable : Configurable { private fun initComponents() { val settingsState = SettingsState.getInstance() val translators = TranslatorService.getInstance().getTranslators() + val selected = settingsState.selectedTranslator settingsComponent?.let { component -> component.setTranslators(translators) - component.setSelectedTranslator(translators[settingsState.selectedTranslator.key]!!) + component.setSelectedTranslator(translators[selected.key]!!) + component.setCredentialValues( + settingsState.getCredentials(selected.key, selected.credentialDefinitions) + ) component.setEnableCache(settingsState.isEnableCache) component.setMaxCacheSize(settingsState.maxCacheSize) component.setTranslationInterval(settingsState.translationInterval) @@ -67,8 +70,13 @@ class SettingsConfigurable : Configurable { val selectedTranslator = settingsComponent?.selectedTranslator ?: return false var isChanged = settingsState.selectedTranslator != selectedTranslator - isChanged = isChanged || settingsState.getAppId(selectedTranslator.key) != selectedTranslator.appId - isChanged = isChanged || settingsState.getAppKey(selectedTranslator.key) != selectedTranslator.appKey + + val expectedCredentials = settingsState.getCredentials( + selectedTranslator.key, + selectedTranslator.credentialDefinitions + ) + val currentCredentials = settingsComponent?.credentialValues ?: emptyMap() + isChanged = isChanged || expectedCredentials != currentCredentials isChanged = isChanged || settingsState.isEnableCache != (settingsComponent?.isEnableCache ?: false) isChanged = isChanged || settingsState.maxCacheSize != (settingsComponent?.maxCacheSize ?: 0) isChanged = isChanged || settingsState.translationInterval != (settingsComponent?.translationInterval ?: 0) @@ -85,24 +93,22 @@ class SettingsConfigurable : Configurable { LOG.info("apply selectedTranslator: ${selectedTranslator.name}") - // Verify that the required parameters are not configured - if (selectedTranslator.isNeedAppId && StringUtil.isEmpty(settingsComponent?.appId)) { - throw ConfigurationException("${selectedTranslator.appIdDisplay} not configured") - } - if (selectedTranslator.isNeedAppKey && StringUtil.isEmpty(settingsComponent?.appKey)) { - throw ConfigurationException("${selectedTranslator.appKeyDisplay} not configured") + // Verify credential requirements + val credentialValues = settingsComponent?.credentialValues ?: emptyMap() + val credentialDefinitions = selectedTranslator.credentialDefinitions.associateBy { it.id } + selectedTranslator.credentialDefinitions.forEach { descriptor -> + if (descriptor.required) { + val value = credentialValues[descriptor.id] + if (value.isNullOrBlank()) { + throw ConfigurationException("${descriptor.label} not configured") + } + } } settingsState.selectedTranslator = selectedTranslator - if (selectedTranslator.isNeedAppId) { - settingsComponent?.appId?.let { appId -> - settingsState.setAppId(selectedTranslator.key, appId) - } - } - if (selectedTranslator.isNeedAppKey) { - settingsComponent?.appKey?.let { appKey -> - settingsState.setAppKey(selectedTranslator.key, appKey) - } + credentialValues.forEach { (id, value) -> + val descriptor = credentialDefinitions[id] ?: return@forEach + settingsState.setCredential(selectedTranslator.key, descriptor, value) } settingsComponent?.let { component -> @@ -124,8 +130,9 @@ class SettingsConfigurable : Configurable { val selectedTranslator = settingsState.selectedTranslator settingsComponent?.let { component -> component.setSelectedTranslator(selectedTranslator) - component.setAppId(settingsState.getAppId(selectedTranslator.key)) - component.setAppKey(settingsState.getAppKey(selectedTranslator.key)) + component.setCredentialValues( + settingsState.getCredentials(selectedTranslator.key, selectedTranslator.credentialDefinitions) + ) component.setEnableCache(settingsState.isEnableCache) component.setMaxCacheSize(settingsState.maxCacheSize) component.setTranslationInterval(settingsState.translationInterval) @@ -135,4 +142,4 @@ class SettingsConfigurable : Configurable { override fun disposeUIResources() { settingsComponent = null } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt index 1602535..588fea4 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt @@ -19,6 +19,7 @@ package com.airsaid.localization.config import com.airsaid.localization.services.AndroidValuesService import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.utils.SecureStorage import com.intellij.openapi.components.* @@ -42,31 +43,16 @@ class SettingsState : PersistentStateComponent { } } - private val appKeyStorage: Map + private val credentialSecureStorage = mutableMapOf() private var state = State() - init { - val storage = mutableMapOf() - val translatorService = TranslatorService.getInstance() - val translators = translatorService.getTranslators().values - for (translator in translators) { - if (translatorService.getDefaultTranslator() != translator) { - storage[translator.key] = SecureStorage(translator.key) - } - } - appKeyStorage = storage - } - fun initSetting() { val translatorService = TranslatorService.getInstance() - val selectedTranslator = translatorService.getSelectedTranslator() - if (selectedTranslator == null) { - LOG.info("initSetting") - translatorService.setSelectedTranslator(this.selectedTranslator) - translatorService.setEnableCache(isEnableCache) - translatorService.maxCacheSize = maxCacheSize - translatorService.translationInterval = translationInterval - } + LOG.info("initSetting") + translatorService.setSelectedTranslator(this.selectedTranslator) + translatorService.setEnableCache(isEnableCache) + translatorService.maxCacheSize = maxCacheSize + translatorService.translationInterval = translationInterval AndroidValuesService.getInstance().isSkipNonTranslatable = isSkipNonTranslatable } @@ -82,22 +68,37 @@ class SettingsState : PersistentStateComponent { state.selectedTranslatorKey = translator.key } - fun setAppId(translatorKey: String, appId: String) { - state.appIds[translatorKey] = appId - } - - fun getAppId(translatorKey: String): String { - return state.appIds[translatorKey] ?: "" + fun setCredential(translatorKey: String, descriptor: TranslatorCredentialDescriptor, value: String) { + if (descriptor.isSecret) { + secureStorage(translatorKey, descriptor.id).save(value) + } else { + val credentials = state.credentials.getOrPut(translatorKey) { mutableMapOf() } + if (value.isBlank()) { + credentials.remove(descriptor.id) + if (credentials.isEmpty()) { + state.credentials.remove(translatorKey) + } + } else { + credentials[descriptor.id] = value + } + } } - fun setAppKey(translatorKey: String, appKey: String) { - val secureStorage = appKeyStorage[translatorKey] - secureStorage?.save(appKey) + fun getCredential(translatorKey: String, descriptor: TranslatorCredentialDescriptor): String { + return if (descriptor.isSecret) { + readSecret(translatorKey, descriptor) + } else { + state.credentials[translatorKey]?.get(descriptor.id) ?: "" + } } - fun getAppKey(translatorKey: String): String { - val secureStorage = appKeyStorage[translatorKey] - return secureStorage?.read() ?: "" + fun getCredentials(translatorKey: String, descriptors: List): Map { + if (descriptors.isEmpty()) return emptyMap() + return buildMap { + descriptors.forEach { descriptor -> + put(descriptor.id, getCredential(translatorKey, descriptor)) + } + } } var isEnableCache: Boolean @@ -130,14 +131,52 @@ class SettingsState : PersistentStateComponent { override fun loadState(state: State) { this.state = state + migrateLegacyAppIds() } data class State( var selectedTranslatorKey: String? = null, + var credentials: MutableMap> = mutableMapOf(), + @Deprecated("Replaced by credentials") var appIds: MutableMap = mutableMapOf(), var isEnableCache: Boolean = true, var maxCacheSize: Int = 500, var translationInterval: Int = 2, // 2 second var isSkipNonTranslatable: Boolean = false ) -} \ No newline at end of file + + private fun secureStorage(translatorKey: String, credentialId: String): SecureStorage { + val key = "$translatorKey::$credentialId" + return credentialSecureStorage.getOrPut(key) { SecureStorage(key) } + } + + private fun readSecret(translatorKey: String, descriptor: TranslatorCredentialDescriptor): String { + val storage = secureStorage(translatorKey, descriptor.id) + val value = storage.read() + if (value.isNotEmpty()) { + return value + } + + // Backwards compatibility: migrate legacy key-only storage if present. + val legacy = credentialSecureStorage.getOrPut(translatorKey) { SecureStorage(translatorKey) } + val legacyValue = legacy.read() + if (legacyValue.isNotEmpty()) { + storage.save(legacyValue) + return legacyValue + } + return "" + } + + private fun migrateLegacyAppIds() { + if (state.appIds.isEmpty()) return + val translators = TranslatorService.getInstance().getTranslators() + for ((translatorKey, appId) in state.appIds) { + val translator = translators[translatorKey] ?: continue + val descriptor = translator.credentialDefinitions.firstOrNull { it.id == "appId" } + if (descriptor != null && appId.isNotBlank()) { + state.credentials.getOrPut(translatorKey) { mutableMapOf() }[descriptor.id] = appId + } + } + state.appIds.clear() + } +} diff --git a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt index 84ecc1d..5a0061b 100644 --- a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt +++ b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt @@ -4,7 +4,7 @@ import com.airsaid.localization.translate.AbstractTranslator import com.intellij.openapi.application.ApplicationManager fun interface TranslatorCredentialsLoader { - fun load(translator: AbstractTranslator, onLoaded: (appId: String, appKey: String) -> Unit) + fun load(translator: AbstractTranslator, onLoaded: (credentials: Map) -> Unit) companion object { fun default(): TranslatorCredentialsLoader = DefaultTranslatorCredentialsLoader @@ -14,13 +14,12 @@ fun interface TranslatorCredentialsLoader { private object DefaultTranslatorCredentialsLoader : TranslatorCredentialsLoader { private val application = ApplicationManager.getApplication() - override fun load(translator: AbstractTranslator, onLoaded: (String, String) -> Unit) { + override fun load(translator: AbstractTranslator, onLoaded: (Map) -> Unit) { application.executeOnPooledThread { val settingsState = SettingsState.getInstance() - val appId = settingsState.getAppId(translator.key) - val appKey = settingsState.getAppKey(translator.key) + val credentials = settingsState.getCredentials(translator.key, translator.credentialDefinitions) application.invokeLater { - onLoaded(appId, appKey) + onLoaded(credentials) } } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt index b279ada..befa818 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt @@ -75,21 +75,12 @@ abstract class AbstractTranslator : Translator, TranslatorConfigurable { override val icon: Icon? = null - override val isNeedAppId: Boolean = true + override val credentialDefinitions: List = listOf( + TranslatorCredentialDescriptor(id = "appId", label = "APP ID", isSecret = false), + TranslatorCredentialDescriptor(id = "appKey", label = "APP KEY", isSecret = true), + ) - override val appId: String? - get() = SettingsState.getInstance().getAppId(key) - - override val appIdDisplay: String = "APP ID" - - override val isNeedAppKey: Boolean = true - - override val appKey: String? - get() = SettingsState.getInstance().getAppKey(key) - - override val appKeyDisplay: String = "APP KEY" - - override val applyAppIdUrl: String? = null + override val credentialHelpUrl: String? = null protected open fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { throw UnsupportedOperationException() @@ -117,6 +108,12 @@ abstract class AbstractTranslator : Translator, TranslatorConfigurable { throw UnsupportedOperationException() } + protected fun credentialValue(credentialId: String): String { + val descriptor = credentialDefinitions.firstOrNull { it.id == credentialId } + ?: error("Unknown credential $credentialId for translator $key") + return SettingsState.getInstance().getCredential(key, descriptor) + } + protected fun checkSupportedLanguages(fromLang: Lang, toLang: Lang, text: String) { if (!supportedLanguages.contains(toLang)) { throw TranslationException(fromLang, toLang, text, "${toLang.englishName} is not supported.") diff --git a/src/main/kotlin/com/airsaid/localization/translate/TranslatorConfigurable.kt b/src/main/kotlin/com/airsaid/localization/translate/TranslatorConfigurable.kt index 9608284..c3b1b54 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/TranslatorConfigurable.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/TranslatorConfigurable.kt @@ -33,17 +33,9 @@ interface TranslatorConfigurable { val supportedLanguages: List - val isNeedAppId: Boolean + /** Describes credentials required to use this translator. */ + val credentialDefinitions: List - val appId: String? - - val appIdDisplay: String - - val isNeedAppKey: Boolean - - val appKey: String? - - val appKeyDisplay: String - - val applyAppIdUrl: String? -} \ No newline at end of file + /** Optional URL for requesting credentials or reading setup instructions. */ + val credentialHelpUrl: String? +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/TranslatorCredentialDescriptor.kt b/src/main/kotlin/com/airsaid/localization/translate/TranslatorCredentialDescriptor.kt new file mode 100644 index 0000000..58a28c0 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/TranslatorCredentialDescriptor.kt @@ -0,0 +1,10 @@ +package com.airsaid.localization.translate + +data class TranslatorCredentialDescriptor( + val id: String, + val label: String, + val isSecret: Boolean = true, + val required: Boolean = true, + val description: String? = null, + val placeholder: String? = null, +) diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt index af60f0c..6cb17ae 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt @@ -19,6 +19,7 @@ package com.airsaid.localization.translate.impl.ali import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages import com.aliyun.alimt20181012.Client @@ -42,9 +43,7 @@ class AliTranslator : AbstractTranslator() { private const val APPLY_APP_ID_URL = "https://www.aliyun.com/product/ai/base_alimt" } - private val config = Config() private var _supportedLanguages: MutableList? = null - private var client: Client? = null override val key: String = KEY @@ -52,6 +51,13 @@ class AliTranslator : AbstractTranslator() { override val icon: Icon? = PluginIcons.ALI_ICON + override val credentialDefinitions = listOf( + TranslatorCredentialDescriptor(id = "appId", label = "AccessKey ID", isSecret = false), + TranslatorCredentialDescriptor(id = "appKey", label = "AccessKey Secret", isSecret = true) + ) + + override val credentialHelpUrl: String? = APPLY_APP_ID_URL + override val supportedLanguages: List get() { if (_supportedLanguages == null) { @@ -78,24 +84,20 @@ class AliTranslator : AbstractTranslator() { return _supportedLanguages!! } - override val appIdDisplay: String = "AccessKey ID" - - override val appKeyDisplay: String = "AccessKey Secret" - - override val applyAppIdUrl: String? = APPLY_APP_ID_URL - @Throws(TranslationException::class) override fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { checkSupportedLanguages(fromLang, toLang, text) - config.setAccessKeyId(appId).setAccessKeySecret(appKey).setEndpoint(ENDPOINT) + val credentials = resolveCredentials(fromLang, toLang, text) - if (client == null) { - try { - client = Client(config) - } catch (e: Exception) { - throw TranslationException(fromLang, toLang, text, e) - } + val config = Config() + .setAccessKeyId(credentials.first) + .setAccessKeySecret(credentials.second) + .setEndpoint(ENDPOINT) + val client = try { + Client(config) + } catch (e: Exception) { + throw TranslationException(fromLang, toLang, text, e) } val request = TranslateGeneralRequest() @@ -109,7 +111,7 @@ class AliTranslator : AbstractTranslator() { val response: TranslateGeneralResponse try { - response = client!!.translateGeneralWithOptions(request, runtime) + response = client.translateGeneralWithOptions(request, runtime) } catch (e: Exception) { throw TranslationException(fromLang, toLang, text, e) } @@ -121,4 +123,16 @@ class AliTranslator : AbstractTranslator() { throw TranslationException(fromLang, toLang, text, "${body.message}(${body.code})") } } -} \ No newline at end of file + private fun resolveCredentials( + fromLang: Lang, + toLang: Lang, + text: String + ): Pair { + val accessKeyId = credentialValue("appId").takeIf { it.isNotBlank() } + val accessKeySecret = credentialValue("appKey").takeIf { it.isNotBlank() } + if (accessKeyId == null || accessKeySecret == null) { + throw TranslationException(fromLang, toLang, text, "AccessKey credentials are not configured") + } + return Pair(accessKeyId, accessKeySecret) + } +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt index 931ff01..644b614 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt @@ -19,6 +19,7 @@ package com.airsaid.localization.translate.impl.baidu import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages import com.airsaid.localization.translate.util.GsonUtil @@ -87,14 +88,19 @@ class BaiduTranslator : AbstractTranslator() { return _supportedLanguages!! } - override val applyAppIdUrl: String? = APPLY_APP_ID_URL + override val credentialDefinitions = listOf( + TranslatorCredentialDescriptor(id = "appId", label = "APP ID", isSecret = false), + TranslatorCredentialDescriptor(id = "appKey", label = "APP KEY", isSecret = true) + ) + + override val credentialHelpUrl: String? = APPLY_APP_ID_URL override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = TRANSLATE_URL override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { val salt = System.currentTimeMillis().toString() - val appId = this.appId - val securityKey = this.appKey + val appId = credentialValue("appId") + val securityKey = credentialValue("appKey") val sign = MD5.md5("$appId$text$salt$securityKey") return listOf( @@ -123,4 +129,4 @@ class BaiduTranslator : AbstractTranslator() { throw TranslationException(fromLang, toLang, text, message) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt index a9344c9..46ddeb7 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt @@ -18,6 +18,7 @@ package com.airsaid.localization.translate.impl.deepl import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages import com.airsaid.localization.translate.util.GsonUtil @@ -51,7 +52,11 @@ open class DeepLTranslator : AbstractTranslator() { override val icon: Icon? = PluginIcons.DEEP_L_ICON - override val isNeedAppId: Boolean = false + override val credentialDefinitions = listOf( + TranslatorCredentialDescriptor(id = "appKey", label = "KEY", isSecret = true) + ) + + override val credentialHelpUrl: String? = APPLY_APP_ID_URL override val supportedLanguages: List get() { @@ -93,10 +98,6 @@ open class DeepLTranslator : AbstractTranslator() { return _supportedLanguages!! } - override val appKeyDisplay: String = "KEY" - - override val applyAppIdUrl: String? = APPLY_APP_ID_URL - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = UrlBuilder(TRANSLATE_URL).build() @@ -109,7 +110,7 @@ open class DeepLTranslator : AbstractTranslator() { override fun configureRequestBuilder(requestBuilder: RequestBuilder) { requestBuilder.tuner { connection -> - connection.setRequestProperty("Authorization", "DeepL-Auth-Key ${appKey}") + connection.setRequestProperty("Authorization", "DeepL-Auth-Key ${credentialValue("appKey")}") connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") } } @@ -118,4 +119,4 @@ open class DeepLTranslator : AbstractTranslator() { LOG.info("parsingResult: $resultText") return GsonUtil.getInstance().gson.fromJson(resultText, DeepLTranslationResult::class.java).translationResult } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt index 9f7441c..1edb29d 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt @@ -18,6 +18,7 @@ package com.airsaid.localization.translate.impl.google import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages import icons.PluginIcons @@ -32,6 +33,8 @@ abstract class AbsGoogleTranslator : AbstractTranslator() { override val icon: Icon = PluginIcons.GOOGLE_ICON + override val credentialDefinitions: List = emptyList() + override val supportedLanguages: List get() { if (_supportedLanguages == null) { @@ -53,4 +56,4 @@ abstract class AbsGoogleTranslator : AbstractTranslator() { } return _supportedLanguages!! } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt index eeea29c..43e18f4 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt @@ -44,10 +44,6 @@ class GoogleTranslator : AbsGoogleTranslator() { override val name: String = "Google" - override val isNeedAppId: Boolean = false - - override val isNeedAppKey: Boolean = false - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { return UrlBuilder(BASE_URL) .addQueryParameter("sl", fromLang.translationCode) // source language code (auto for auto detection) @@ -75,4 +71,4 @@ class GoogleTranslator : AbsGoogleTranslator() { val googleTranslationResult = GsonUtil.getInstance().gson.fromJson(resultText, GoogleTranslationResult::class.java) return googleTranslationResult.translationResult } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt index 47acad8..73268ec 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt @@ -2,6 +2,7 @@ package com.airsaid.localization.translate.impl.googleapi import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.impl.google.AbsGoogleTranslator import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.util.GsonUtil @@ -30,17 +31,17 @@ class GoogleApiTranslator : AbsGoogleTranslator() { override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = TRANSLATE_URL - override val appKeyDisplay: String = "API Key" + override val credentialDefinitions = listOf( + TranslatorCredentialDescriptor(id = "appKey", label = "API Key", isSecret = true) + ) - override val applyAppIdUrl: String? = APPLY_APP_ID_URL - - override val isNeedAppId: Boolean = false + override val credentialHelpUrl: String? = APPLY_APP_ID_URL override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { return listOf( Pair.create("q", text), Pair.create("target", toLang.translationCode), - Pair.create("key", appKey), + Pair.create("key", credentialValue("appKey")), Pair.create("format", "text") ) } @@ -63,4 +64,4 @@ class GoogleApiTranslator : AbsGoogleTranslator() { throw TranslationException(fromLang, toLang, text, message) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt index c62ba22..cb88e6f 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt @@ -18,6 +18,7 @@ package com.airsaid.localization.translate.impl.microsoft import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages import com.airsaid.localization.translate.util.GsonUtil @@ -50,7 +51,11 @@ class MicrosoftTranslator : AbstractTranslator() { override val icon: Icon? = PluginIcons.MICROSOFT_ICON - override val isNeedAppId: Boolean = false + override val credentialDefinitions = listOf( + TranslatorCredentialDescriptor(id = "appKey", label = "KEY", isSecret = true) + ) + + override val credentialHelpUrl: String? = APPLY_APP_ID_URL override val supportedLanguages: List get() { @@ -145,10 +150,6 @@ class MicrosoftTranslator : AbstractTranslator() { return _supportedLanguages!! } - override val appKeyDisplay: String = "KEY" - - override val applyAppIdUrl: String? = APPLY_APP_ID_URL - override val requestContentType: String get() = "application/json" @@ -165,7 +166,7 @@ class MicrosoftTranslator : AbstractTranslator() { override fun configureRequestBuilder(requestBuilder: RequestBuilder) { requestBuilder.tuner { connection -> - connection.setRequestProperty("Ocp-Apim-Subscription-Key", appKey) + connection.setRequestProperty("Ocp-Apim-Subscription-Key", credentialValue("appKey")) connection.setRequestProperty("Content-type", "application/json") } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt index 1ff2dad..027c19c 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt @@ -18,6 +18,7 @@ package com.airsaid.localization.translate.impl.openai import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages import com.airsaid.localization.translate.util.GsonUtil @@ -43,18 +44,14 @@ class ChatGPTTranslator : AbstractTranslator() { override val icon: Icon? get() = PluginIcons.OPENAI_ICON - override val isNeedAppId: Boolean - get() = false - - override val isNeedAppKey: Boolean - get() = true + override val credentialDefinitions: List + get() = listOf( + TranslatorCredentialDescriptor(id = "appKey", label = "API Key", isSecret = true) + ) override val supportedLanguages: List get() = Languages.getLanguages() - override val appKeyDisplay: String - get() = "KEY" - override val requestContentType: String get() = "application/json" @@ -79,7 +76,7 @@ class ChatGPTTranslator : AbstractTranslator() { override fun configureRequestBuilder(requestBuilder: RequestBuilder) { requestBuilder.tuner { connection -> - connection.setRequestProperty("Authorization", "Bearer ${appKey}") + connection.setRequestProperty("Authorization", "Bearer ${credentialValue("appKey")}") connection.setRequestProperty("Content-Type", "application/json") } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt index 3d70226..9d54609 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt @@ -19,6 +19,7 @@ package com.airsaid.localization.translate.impl.youdao import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages import com.airsaid.localization.translate.util.GsonUtil @@ -154,14 +155,12 @@ class YoudaoTranslator : AbstractTranslator() { return _supportedLanguages!! } - override val appIdDisplay: String - get() = "应用 ID" + override val credentialDefinitions = listOf( + TranslatorCredentialDescriptor(id = "appId", label = "应用 ID", isSecret = false), + TranslatorCredentialDescriptor(id = "appKey", label = "应用秘钥", isSecret = true) + ) - override val appKeyDisplay: String - get() = "应用秘钥" - - override val applyAppIdUrl: String? - get() = APPLY_APP_ID_URL + override val credentialHelpUrl: String? = APPLY_APP_ID_URL override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { return TRANSLATE_URL @@ -195,8 +194,8 @@ class YoudaoTranslator : AbstractTranslator() { override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { val salt = System.currentTimeMillis().toString() val curTime = (System.currentTimeMillis() / 1000).toString() - val appId = this.appId - val appKey = this.appKey + val appId = credentialValue("appId") + val appKey = credentialValue("appKey") val sign = getDigest(appId + truncate(text) + salt + curTime + appKey) val params = mutableListOf>() params.add(Pair.create("from", fromLang.translationCode)) @@ -225,4 +224,4 @@ class YoudaoTranslator : AbstractTranslator() { throw TranslationException(fromLang, toLang, text, translationResult.errorCode ?: "Unknown error") } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt b/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt index 7688275..2768e22 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt @@ -28,6 +28,7 @@ import com.intellij.openapi.diagnostic.Logger import org.apache.commons.lang.StringUtils import java.util.* import java.util.function.Consumer +import kotlin.jvm.Volatile /** * @author airsaid @@ -39,13 +40,14 @@ class TranslatorService { fun process(text: String?): String? } - private var selectedTranslator: AbstractTranslator? = null private val defaultTranslator: AbstractTranslator private val cacheService: TranslationCacheService private val translators: Map private val translationInterceptors: MutableList private var isEnableCache = true private var intervalTime = 0 + @Volatile + private var selectedTranslator: AbstractTranslator var maxCacheSize: Int = 1000 set(value) { field = value @@ -65,8 +67,14 @@ class TranslatorService { for (translator in serviceLoader) { translatorsMap[translator.key] = translator } + if (translatorsMap.isEmpty()) { + LOG.error("No translators were registered. Translation functionality will be unavailable.") + throw IllegalStateException("No translators registered") + } translators = translatorsMap - defaultTranslator = translators[GoogleTranslator.KEY]!! + + defaultTranslator = selectDefaultTranslator(translatorsMap) + selectedTranslator = defaultTranslator cacheService = TranslationCacheService.getInstance() @@ -85,7 +93,7 @@ class TranslatorService { } } - fun getSelectedTranslator(): AbstractTranslator? = selectedTranslator + fun getSelectedTranslator(): AbstractTranslator = selectedTranslator fun doTranslateByAsync(fromLang: Lang, toLang: Lang, text: String, consumer: Consumer) { ApplicationManager.getApplication().executeOnPooledThread { @@ -112,7 +120,8 @@ class TranslatorService { return text } - var result = selectedTranslator!!.doTranslate(fromLang, toLang, text) + val translator = selectedTranslator + var result = translator.doTranslate(fromLang, toLang, text) LOG.info("doTranslate result: $result") for (interceptor in translationInterceptors) { result = interceptor.process(result) ?: result @@ -148,5 +157,15 @@ class TranslatorService { private val LOG = Logger.getInstance(TranslatorService::class.java) fun getInstance(): TranslatorService = service() + + internal fun selectDefaultTranslator(translators: Map): AbstractTranslator { + val googleTranslator = translators[GoogleTranslator.KEY] + if (googleTranslator != null) { + return googleTranslator + } + val fallback = translators.values.first() + LOG.warn("Google translator is not available. Falling back to ${fallback.key} as default.") + return fallback + } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt index c1d3292..0d419ac 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -176,7 +176,7 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje private fun initState() { val properties = properties() - translator = translatorService.getSelectedTranslator() ?: error("Translator is not available") + translator = translatorService.getSelectedTranslator() supportedLanguages = translator.supportedLanguages.sortedBy { it.englishName } val savedLanguageIds = LanguageUtil.getSelectedLanguageIds(project) diff --git a/src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt b/src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt new file mode 100644 index 0000000..01c9b59 --- /dev/null +++ b/src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt @@ -0,0 +1,54 @@ +package com.airsaid.localization.translate.services + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.lang.Lang +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TranslatorServiceTest { + + @Test + fun `selectDefaultTranslator prefers google when present`() { + val google = StubTranslator("Google") + val other = StubTranslator("Other") + val translators = linkedMapOf( + google.key to google, + other.key to other, + ) + + val defaultTranslator = TranslatorService.selectDefaultTranslator(translators) + + assertEquals(google, defaultTranslator) + } + + @Test + fun `selectDefaultTranslator falls back to first translator when google missing`() { + val first = StubTranslator("First") + val second = StubTranslator("Second") + val translators = linkedMapOf( + first.key to first, + second.key to second, + ) + + val defaultTranslator = TranslatorService.selectDefaultTranslator(translators) + + assertEquals(first, defaultTranslator) + } + + private class StubTranslator(override val key: String) : AbstractTranslator() { + override val name: String = key + override val supportedLanguages: List = emptyList() + override val isNeedAppId: Boolean = false + override val isNeedAppKey: Boolean = false + override val appId: String? get() = null + override val appKey: String? get() = null + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + throw UnsupportedOperationException() + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + throw UnsupportedOperationException() + } + } +} From 1a8df92c4747974f2c2d4f89df29abbf240ce914 Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Sun, 21 Sep 2025 15:31:05 +0800 Subject: [PATCH 24/58] Remove legacy Google translator --- README.md | 4 +- README_CN.md | 2 +- .../translate/TranslationResult.kt | 4 +- .../localization/translate/Translator.kt | 4 +- .../translate/impl/google/GoogleToken.kt | 148 ------------------ .../impl/google/GoogleTranslationResult.kt | 52 ------ .../translate/impl/google/GoogleTranslator.kt | 74 --------- .../impl/googleapi/GoogleApiTranslator.kt | 2 +- .../translate/services/TranslatorService.kt | 11 +- .../config/SettingsComponentTest.kt | 11 +- .../translate/impl/google/GoogleTokenTest.kt | 21 --- .../services/TranslatorServiceTest.kt | 20 +-- 12 files changed, 16 insertions(+), 337 deletions(-) delete mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt delete mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.kt delete mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt delete mode 100644 src/test/kotlin/com/airsaid/localization/translate/impl/google/GoogleTokenTest.kt diff --git a/README.md b/README.md index 1c70aaf..aabfd00 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Android localization plugin. supports multiple languages and multiple translator # Features - Multiple translator support: - - Google translator. + - Google translator. - Microsoft translator. - Baidu translator. - Youdao translator. @@ -54,7 +54,7 @@ Android localization plugin. supports multiple languages and multiple translator **Note: Display one line without extra line breaks and spaces in between.** - Q: Translation failure: java.net.HttpRetryException: cannot retry due to redirection, in streaming mode - A: If you are using the default translation engine (Google), then you can try switching to another engine on the settings page and use your own account for translation. Because the default translation engine is not stable. + A: Try switching to another translation engine on the settings page and use your own account for translation. Some default translators rely on shared credentials and may be rate limited. # ChangeLog [ChangeLog](CHANGELOG.md) diff --git a/README_CN.md b/README_CN.md index 3509f95..d26c8ac 100644 --- a/README_CN.md +++ b/README_CN.md @@ -52,7 +52,7 @@ Android 本地化插件,支持多种语言和翻译器。 - 问题:Translation failure: java.net.HttpRetryException: cannot retry due to redirection, in streaming mode - 回答:如果你使用的是默认的翻译引擎(Google),那么你可以在设置页面尝试切换到其他引擎,并使用自己的账号进行翻译。因为默认的翻译引擎并不稳定。 + 回答:可以在设置页面尝试切换到其他引擎,并使用自己的账号进行翻译。部分默认翻译引擎依赖共享凭证,可能会被限流。 # 更新日志 [更新日志](CHANGELOG.md) diff --git a/src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt index 1c8e43f..60f04ec 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt @@ -17,13 +17,11 @@ package com.airsaid.localization.translate -import com.airsaid.localization.translate.impl.google.GoogleTranslationResult /** * Translation results interface to obtain common translation result. * * @author airsaid - * @see GoogleTranslationResult */ interface TranslationResult { @@ -33,4 +31,4 @@ interface TranslationResult { * @return translation result text. */ val translationResult: String -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/Translator.kt b/src/main/kotlin/com/airsaid/localization/translate/Translator.kt index 7204dd1..d4535bf 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/Translator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/Translator.kt @@ -17,7 +17,6 @@ package com.airsaid.localization.translate -import com.airsaid.localization.translate.impl.google.GoogleTranslator import com.airsaid.localization.translate.lang.Lang /** @@ -26,7 +25,6 @@ import com.airsaid.localization.translate.lang.Lang * * @author airsaid * @see AbstractTranslator - * @see GoogleTranslator */ interface Translator { @@ -42,4 +40,4 @@ interface Translator { @Throws(TranslationException::class) fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt deleted file mode 100644 index ce1922d..0000000 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.google - -import com.airsaid.localization.translate.util.AgentUtil -import com.airsaid.localization.translate.util.HttpRequestFactory -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.util.Pair -import java.util.* -import java.util.regex.Pattern -import kotlin.math.abs - -/** - * @author airsaid - */ -object GoogleToken { - - private val LOG = Logger.getInstance(GoogleToken::class.java) - - private const val MIM = 60 * 60 * 1000 - private val GENERATOR = Random() - private val TKK_PATTERN = Pattern.compile("tkk='(\\d+).(-?\\d+)'") - private const val ELEMENT_URL = "%s/translate_a/element.js" - - private var sInnerValue = Pair.create(0L, 0L) - private var sNeedUpdate = true - - @JvmStatic - fun getToken(text: String): String { - return getToken(text, getDefaultTKK()) - } - - @JvmStatic - fun getToken(text: String, tkk: Pair): String { - val length = text.length - val a = mutableListOf() - var b = 0 - val ch = text.toCharArray() - - while (b < length) { - var c = ch[b].code - when { - 128 > c -> a.add(c.toLong()) - 2048 > c -> a.add((c shr 6 or 192).toLong()) - else -> { - if (55296 == (c and 64512) && b + 1 < length && 56320 == (ch[b + 1].code and 64512)) { - c = 65536 + ((c and 1023) shl 10) + (ch[++b].code and 1023) - a.add((c shr 18 or 240).toLong()) - a.add((c shr 12 and 63 or 128).toLong()) - } else { - a.add((c shr 12 or 224).toLong()) - } - a.add((c shr 6 and 63 or 128).toLong()) - } - } - if (2048 > ch[b].code) { - // Only add the last part if c was not modified in the 55296 branch above - } else { - a.add((c and 63 or 128).toLong()) - } - b++ - } - - val d = tkk.first - val e = tkk.second - var f = d - for (h in a) { - f += h - f = transform(f, "+-a^+6") - } - - f = transform(f, "+-3^+b+-f") - f = f xor e - if (0 > f) { - f = (f and Int.MAX_VALUE.toLong()) + Int.MAX_VALUE + 1 - } - f = (f % 1E6).toLong() - - return "$f.${f xor d}" - } - - private fun transform(a: Long, b: String): Long { - var g = a - val ch = b.toCharArray() - var c = 0 - while (c < ch.size - 1) { - val d = ch[c + 2] - val e = if ('a' <= d) (d.code - 87) else (d.code - '0'.code) - val f = if ('+' == ch[c + 1]) g ushr e else g shl e - g = if ('+' == ch[c]) g + f and (Int.MAX_VALUE.toLong() * 2 + 1) else g xor f - c += 3 - } - return g - } - - private fun getDefaultTKK(): Pair { - val now = System.currentTimeMillis() / MIM - val curVal = sInnerValue.first - if (!sNeedUpdate && now == curVal) { - return sInnerValue - } - - val newTKK = getTKKFromGoogle() - sNeedUpdate = newTKK == null - sInnerValue = newTKK ?: Pair.create(now, abs(GENERATOR.nextInt().toLong()) + GENERATOR.nextInt().toLong()) - - return sInnerValue - } - - private fun getTKKFromGoogle(): Pair? { - return try { - val url = String.format(ELEMENT_URL, GoogleTranslator.HOST_URL) - LOG.info("getTKKFromGoogle url: $url") - val elementJs = HttpRequestFactory.get(url) - .userAgent(AgentUtil.getUserAgent()) - .tuner { connection -> connection.setRequestProperty("Referer", GoogleTranslator.HOST_URL) } - .readString() - val matcher = TKK_PATTERN.matcher(elementJs) - if (matcher.find()) { - val value1 = matcher.group(1)!!.toLong() - val value2 = matcher.group(2)!!.toLong() - LOG.info(String.format("TKK: %d.%d", value1, value2)) - Pair.create(value1, value1) - } else { - null - } - } catch (e: Exception) { - e.printStackTrace() - LOG.warn("TKK get failed.", e) - null - } - } -} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.kt deleted file mode 100644 index 6a1f9f9..0000000 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResult.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.google - -import com.airsaid.localization.translate.TranslationResult -import com.google.gson.annotations.SerializedName - -/** - * @author airsaid - */ -data class GoogleTranslationResult( - @SerializedName("src") - var sourceCode: String? = null, - var sentences: List? = null -) : TranslationResult { - - override val translationResult: String - get() { - val sentences = this.sentences - if (sentences.isNullOrEmpty()) { - return "" - } - val result = StringBuilder() - for (sentence in sentences) { - val trans = sentence.trans - if (trans != null) result.append(trans) - } - return result.toString() - } - - data class Sentences( - var trans: String? = null, - @SerializedName("orig") - var origin: String? = null, - var backend: Int = 0 - ) -} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt deleted file mode 100644 index 43e18f4..0000000 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.google - -import com.airsaid.localization.translate.AbstractTranslator -import com.airsaid.localization.translate.lang.Lang -import com.airsaid.localization.translate.util.AgentUtil -import com.airsaid.localization.translate.util.GsonUtil -import com.airsaid.localization.translate.util.UrlBuilder -import com.google.auto.service.AutoService -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.util.Pair -import com.intellij.util.io.RequestBuilder - -/** - * @author airsaid - */ -@AutoService(AbstractTranslator::class) -class GoogleTranslator : AbsGoogleTranslator() { - - companion object { - private val LOG = Logger.getInstance(GoogleTranslator::class.java) - const val KEY = "Google" - const val HOST_URL = "https://translate.googleapis.com" - private const val BASE_URL = "$HOST_URL/translate_a/single" - } - - override val key: String = KEY - - override val name: String = "Google" - - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { - return UrlBuilder(BASE_URL) - .addQueryParameter("sl", fromLang.translationCode) // source language code (auto for auto detection) - .addQueryParameter("tl", toLang.translationCode) // translation language - .addQueryParameter("client", "gtx") // client of request (guess) - .addQueryParameters("dt", "t") // specify what to return - .addQueryParameter("dj", "1") // json response with names - .addQueryParameter("ie", "UTF-8") // input encoding - .addQueryParameter("oe", "UTF-8") // output encoding - .addQueryParameter("tk", GoogleToken.getToken(text)) // translate token - .build() - } - - override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { - return listOf(Pair.create("q", text)) - } - - override fun configureRequestBuilder(requestBuilder: RequestBuilder) { - requestBuilder.userAgent(AgentUtil.getUserAgent()) - .tuner { connection -> connection.setRequestProperty("Referer", GoogleTranslator.HOST_URL) } - } - - override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { - LOG.info("parsingResult: $resultText") - val googleTranslationResult = GsonUtil.getInstance().gson.fromJson(resultText, GoogleTranslationResult::class.java) - return googleTranslationResult.translationResult - } -} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt index 73268ec..5933269 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt @@ -27,7 +27,7 @@ class GoogleApiTranslator : AbsGoogleTranslator() { override val key: String = KEY - override val name: String = "Google (API)" + override val name: String = "Google" override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = TRANSLATE_URL diff --git a/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt b/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt index 2768e22..3f589fa 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt @@ -18,7 +18,6 @@ package com.airsaid.localization.translate.services import com.airsaid.localization.translate.AbstractTranslator -import com.airsaid.localization.translate.impl.google.GoogleTranslator import com.airsaid.localization.translate.interceptors.EscapeCharactersInterceptor import com.airsaid.localization.translate.lang.Lang import com.intellij.openapi.application.ApplicationManager @@ -159,13 +158,9 @@ class TranslatorService { fun getInstance(): TranslatorService = service() internal fun selectDefaultTranslator(translators: Map): AbstractTranslator { - val googleTranslator = translators[GoogleTranslator.KEY] - if (googleTranslator != null) { - return googleTranslator - } - val fallback = translators.values.first() - LOG.warn("Google translator is not available. Falling back to ${fallback.key} as default.") - return fallback + val default = translators.values.first() + LOG.info("Selected ${default.key} as default translator.") + return default } } } diff --git a/src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt b/src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt index 9588895..e37097a 100644 --- a/src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt +++ b/src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt @@ -24,24 +24,25 @@ class SettingsComponentTest { loader.complete("test-app-id", "test-app-key") - assertEquals("test-app-id", component.appId) - assertEquals("test-app-key", component.appKey) + val credentials = component.credentialValues + assertEquals("test-app-id", credentials["appId"]) + assertEquals("test-app-key", credentials["appKey"]) } } private class RecordingCredentialsLoader : TranslatorCredentialsLoader { @Volatile var loadCalledOnEdt: Boolean = false - private var callback: ((String, String) -> Unit)? = null + private var callback: ((Map) -> Unit)? = null - override fun load(translator: AbstractTranslator, onLoaded: (String, String) -> Unit) { + override fun load(translator: AbstractTranslator, onLoaded: (Map) -> Unit) { loadCalledOnEdt = ApplicationManager.getApplication().isDispatchThread callback = onLoaded } fun complete(appId: String, appKey: String) { ApplicationManager.getApplication().invokeAndWait { - callback?.invoke(appId, appKey) + callback?.invoke(mapOf("appId" to appId, "appKey" to appKey)) } } } diff --git a/src/test/kotlin/com/airsaid/localization/translate/impl/google/GoogleTokenTest.kt b/src/test/kotlin/com/airsaid/localization/translate/impl/google/GoogleTokenTest.kt deleted file mode 100644 index 53f2ccc..0000000 --- a/src/test/kotlin/com/airsaid/localization/translate/impl/google/GoogleTokenTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.airsaid.localization.translate.impl.google - -import com.intellij.openapi.util.Pair -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.Test - -/** - * @author airsaid - */ -class GoogleTokenTest { - - @Test - fun getToken() { - val a = 202905874L - val b = 544157181L - val c = 419689L - val tkk = Pair(c, a + b) - Assertions.assertEquals("34939.454418", GoogleToken.getToken("Translate", tkk)) - Assertions.assertEquals("671407.809414", GoogleToken.getToken("Google translate", tkk)) - } -} \ No newline at end of file diff --git a/src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt b/src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt index 01c9b59..498c64e 100644 --- a/src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt +++ b/src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt @@ -8,21 +8,7 @@ import org.junit.jupiter.api.Test class TranslatorServiceTest { @Test - fun `selectDefaultTranslator prefers google when present`() { - val google = StubTranslator("Google") - val other = StubTranslator("Other") - val translators = linkedMapOf( - google.key to google, - other.key to other, - ) - - val defaultTranslator = TranslatorService.selectDefaultTranslator(translators) - - assertEquals(google, defaultTranslator) - } - - @Test - fun `selectDefaultTranslator falls back to first translator when google missing`() { + fun `selectDefaultTranslator returns first translator`() { val first = StubTranslator("First") val second = StubTranslator("Second") val translators = linkedMapOf( @@ -38,10 +24,6 @@ class TranslatorServiceTest { private class StubTranslator(override val key: String) : AbstractTranslator() { override val name: String = key override val supportedLanguages: List = emptyList() - override val isNeedAppId: Boolean = false - override val isNeedAppKey: Boolean = false - override val appId: String? get() = null - override val appKey: String? get() = null override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { throw UnsupportedOperationException() From 1276cd894bf27ed05f732eb2eaa17fbd9220608e Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Sun, 21 Sep 2025 18:31:35 +0800 Subject: [PATCH 25/58] Refine translator settings UI and add live translator tests --- .../localization/config/SettingsComponent.kt | 273 +++++------------- .../config/SettingsConfigurable.kt | 26 +- .../localization/config/SettingsState.kt | 12 +- .../config/TranslatorConfigurationManager.kt | 30 ++ .../config/TranslatorCredentialsDialog.kt | 123 ++++++++ .../config/TranslatorCredentialsLoader.kt | 26 -- .../translate/impl/google/GoogleHttp.kt | 24 ++ .../translate/impl/google/GoogleToken.kt | 130 +++++++++ .../impl/google/GoogleTranslationResponse.kt | 23 ++ .../translate/impl/google/GoogleTranslator.kt | 71 +++++ .../impl/google/GoogleTranslatorSettings.kt | 43 +++ .../google/GoogleTranslatorSettingsDialog.kt | 122 ++++++++ .../googleapi/GoogleApiTranslationResult.kt | 49 ---- .../impl/googleapi/GoogleApiTranslator.kt | 67 ----- .../microsoft/MicrosoftEdgeAuthService.kt | 119 ++++++++ .../impl/microsoft/MicrosoftExceptions.kt | 8 + .../impl/microsoft/MicrosoftTranslator.kt | 23 +- .../translate/services/TranslatorService.kt | 14 +- .../localization/ui/SelectLanguagesDialog.kt | 38 +-- .../ui/components/FormControls.kt | 152 ++++++++++ .../config/SettingsComponentTest.kt | 67 ----- 21 files changed, 962 insertions(+), 478 deletions(-) create mode 100644 src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt create mode 100644 src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt delete mode 100644 src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResponse.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettings.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt delete mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.kt delete mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftExceptions.kt create mode 100644 src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt delete mode 100644 src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt index 14e6967..02e53bd 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt @@ -24,17 +24,17 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -42,40 +42,35 @@ import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposePanel -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp +import androidx.compose.ui.draw.scale import com.airsaid.localization.translate.AbstractTranslator -import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.ui.IdeTheme import com.airsaid.localization.ui.SupportLanguagesDialog -import com.intellij.ide.BrowserUtil +import com.airsaid.localization.ui.components.IdeTextField import com.intellij.openapi.diagnostic.Logger import com.intellij.util.ui.UIUtil import java.awt.Dimension @@ -88,9 +83,7 @@ import kotlin.math.max /** * Compose implementation of the settings panel exposed through the IDE Settings. */ -class SettingsComponent( - private val credentialsLoader: TranslatorCredentialsLoader = TranslatorCredentialsLoader.default() -) { +class SettingsComponent { companion object { private val LOG = Logger.getInstance(SettingsComponent::class.java) } @@ -99,11 +92,9 @@ class SettingsComponent( private val translatorsState = mutableStateListOf() private val selectedTranslatorState = mutableStateOf(null) - private val credentialDefinitionsState = mutableStateListOf() - private val credentialValuesState = mutableStateMapOf() private val enableCacheState = mutableStateOf(true) private val maxCacheSizeState = mutableStateOf("500") - private val translationIntervalState = mutableStateOf("2") + private val translationIntervalState = mutableStateOf("500") init { composePanel.preferredSize = Dimension(680, 560) @@ -115,19 +106,14 @@ class SettingsComponent( translators = translatorsState, selectedTranslator = selectedTranslatorState.value, defaultTranslatorKey = TranslatorService.getInstance().getDefaultTranslator()?.key, - credentialDefinitions = credentialDefinitionsState, - credentialValues = credentialValuesState, enableCacheState = enableCacheState, maxCacheSizeState = maxCacheSizeState, translationIntervalState = translationIntervalState, - onTranslatorSelected = { translator -> - applySelectedTranslator(translator) - }, - onCredentialValueChanged = { id, value -> - credentialValuesState[id] = value - }, + onTranslatorSelected = { translator -> applySelectedTranslator(translator) }, onShowSupportedLanguages = { translator -> SupportLanguagesDialog(translator).show() }, - onNavigateToApplyPage = { url -> BrowserUtil.browse(url) } + onConfigureTranslator = { translator -> + TranslatorConfigurationManager.showConfigurationDialog(translator) + } ) } } @@ -147,7 +133,7 @@ class SettingsComponent( translatorsState.clear() translatorsState.addAll(translators.values) if (selectedTranslatorState.value == null && translatorsState.isNotEmpty()) { - applySelectedTranslator(translatorsState.first()) + applySelectedTranslator(TranslatorService.getInstance().getDefaultTranslator()) } } @@ -155,22 +141,6 @@ class SettingsComponent( applySelectedTranslator(selected) } - fun setCredentialValues(values: Map) { - credentialValuesState.clear() - credentialDefinitionsState.forEach { descriptor -> - credentialValuesState[descriptor.id] = values[descriptor.id] ?: "" - } - } - - fun setCredentialValue(id: String, value: String) { - credentialValuesState[id] = value - } - - val credentialValues: Map - get() = credentialDefinitionsState.associate { descriptor -> - descriptor.id to (credentialValuesState[descriptor.id] ?: "") - } - fun setEnableCache(isEnable: Boolean) { enableCacheState.value = isEnable } @@ -201,26 +171,15 @@ class SettingsComponent( private fun applySelectedTranslator(translator: AbstractTranslator) { selectedTranslatorState.value = translator - credentialDefinitionsState.clear() - credentialDefinitionsState.addAll(translator.credentialDefinitions) - credentialValuesState.clear() - translator.credentialDefinitions.forEach { descriptor -> - credentialValuesState[descriptor.id] = "" - } - - credentialsLoader.load(translator) { loaded -> - if (selectedTranslatorState.value?.key == translator.key) { - translator.credentialDefinitions.forEach { descriptor -> - credentialValuesState[descriptor.id] = loaded[descriptor.id] ?: "" - } - } - } } } -private val LabelColumnWidth = 176.dp +private val LabelColumnWidth = 140.dp private val FieldMinWidth = 160.dp +private val TranslatorDropdownWidth = 280.dp private val CompactFieldHeight = 36.dp +private val FormContentSpacing = 8.dp +private const val MAX_REQUEST_INTERVAL_MS = 60_000 @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -228,15 +187,12 @@ private fun SettingsContent( translators: SnapshotStateList, selectedTranslator: AbstractTranslator?, defaultTranslatorKey: String?, - credentialDefinitions: SnapshotStateList, - credentialValues: SnapshotStateMap, enableCacheState: androidx.compose.runtime.MutableState, maxCacheSizeState: androidx.compose.runtime.MutableState, translationIntervalState: androidx.compose.runtime.MutableState, onTranslatorSelected: (AbstractTranslator) -> Unit, - onCredentialValueChanged: (String, String) -> Unit = { _, _ -> }, onShowSupportedLanguages: (AbstractTranslator) -> Unit, - onNavigateToApplyPage: (String) -> Unit + onConfigureTranslator: (AbstractTranslator) -> Unit, ) { val scrollState = rememberScrollState() @@ -251,47 +207,44 @@ private fun SettingsContent( SectionHeader(title = "Translator") SettingsFormRow(label = "Provider") { - TranslatorDropdown( - modifier = Modifier - .weight(1f) - .heightIn(min = CompactFieldHeight), - translators = translators, - selectedTranslator = selectedTranslator, - defaultTranslatorKey = defaultTranslatorKey, - onTranslatorSelected = onTranslatorSelected - ) - - selectedTranslator?.let { - TextButton(onClick = { onShowSupportedLanguages(it) }) { - Text("View supported languages") - } - } - } - - selectedTranslator?.let { provider -> - credentialDefinitions.forEach { descriptor -> - SettingsFormRow(label = descriptor.label, helperText = descriptor.description) { - IdeTextField( + Column( + modifier = Modifier.weight(1f), + verticalArrangement = LayoutArrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = LayoutArrangement.spacedBy(FormContentSpacing) + ) { + TranslatorDropdown( modifier = Modifier - .fillMaxWidth() - .widthIn(min = FieldMinWidth), - value = credentialValues[descriptor.id] ?: "", - onValueChange = { newValue -> - onCredentialValueChanged(descriptor.id, newValue.trimStart()) - }, - singleLine = true, - secureInput = descriptor.isSecret + .width(TranslatorDropdownWidth) + .heightIn(min = CompactFieldHeight), + translators = translators, + selectedTranslator = selectedTranslator, + defaultTranslatorKey = defaultTranslatorKey, + onTranslatorSelected = onTranslatorSelected ) + + selectedTranslator?.let { translator -> + if (TranslatorConfigurationManager.hasConfiguration(translator)) { + IconButton(onClick = { onConfigureTranslator(translator) }) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = "Configure translator", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } } - } - provider.credentialHelpUrl?.takeUnless { it.isBlank() }?.let { url -> - SettingsFormRow( - label = "Need credentials?", - helperText = "Click to request keys from ${provider.name}." - ) { - TextButton(onClick = { onNavigateToApplyPage(url) }) { - Text("Apply for API key") + selectedTranslator?.let { translator -> + TextButton( + onClick = { onShowSupportedLanguages(translator) }, + contentPadding = PaddingValues(0.dp), + modifier = Modifier.align(Alignment.Start) + ) { + Text("See supported languages") } } } @@ -303,6 +256,7 @@ private fun SettingsContent( SettingsFormRow(label = "Use cache") { Switch( + modifier = Modifier.scale(0.8f), checked = enableCacheState.value, onCheckedChange = { enableCacheState.value = it } ) @@ -317,8 +271,7 @@ private fun SettingsContent( } ) { IdeTextField( - modifier = Modifier - .widthIn(min = FieldMinWidth, max = 220.dp), + modifier = Modifier.widthIn(min = FieldMinWidth, max = 220.dp), value = maxCacheSizeState.value, onValueChange = { newValue -> val digits = newValue.filter { it.isDigit() } @@ -335,19 +288,33 @@ private fun SettingsContent( SectionHeader(title = "Requests") SettingsFormRow( - label = "Interval (seconds)", - helperText = "Delay between translation requests to avoid provider rate limits." + label = "Interval", + helperText = "Delay between translation requests in milliseconds (max 60,000 ms ≈ 1 minute)." ) { IdeTextField( - modifier = Modifier - .widthIn(min = FieldMinWidth, max = 220.dp), + modifier = Modifier.widthIn(min = FieldMinWidth, max = 220.dp), value = translationIntervalState.value, onValueChange = { newValue -> val digits = newValue.filter { it.isDigit() } - translationIntervalState.value = digits.ifEmpty { "0" } + val clampedValue = digits.toLongOrNull() + ?.coerceAtMost(MAX_REQUEST_INTERVAL_MS.toLong()) + ?.toInt() + translationIntervalState.value = when { + digits.isEmpty() -> "0" + clampedValue == null -> MAX_REQUEST_INTERVAL_MS.toString() + else -> clampedValue.toString() + } }, singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), + suffix = { + Text( + text = "ms", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp) + ) + } ) } } @@ -389,7 +356,7 @@ private fun SettingsFormRow( Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = LayoutArrangement.spacedBy(12.dp), + horizontalArrangement = LayoutArrangement.spacedBy(FormContentSpacing), content = content ) } @@ -399,94 +366,12 @@ private fun SettingsFormRow( text = it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = LabelColumnWidth + 12.dp, top = 4.dp) + modifier = Modifier.padding(start = LabelColumnWidth + FormContentSpacing, top = 4.dp) ) } } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun IdeTextField( - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - readOnly: Boolean = false, - placeholder: (@Composable (() -> Unit))? = null, - leadingIcon: (@Composable (() -> Unit))? = null, - trailingIcon: (@Composable (() -> Unit))? = null, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - secureInput: Boolean = false, - singleLine: Boolean = true, -) { - val interactionSource = remember { MutableInteractionSource() } - val colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f), - disabledBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurface, - disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), - cursorColor = MaterialTheme.colorScheme.primary, - focusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - ) - - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = modifier - .heightIn(min = CompactFieldHeight) - .defaultMinSize(minHeight = CompactFieldHeight), - enabled = enabled, - readOnly = readOnly, - textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), - singleLine = singleLine, - keyboardOptions = keyboardOptions, - interactionSource = interactionSource, - ) { innerTextField -> - val visualTransformation = if (secureInput) { - PasswordVisualTransformation() - } else { - VisualTransformation.None - } - OutlinedTextFieldDefaults.DecorationBox( - value = value, - visualTransformation = visualTransformation, - innerTextField = innerTextField, - placeholder = placeholder, - label = null, - prefix = null, - suffix = null, - supportingText = null, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - singleLine = singleLine, - enabled = enabled, - isError = false, - interactionSource = interactionSource, - colors = colors, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), - container = { - OutlinedTextFieldDefaults.Container( - enabled = enabled, - isError = false, - interactionSource = interactionSource, - colors = colors - ) - } - ) - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TranslatorDropdown( diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt index dad1aa7..ba863d9 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt @@ -56,9 +56,6 @@ class SettingsConfigurable : Configurable { settingsComponent?.let { component -> component.setTranslators(translators) component.setSelectedTranslator(translators[selected.key]!!) - component.setCredentialValues( - settingsState.getCredentials(selected.key, selected.credentialDefinitions) - ) component.setEnableCache(settingsState.isEnableCache) component.setMaxCacheSize(settingsState.maxCacheSize) component.setTranslationInterval(settingsState.translationInterval) @@ -71,12 +68,6 @@ class SettingsConfigurable : Configurable { var isChanged = settingsState.selectedTranslator != selectedTranslator - val expectedCredentials = settingsState.getCredentials( - selectedTranslator.key, - selectedTranslator.credentialDefinitions - ) - val currentCredentials = settingsComponent?.credentialValues ?: emptyMap() - isChanged = isChanged || expectedCredentials != currentCredentials isChanged = isChanged || settingsState.isEnableCache != (settingsComponent?.isEnableCache ?: false) isChanged = isChanged || settingsState.maxCacheSize != (settingsComponent?.maxCacheSize ?: 0) isChanged = isChanged || settingsState.translationInterval != (settingsComponent?.translationInterval ?: 0) @@ -94,23 +85,17 @@ class SettingsConfigurable : Configurable { LOG.info("apply selectedTranslator: ${selectedTranslator.name}") // Verify credential requirements - val credentialValues = settingsComponent?.credentialValues ?: emptyMap() - val credentialDefinitions = selectedTranslator.credentialDefinitions.associateBy { it.id } + settingsState.selectedTranslator = selectedTranslator + selectedTranslator.credentialDefinitions.forEach { descriptor -> if (descriptor.required) { - val value = credentialValues[descriptor.id] - if (value.isNullOrBlank()) { + val storedValue = settingsState.getCredential(selectedTranslator.key, descriptor) + if (storedValue.isBlank()) { throw ConfigurationException("${descriptor.label} not configured") } } } - settingsState.selectedTranslator = selectedTranslator - credentialValues.forEach { (id, value) -> - val descriptor = credentialDefinitions[id] ?: return@forEach - settingsState.setCredential(selectedTranslator.key, descriptor, value) - } - settingsComponent?.let { component -> settingsState.isEnableCache = component.isEnableCache settingsState.maxCacheSize = component.maxCacheSize @@ -130,9 +115,6 @@ class SettingsConfigurable : Configurable { val selectedTranslator = settingsState.selectedTranslator settingsComponent?.let { component -> component.setSelectedTranslator(selectedTranslator) - component.setCredentialValues( - settingsState.getCredentials(selectedTranslator.key, selectedTranslator.credentialDefinitions) - ) component.setEnableCache(settingsState.isEnableCache) component.setMaxCacheSize(settingsState.maxCacheSize) component.setTranslationInterval(settingsState.translationInterval) diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt index 588fea4..e4f8e0a 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt @@ -131,6 +131,7 @@ class SettingsState : PersistentStateComponent { override fun loadState(state: State) { this.state = state + normalizeTranslationInterval() migrateLegacyAppIds() } @@ -141,7 +142,7 @@ class SettingsState : PersistentStateComponent { var appIds: MutableMap = mutableMapOf(), var isEnableCache: Boolean = true, var maxCacheSize: Int = 500, - var translationInterval: Int = 2, // 2 second + var translationInterval: Int = 500, // milliseconds var isSkipNonTranslatable: Boolean = false ) @@ -150,6 +151,15 @@ class SettingsState : PersistentStateComponent { return credentialSecureStorage.getOrPut(key) { SecureStorage(key) } } + private fun normalizeTranslationInterval() { + if (state.translationInterval in 1..10) { + state.translationInterval *= 1000 + } + if (state.translationInterval <= 0) { + state.translationInterval = 500 + } + } + private fun readSecret(translatorKey: String, descriptor: TranslatorCredentialDescriptor): String { val storage = secureStorage(translatorKey, descriptor.id) val value = storage.read() diff --git a/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt b/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt new file mode 100644 index 0000000..92d1beb --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt @@ -0,0 +1,30 @@ +package com.airsaid.localization.config + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.impl.google.GoogleTranslatorSettingsDialog +import com.intellij.openapi.diagnostic.Logger + +object TranslatorConfigurationManager { + + private val LOG = Logger.getInstance(TranslatorConfigurationManager::class.java) + + fun showConfigurationDialog(translator: AbstractTranslator): Boolean { + return when (translator.key) { + "Google" -> GoogleTranslatorSettingsDialog().showAndGet() + else -> showCredentialDialog(translator) + } + } + + fun hasConfiguration(translator: AbstractTranslator): Boolean { + return translator.key == "Google" || translator.credentialDefinitions.isNotEmpty() + } + + private fun showCredentialDialog(translator: AbstractTranslator): Boolean { + if (translator.credentialDefinitions.isEmpty()) { + LOG.debug("Translator ${translator.key} has no configurable credentials") + return false + } + val dialog = TranslatorCredentialsDialog(translator, SettingsState.getInstance()) + return dialog.showAndGet() + } +} diff --git a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt new file mode 100644 index 0000000..895d235 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt @@ -0,0 +1,123 @@ +package com.airsaid.localization.config + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslatorCredentialDescriptor +import com.airsaid.localization.ui.IdeTheme +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.ui.DialogWrapper +import java.awt.Dimension +import javax.swing.JComponent + +class TranslatorCredentialsDialog( + private val translator: AbstractTranslator, + private val settingsState: SettingsState, +) : DialogWrapper(true) { + + private val credentialValuesState: SnapshotStateMap = mutableStateMapOf() + private val composePanel = ComposePanel() + + init { + title = "${translator.name} Settings" + + translator.credentialDefinitions.forEach { descriptor -> + credentialValuesState[descriptor.id] = settingsState.getCredential(translator.key, descriptor) + } + + composePanel.preferredSize = Dimension(460, 280) + composePanel.setContent { + IdeTheme { + CredentialsContent() + } + } + + init() + } + + override fun createCenterPanel(): JComponent = composePanel + + @Composable + private fun CredentialsContent() { + val descriptors = remember { translator.credentialDefinitions } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + descriptors.forEach { descriptor -> + CredentialField(descriptor = descriptor) + Spacer(modifier = Modifier.height(12.dp)) + } + + translator.credentialHelpUrl?.takeUnless { it.isNullOrBlank() }?.let { url -> + TextButton(onClick = { BrowserUtil.browse(url) }) { + Text("How to obtain credentials?") + } + } + } + } + + @Composable + private fun CredentialField(descriptor: TranslatorCredentialDescriptor) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = descriptor.label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(6.dp)) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = credentialValuesState[descriptor.id] ?: "", + onValueChange = { newValue -> credentialValuesState[descriptor.id] = newValue.trimStart() }, + singleLine = true, + visualTransformation = if (descriptor.isSecret) PasswordVisualTransformation() else VisualTransformation.None, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f), + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface + ) + ) + descriptor.description?.takeIf { it.isNotBlank() }?.let { helper -> + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = helper, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + override fun doOKAction() { + credentialValuesState.forEach { (id, value) -> + translator.credentialDefinitions.firstOrNull { it.id == id }?.let { descriptor -> + settingsState.setCredential(translator.key, descriptor, value.trim()) + } + } + super.doOKAction() + } +} diff --git a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt deleted file mode 100644 index 5a0061b..0000000 --- a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsLoader.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.airsaid.localization.config - -import com.airsaid.localization.translate.AbstractTranslator -import com.intellij.openapi.application.ApplicationManager - -fun interface TranslatorCredentialsLoader { - fun load(translator: AbstractTranslator, onLoaded: (credentials: Map) -> Unit) - - companion object { - fun default(): TranslatorCredentialsLoader = DefaultTranslatorCredentialsLoader - } -} - -private object DefaultTranslatorCredentialsLoader : TranslatorCredentialsLoader { - private val application = ApplicationManager.getApplication() - - override fun load(translator: AbstractTranslator, onLoaded: (Map) -> Unit) { - application.executeOnPooledThread { - val settingsState = SettingsState.getInstance() - val credentials = settingsState.getCredentials(translator.key, translator.credentialDefinitions) - application.invokeLater { - onLoaded(credentials) - } - } - } -} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt new file mode 100644 index 0000000..3453eec --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt @@ -0,0 +1,24 @@ +package com.airsaid.localization.translate.impl.google + +import com.intellij.util.io.RequestBuilder + +private const val GOOGLE_REFERER = "https://translate.google.com/" + +internal fun googleApiUrl(path: String): String { + val settings = GoogleTranslatorSettings.getInstance() + val base = if (settings.useCustomServer) settings.serverUrl else GoogleTranslatorSettings.DEFAULT_SERVER_URL + val normalizedBase = base.trim().removeSuffix("/") + val normalizedPath = if (path.startsWith('/')) path.drop(1) else path + return "$normalizedBase/$normalizedPath" +} + +internal fun RequestBuilder.withGoogleHeaders(): RequestBuilder = apply { + tuner { connection -> + connection.setRequestProperty("Referer", GOOGLE_REFERER) + connection.setRequestProperty( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" + ) + } +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt new file mode 100644 index 0000000..c5b3480 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt @@ -0,0 +1,130 @@ +package com.airsaid.localization.translate.impl.google + +import com.airsaid.localization.translate.util.HttpRequestFactory +import com.intellij.openapi.diagnostic.Logger +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import java.lang.StrictMath.abs +import java.util.concurrent.ThreadLocalRandom +import java.util.regex.Pattern + +private const val TOKEN_TTL_MILLIS = 60 * 60 * 1000L +private const val ELEMENT_JS_PATH = "/translate_a/element.js" + +private val TKK_PATTERN: Pattern = Pattern.compile("tkk='(\\d+).(-?\\d+)'") + +private data class Token(val value1: Long, val value2: Long, val hour: Long) + +private val LOG: Logger = Logger.getInstance("GoogleToken") + +private var cachedToken: Token? = null +private val tokenLock = Any() + +internal fun resetGoogleTokenCache() { + synchronized(tokenLock) { + cachedToken = null + } +} + +@RequiresBackgroundThread +internal fun currentTkk(): Pair { + synchronized(tokenLock) { + val nowHour = System.currentTimeMillis() / TOKEN_TTL_MILLIS + cachedToken?.takeIf { it.hour == nowHour }?.let { return it.value1 to it.value2 } + + val updated = fetchFromGoogle()?.takeIf { it.hour == nowHour } ?: generateLocal(nowHour) + cachedToken = updated + return updated.value1 to updated.value2 + } +} + +private fun fetchFromGoogle(): Token? { + return try { + val url = googleApiUrl(ELEMENT_JS_PATH) + val response = HttpRequestFactory.get(url, 10_000) + .withGoogleHeaders() + .connect { it.readString(null) } + val matcher = TKK_PATTERN.matcher(response) + if (!matcher.find()) { + LOG.warn("TKK not found in element.js response") + null + } else { + val nowHour = System.currentTimeMillis() / TOKEN_TTL_MILLIS + val value1 = matcher.group(1).toLong() + val value2 = matcher.group(2).toLong() + Token(value1, value2, nowHour) + } + } catch (error: Throwable) { + LOG.warn("Failed to refresh Google TKK", error) + null + } +} + +private fun generateLocal(hour: Long): Token { + val random = ThreadLocalRandom.current() + val value = abs(random.nextInt().toLong()) + random.nextInt().toLong() + return Token(hour, value, hour) +} + +internal fun String.tk(): String { + val (d, e) = currentTkk() + val bytes = mutableListOf() + var index = 0 + while (index < length) { + var charCode = this[index].code + when { + charCode < 0x80 -> bytes += charCode.toLong() + charCode < 0x800 -> { + bytes += (charCode shr 6 or 0xC0).toLong() + bytes += (charCode and 0x3F or 0x80).toLong() + } + charCode in 0xD800..0xDBFF && index + 1 < length -> { + val next = this[index + 1].code + if (next and 0xFC00 == 0xDC00) { + charCode = 0x10000 + ((charCode and 0x3FF) shl 10) + (next and 0x3FF) + bytes += (charCode shr 18 or 0xF0).toLong() + bytes += (charCode shr 12 and 0x3F or 0x80).toLong() + bytes += (charCode shr 6 and 0x3F or 0x80).toLong() + bytes += (charCode and 0x3F or 0x80).toLong() + index++ + } else { + bytes += (charCode shr 12 or 0xE0).toLong() + bytes += (charCode shr 6 and 0x3F or 0x80).toLong() + bytes += (charCode and 0x3F or 0x80).toLong() + } + } + else -> { + bytes += (charCode shr 12 or 0xE0).toLong() + bytes += (charCode shr 6 and 0x3F or 0x80).toLong() + bytes += (charCode and 0x3F or 0x80).toLong() + } + } + index++ + } + + var result = d + for (byte in bytes) { + result += byte + result = applyTransformation(result, "+-a^+6") + } + result = applyTransformation(result, "+-3^+b+-f") + result = result xor e + if (result < 0) { + result = (result and 0x7FFFFFFF) + 0x80000000 + 1 + } + result %= 1_000_000 + + return "$result.${result xor d}" +} + +private fun applyTransformation(value: Long, op: String): Long { + var acc = value + var i = 0 + while (i < op.length - 2) { + val shiftChar = op[i + 2] + val shift = if (shiftChar >= 'a') shiftChar.code - 87 else shiftChar.digitToInt() + val operand = if (op[i + 1] == '+') acc ushr shift else acc shl shift + acc = if (op[i] == '+') (acc + operand) and 0xFFFFFFFFL else acc xor operand + i += 3 + } + return acc +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResponse.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResponse.kt new file mode 100644 index 0000000..d625327 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResponse.kt @@ -0,0 +1,23 @@ +package com.airsaid.localization.translate.impl.google + +import com.google.gson.annotations.SerializedName + +internal data class GoogleTranslationResponse( + @SerializedName("sentences") val sentences: List?, + @SerializedName("src") val sourceLanguage: String?, + @SerializedName("ld_result") val languageDetection: LanguageDetectionResult?, + @SerializedName("error") val error: ErrorBody? = null +) { + data class Sentence( + @SerializedName("trans") val translation: String?, + @SerializedName("orig") val original: String? + ) + + data class LanguageDetectionResult( + @SerializedName("srclangs") val sourceLanguages: List? + ) + + data class ErrorBody( + @SerializedName("message") val message: String? = null + ) +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt new file mode 100644 index 0000000..6ded492 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt @@ -0,0 +1,71 @@ +package com.airsaid.localization.translate.impl.google + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.util.GsonUtil +import com.airsaid.localization.translate.util.UrlBuilder +import com.google.auto.service.AutoService +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Pair +import com.intellij.util.io.RequestBuilder +import icons.PluginIcons +import javax.swing.Icon + +@AutoService(AbstractTranslator::class) +class GoogleTranslator : AbsGoogleTranslator() { + + private val log = Logger.getInstance(GoogleTranslator::class.java) + + override val key: String = KEY + override val name: String = "Google" + override val icon: Icon = PluginIcons.GOOGLE_ICON + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + val source = fromLang.takeIf { it != Languages.AUTO }?.translationCode ?: "auto" + val builder = UrlBuilder(googleApiUrl(TRANSLATE_PATH)) + .addQueryParameter("client", "gtx") + .addQueryParameter("sl", source) + .addQueryParameter("tl", toLang.translationCode) + .addQueryParameters("dt", "t", "bd", "rm", "qca", "ex") + .addQueryParameter("dj", "1") + .addQueryParameter("ie", "UTF-8") + .addQueryParameter("oe", "UTF-8") + .addQueryParameter("hl", Languages.ENGLISH.translationCode) + .addQueryParameter("tk", text.tk()) + return builder.build() + } + + override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + return listOf(Pair.create("q", text)) + } + + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.withGoogleHeaders() + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + val response = GsonUtil.getInstance().gson.fromJson(resultText, GoogleTranslationResponse::class.java) + response.error?.message?.let { message -> + throw TranslationException(fromLang, toLang, text, message) + } + + val translation = response.sentences + ?.mapNotNull { it.translation } + ?.joinToString(separator = "") + ?.trim() + .orEmpty() + + if (translation.isEmpty()) { + log.warn("Empty translation from Google API: $resultText") + return "" + } + return translation + } + + companion object { + private const val KEY = "Google" + private const val TRANSLATE_PATH = "/translate_a/single" + } +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettings.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettings.kt new file mode 100644 index 0000000..f50dca3 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettings.kt @@ -0,0 +1,43 @@ +package com.airsaid.localization.translate.impl.google + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@Service +@State(name = "com.airsaid.localization.GoogleTranslatorSettings", storages = [Storage("googleTranslatorSettings.xml")]) +class GoogleTranslatorSettings : PersistentStateComponent { + + data class State( + var useCustomServer: Boolean = false, + var serverUrl: String = DEFAULT_SERVER_URL + ) + + private var state = State() + + var useCustomServer: Boolean + get() = state.useCustomServer + set(value) { + state = state.copy(useCustomServer = value) + } + + var serverUrl: String + get() = state.serverUrl.ifBlank { DEFAULT_SERVER_URL } + set(value) { + state = state.copy(serverUrl = value.ifBlank { DEFAULT_SERVER_URL }) + } + + override fun getState(): State = state + + override fun loadState(state: State) { + this.state = state + } + + companion object { + const val DEFAULT_SERVER_URL = "https://translate.googleapis.com" + + fun getInstance(): GoogleTranslatorSettings = service() + } +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt new file mode 100644 index 0000000..aa9c47a --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt @@ -0,0 +1,122 @@ +package com.airsaid.localization.translate.impl.google + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposePanel +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.airsaid.localization.ui.IdeTheme +import com.airsaid.localization.ui.components.IdeCheckbox +import com.airsaid.localization.ui.components.IdeTextField +import com.intellij.openapi.ui.DialogWrapper +import javax.swing.JComponent + +class GoogleTranslatorSettingsDialog : DialogWrapper(true) { + + private val settings = GoogleTranslatorSettings.getInstance() + private val composePanel = ComposePanel() + + init { + title = "Google Translator Settings" + composePanel.setContent { + IdeTheme { + SettingsContent() + } + } + init() + } + + override fun createCenterPanel(): JComponent = composePanel + + @Composable + private fun SettingsContent() { + var useCustomServer by remember { mutableStateOf(settings.useCustomServer) } + var serverUrl by remember { mutableStateOf(settings.serverUrl) } + val toggleInteraction = remember { MutableInteractionSource() } + + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = useCustomServer, + interactionSource = toggleInteraction, + indication = null, + role = Role.Checkbox, + onValueChange = { + useCustomServer = it + if (!it) { + serverUrl = settings.serverUrl + } + } + ), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IdeCheckbox(checked = useCustomServer) + Text( + text = "Use custom server", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = "Server URL", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + IdeTextField( + modifier = Modifier.fillMaxWidth(), + value = serverUrl, + onValueChange = { serverUrl = it.trimStart() }, + singleLine = true, + enabled = useCustomServer, + placeholder = { + Text( + text = GoogleTranslatorSettings.DEFAULT_SERVER_URL, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + + Text( + text = "Defaults to translate.googleapis.com when not specified.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + footer = { + settings.useCustomServer = useCustomServer + if (useCustomServer) { + settings.serverUrl = serverUrl.ifBlank { GoogleTranslatorSettings.DEFAULT_SERVER_URL } + } + } + } + + private var footer: (() -> Unit)? = null + + override fun doOKAction() { + footer?.invoke() + super.doOKAction() + } +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.kt deleted file mode 100644 index 40e1ef1..0000000 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslationResult.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.airsaid.localization.translate.impl.googleapi - -import com.airsaid.localization.translate.TranslationResult - -/** - * @author airsaid - */ -data class GoogleApiTranslationResult( - var data: Data? = null, - var error: Error? = null -) : TranslationResult { - - fun isSuccess(): Boolean = data != null && error == null - - override val translationResult: String - get() = data?.translations?.get(0)?.translatedText ?: "" - - data class Data( - var translations: Array? = null - ) { - data class Translation( - var translatedText: String? = null, - var detectedSourceLanguage: String? = null - ) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Data - - if (translations != null) { - if (other.translations == null) return false - if (!translations.contentEquals(other.translations)) return false - } else if (other.translations != null) return false - - return true - } - - override fun hashCode(): Int { - return translations?.contentHashCode() ?: 0 - } - } - - data class Error( - var code: Int = 0, - var message: String? = null - ) -} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt deleted file mode 100644 index 5933269..0000000 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/googleapi/GoogleApiTranslator.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.airsaid.localization.translate.impl.googleapi - -import com.airsaid.localization.translate.AbstractTranslator -import com.airsaid.localization.translate.TranslationException -import com.airsaid.localization.translate.TranslatorCredentialDescriptor -import com.airsaid.localization.translate.impl.google.AbsGoogleTranslator -import com.airsaid.localization.translate.lang.Lang -import com.airsaid.localization.translate.util.GsonUtil -import com.google.auto.service.AutoService -import com.intellij.openapi.diagnostic.Logger -import com.intellij.openapi.util.Pair -import com.intellij.util.io.RequestBuilder - -/** - * @author airsaid - */ -@AutoService(AbstractTranslator::class) -class GoogleApiTranslator : AbsGoogleTranslator() { - - companion object { - private val LOG = Logger.getInstance(GoogleApiTranslator::class.java) - private const val KEY = "GoogleApi" - private const val HOST_URL = "https://translation.googleapis.com" - private const val TRANSLATE_URL = "$HOST_URL/language/translate/v2" - private const val APPLY_APP_ID_URL = "https://cloud.google.com/translate" - } - - override val key: String = KEY - - override val name: String = "Google" - - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = TRANSLATE_URL - - override val credentialDefinitions = listOf( - TranslatorCredentialDescriptor(id = "appKey", label = "API Key", isSecret = true) - ) - - override val credentialHelpUrl: String? = APPLY_APP_ID_URL - - override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { - return listOf( - Pair.create("q", text), - Pair.create("target", toLang.translationCode), - Pair.create("key", credentialValue("appKey")), - Pair.create("format", "text") - ) - } - - override fun configureRequestBuilder(requestBuilder: RequestBuilder) { - requestBuilder.tuner { connection -> connection.setRequestProperty("Referer", HOST_URL) } - } - - override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { - LOG.info("parsingResult: $resultText") - val result = GsonUtil.getInstance().gson.fromJson(resultText, GoogleApiTranslationResult::class.java) - return if (result.isSuccess()) { - result.translationResult - } else { - val message = if (result.error != null) { - "${result.error!!.message}(${result.error!!.code})" - } else { - "Unknown error" - } - throw TranslationException(fromLang, toLang, text, message) - } - } -} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt new file mode 100644 index 0000000..79758e1 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt @@ -0,0 +1,119 @@ +package com.airsaid.localization.translate.impl.microsoft + +import com.airsaid.localization.translate.util.HttpRequestFactory +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.diagnostic.logger +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.intellij.util.io.HttpRequests +import java.io.IOException +import java.util.Base64 +import java.util.Date +import java.util.concurrent.atomic.AtomicReference +import kotlin.jvm.Volatile + +/** + * Fetches and caches Microsoft Translator access tokens using the same public + * endpoint leveraged by the Microsoft Edge browser. This removes the need for + * user-provided subscription keys. + */ +@Service +class MicrosoftEdgeAuthService { + + private val tokenRef = AtomicReference() + + private val gson = Gson() + + @RequiresBackgroundThread + @Throws(MicrosoftAuthenticationException::class) + fun getAccessToken(): String { + val currentTime = System.currentTimeMillis() + tokenRef.get()?.takeIf { currentTime < it.expireAtMillis }?.let { return it.value } + + synchronized(this) { + tokenRef.get()?.takeIf { System.currentTimeMillis() < it.expireAtMillis }?.let { return it.value } + val token = requestAccessToken() + val expireAt = extractExpirationTime(token) + val cached = Token(token, expireAt) + tokenRef.set(cached) + LOG.debug("Fetched Microsoft access token. Expires at ${Date(expireAt)}") + return cached.value + } + } + + @Throws(MicrosoftAuthenticationException::class) + private fun requestAccessToken(): String { + val endpoint = authUrlOverride ?: AUTH_URL + return try { + HttpRequestFactory.get(endpoint, CONNECTION_TIMEOUT_MS) + .tuner { connection -> + // Endpoint expects a modern browser style user agent + connection.setRequestProperty("Accept", "*/*") + connection.setRequestProperty( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" + ) + } + .connect { request -> request.readString(null) } + .also { token -> + if (!TOKEN_REGEX.matches(token.trim())) { + throw MicrosoftAuthenticationException("Authentication failed: invalid token format") + } + } + } catch (statusEx: HttpRequests.HttpStatusException) { + throw MicrosoftAuthenticationException( + "Authentication failed: HTTP ${statusEx.statusCode}", + statusEx + ) + } catch (io: IOException) { + throw MicrosoftAuthenticationException("Authentication failed: ${io.message}", io) + } + } + + private fun extractExpirationTime(token: String): Long { + return try { + val payload = token.split('.') + .getOrNull(1) + ?.let { chunk -> + val decoder = Base64.getUrlDecoder() + String(decoder.decode(chunk)) + } + ?: return System.currentTimeMillis() + DEFAULT_EXPIRATION_MS + val jwt = gson.fromJson(payload, JwtPayload::class.java) + jwt.expirationTime * 1_000L - PRE_EXPIRATION_BUFFER_MS + } catch (_: Throwable) { + System.currentTimeMillis() + DEFAULT_EXPIRATION_MS + } + } + + private data class Token(val value: String, val expireAtMillis: Long) + + private data class JwtPayload(@SerializedName("exp") val expirationTime: Long) + + internal fun clearCacheForTests() { + tokenRef.set(null) + } + + companion object { + private val LOG: Logger = logger() + + private const val AUTH_URL = "https://edge.microsoft.com/translate/auth" + private const val CONNECTION_TIMEOUT_MS = 15_000 + private const val PRE_EXPIRATION_BUFFER_MS = 2 * 60 * 1_000L // Refresh 2 minutes early + private const val DEFAULT_EXPIRATION_MS = 8 * 60 * 1_000L + private val TOKEN_REGEX = Regex("""^[A-Za-z0-9\-_]+(\.[A-Za-z0-9\-_]+){2}$""") + + @Volatile + internal var authUrlOverride: String? = null + + fun getInstance(): MicrosoftEdgeAuthService = service() + + internal fun resetForTests() { + getInstance().clearCacheForTests() + } + } +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftExceptions.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftExceptions.kt new file mode 100644 index 0000000..ddd4500 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftExceptions.kt @@ -0,0 +1,8 @@ +package com.airsaid.localization.translate.impl.microsoft + +import java.io.IOException + +class MicrosoftAuthenticationException( + message: String? = null, + cause: Throwable? = null +) : IOException(message, cause) diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt index cb88e6f..a967143 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt @@ -28,6 +28,7 @@ import com.intellij.openapi.diagnostic.Logger import com.intellij.util.io.RequestBuilder import icons.PluginIcons import javax.swing.Icon +import kotlin.jvm.Volatile /** * @author airsaid @@ -38,9 +39,16 @@ class MicrosoftTranslator : AbstractTranslator() { companion object { private val LOG = Logger.getInstance(MicrosoftTranslator::class.java) private const val KEY = "Microsoft" - private const val HOST_URL = "https://api.cognitive.microsofttranslator.com" - private const val TRANSLATE_URL = "$HOST_URL/translate" - private const val APPLY_APP_ID_URL = "https://docs.microsoft.com/azure/cognitive-services/translator/translator-how-to-signup" + private const val DEFAULT_HOST_URL = "https://api.cognitive.microsofttranslator.com" + + @Volatile + internal var hostOverride: String? = null + + private val HOST_URL: String + get() = hostOverride ?: DEFAULT_HOST_URL + + private val TRANSLATE_URL: String + get() = "$HOST_URL/translate" } private var _supportedLanguages: MutableList? = null @@ -51,11 +59,9 @@ class MicrosoftTranslator : AbstractTranslator() { override val icon: Icon? = PluginIcons.MICROSOFT_ICON - override val credentialDefinitions = listOf( - TranslatorCredentialDescriptor(id = "appKey", label = "KEY", isSecret = true) - ) + override val credentialDefinitions: List = emptyList() - override val credentialHelpUrl: String? = APPLY_APP_ID_URL + override val credentialHelpUrl: String? = null override val supportedLanguages: List get() { @@ -166,7 +172,8 @@ class MicrosoftTranslator : AbstractTranslator() { override fun configureRequestBuilder(requestBuilder: RequestBuilder) { requestBuilder.tuner { connection -> - connection.setRequestProperty("Ocp-Apim-Subscription-Key", credentialValue("appKey")) + val token = MicrosoftEdgeAuthService.getInstance().getAccessToken() + connection.setRequestProperty("Authorization", "Bearer $token") connection.setRequestProperty("Content-type", "application/json") } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt b/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt index 3f589fa..238a11d 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt @@ -142,11 +142,11 @@ class TranslatorService { return "${fromLang.code}_${toLang.code}_$text" } - private fun delay(second: Int) { - if (second <= 0) return + private fun delay(milliseconds: Int) { + if (milliseconds <= 0) return try { - LOG.info("doTranslate delay time: $second second.") - Thread.sleep(second * 1000L) + LOG.info("doTranslate delay time: ${milliseconds} ms.") + Thread.sleep(milliseconds.toLong()) } catch (e: InterruptedException) { e.printStackTrace() } @@ -158,9 +158,9 @@ class TranslatorService { fun getInstance(): TranslatorService = service() internal fun selectDefaultTranslator(translators: Map): AbstractTranslator { - val default = translators.values.first() - LOG.info("Selected ${default.key} as default translator.") - return default + val preferred = translators["Microsoft"] ?: translators.values.first() + LOG.info("Selected ${preferred.key} as default translator.") + return preferred } } } diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt index 0d419ac..3498e15 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -74,6 +74,7 @@ import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.flagEmoji import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.utils.LanguageUtil +import com.airsaid.localization.ui.components.IdeCheckbox import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper @@ -450,43 +451,6 @@ private fun OptionItem( } } -@Composable -private fun IdeCheckbox( - checked: Boolean, - modifier: Modifier = Modifier, - enabled: Boolean = true, -) { - val shape = RoundedCornerShape(3.dp) - val colors = MaterialTheme.colorScheme - val backgroundColor = when { - !enabled -> colors.surface - checked -> colors.primary - else -> colors.surface - } - val borderColor = when { - !enabled -> colors.outline.copy(alpha = 0.3f) - checked -> colors.primary - else -> colors.outline.copy(alpha = 0.7f) - } - - Box( - modifier = modifier - .size(16.dp) - .border(1.dp, borderColor, shape) - .background(backgroundColor, shape), - contentAlignment = Alignment.Center, - ) { - if (checked) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = colors.onPrimary, - modifier = Modifier.size(10.dp), - ) - } - } -} - @OptIn(ExperimentalFoundationApi::class) @Composable private fun TooltipIcon(text: String) { diff --git a/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt b/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt new file mode 100644 index 0000000..49cfb04 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt @@ -0,0 +1,152 @@ +package com.airsaid.localization.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +private val CompactFieldHeight = 36.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("LongParameterList") +fun IdeTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + placeholder: (@Composable (() -> Unit))? = null, + leadingIcon: (@Composable (() -> Unit))? = null, + trailingIcon: (@Composable (() -> Unit))? = null, + prefix: (@Composable (() -> Unit))? = null, + suffix: (@Composable (() -> Unit))? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + secureInput: Boolean = false, + singleLine: Boolean = true, +) { + val interactionSource = remember { MutableInteractionSource() } + val colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f), + disabledBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + cursorColor = MaterialTheme.colorScheme.primary, + focusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .heightIn(min = CompactFieldHeight) + .defaultMinSize(minHeight = CompactFieldHeight), + enabled = enabled, + readOnly = readOnly, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), + singleLine = singleLine, + keyboardOptions = keyboardOptions, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + interactionSource = interactionSource, + ) { innerTextField -> + val visualTransformation = if (secureInput) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + } + OutlinedTextFieldDefaults.DecorationBox( + value = value, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = null, + prefix = prefix, + suffix = suffix, + supportingText = null, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + singleLine = singleLine, + enabled = enabled, + isError = false, + interactionSource = interactionSource, + colors = colors, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + container = { + OutlinedTextFieldDefaults.Container( + enabled = enabled, + isError = false, + interactionSource = interactionSource, + colors = colors, + ) + } + ) + } +} + +@Composable +fun IdeCheckbox( + checked: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val shape = RoundedCornerShape(3.dp) + val colors = MaterialTheme.colorScheme + val backgroundColor = when { + !enabled -> colors.surface + checked -> colors.primary + else -> colors.surface + } + val borderColor = when { + !enabled -> colors.outline.copy(alpha = 0.3f) + checked -> colors.primary + else -> colors.outline.copy(alpha = 0.7f) + } + + Box( + modifier = modifier + .size(16.dp) + .border(1.dp, borderColor, shape) + .background(backgroundColor, shape), + contentAlignment = Alignment.Center, + ) { + if (checked) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = colors.onPrimary, + modifier = Modifier.size(10.dp), + ) + } + } +} diff --git a/src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt b/src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt deleted file mode 100644 index e37097a..0000000 --- a/src/test/kotlin/com/airsaid/localization/config/SettingsComponentTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.airsaid.localization.config - -import com.airsaid.localization.translate.AbstractTranslator -import com.airsaid.localization.translate.lang.Lang -import com.intellij.openapi.application.ApplicationManager -import com.intellij.testFramework.junit5.TestApplication -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -@TestApplication -class SettingsComponentTest { - - @Test - fun `loads credentials via loader without touching password safe on EDT`() { - val loader = RecordingCredentialsLoader() - - ApplicationManager.getApplication().invokeAndWait { - val component = SettingsComponent(loader) - component.setTranslators(mapOf(TEST_TRANSLATOR.key to TEST_TRANSLATOR)) - component.setSelectedTranslator(TEST_TRANSLATOR) - - assertTrue(loader.loadCalledOnEdt, "Credentials loader should be triggered on EDT") - - loader.complete("test-app-id", "test-app-key") - - val credentials = component.credentialValues - assertEquals("test-app-id", credentials["appId"]) - assertEquals("test-app-key", credentials["appKey"]) - } - } - - private class RecordingCredentialsLoader : TranslatorCredentialsLoader { - @Volatile - var loadCalledOnEdt: Boolean = false - private var callback: ((Map) -> Unit)? = null - - override fun load(translator: AbstractTranslator, onLoaded: (Map) -> Unit) { - loadCalledOnEdt = ApplicationManager.getApplication().isDispatchThread - callback = onLoaded - } - - fun complete(appId: String, appKey: String) { - ApplicationManager.getApplication().invokeAndWait { - callback?.invoke(mapOf("appId" to appId, "appKey" to appKey)) - } - } - } - - private companion object { - val TEST_TRANSLATOR: AbstractTranslator = object : AbstractTranslator() { - override val key: String = "test" - override val name: String = "Test" - override val supportedLanguages: List = emptyList() - - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = - throw UnsupportedOperationException() - - override fun parsingResult( - fromLang: Lang, - toLang: Lang, - text: String, - resultText: String, - ): String = throw UnsupportedOperationException() - } - } -} From 3650e86b571d963bbbe070432169d5edacac9df7 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Tue, 23 Sep 2025 20:50:29 +0800 Subject: [PATCH 26/58] Refactor dialogs to use ComposeDialog base class Introduces a ComposeDialog abstract class to unify Compose-based dialog implementations. Refactors TranslatorCredentialsDialog and GoogleTranslatorSettingsDialog to extend ComposeDialog, simplifying their structure and lifecycle. Updates IdeTextField to support secure input and improves code consistency in form controls. Minor UI and code cleanups are also included. --- .../localization/config/SettingsComponent.kt | 15 +- .../config/TranslatorCredentialsDialog.kt | 72 ++---- .../google/GoogleTranslatorSettingsDialog.kt | 72 +++--- .../airsaid/localization/ui/ComposeDialog.kt | 41 ++++ .../ui/components/FormControls.kt | 216 +++++++++--------- 5 files changed, 207 insertions(+), 209 deletions(-) create mode 100644 src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt index 02e53bd..bdca0c7 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -44,6 +43,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -105,7 +105,7 @@ class SettingsComponent { SettingsContent( translators = translatorsState, selectedTranslator = selectedTranslatorState.value, - defaultTranslatorKey = TranslatorService.getInstance().getDefaultTranslator()?.key, + defaultTranslatorKey = TranslatorService.getInstance().getDefaultTranslator().key, enableCacheState = enableCacheState, maxCacheSizeState = maxCacheSizeState, translationIntervalState = translationIntervalState, @@ -208,17 +208,15 @@ private fun SettingsContent( SettingsFormRow(label = "Provider") { Column( - modifier = Modifier.weight(1f), - verticalArrangement = LayoutArrangement.spacedBy(8.dp) + modifier = Modifier.weight(1f), + verticalArrangement = LayoutArrangement.spacedBy(8.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = LayoutArrangement.spacedBy(FormContentSpacing) ) { TranslatorDropdown( - modifier = Modifier - .width(TranslatorDropdownWidth) - .heightIn(min = CompactFieldHeight), + modifier = Modifier.width(TranslatorDropdownWidth), translators = translators, selectedTranslator = selectedTranslator, defaultTranslatorKey = defaultTranslatorKey, @@ -241,7 +239,6 @@ private fun SettingsContent( selectedTranslator?.let { translator -> TextButton( onClick = { onShowSupportedLanguages(translator) }, - contentPadding = PaddingValues(0.dp), modifier = Modifier.align(Alignment.Start) ) { Text("See supported languages") @@ -391,7 +388,7 @@ private fun TranslatorDropdown( ) { OutlinedTextField( modifier = Modifier - .menuAnchor() + .menuAnchor(MenuAnchorType.PrimaryNotEditable, true) .fillMaxWidth() .heightIn(min = CompactFieldHeight), value = selectedTranslator?.name.orEmpty(), diff --git a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt index 895d235..702fc0e 100644 --- a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt @@ -1,14 +1,13 @@ package com.airsaid.localization.config +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -16,25 +15,19 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.ComposePanel -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.TranslatorCredentialDescriptor -import com.airsaid.localization.ui.IdeTheme +import com.airsaid.localization.ui.ComposeDialog +import com.airsaid.localization.ui.components.IdeTextField import com.intellij.ide.BrowserUtil -import com.intellij.openapi.ui.DialogWrapper -import java.awt.Dimension -import javax.swing.JComponent class TranslatorCredentialsDialog( private val translator: AbstractTranslator, private val settingsState: SettingsState, -) : DialogWrapper(true) { +) : ComposeDialog() { private val credentialValuesState: SnapshotStateMap = mutableStateMapOf() - private val composePanel = ComposePanel() init { title = "${translator.name} Settings" @@ -42,64 +35,52 @@ class TranslatorCredentialsDialog( translator.credentialDefinitions.forEach { descriptor -> credentialValuesState[descriptor.id] = settingsState.getCredential(translator.key, descriptor) } - - composePanel.preferredSize = Dimension(460, 280) - composePanel.setContent { - IdeTheme { - CredentialsContent() - } - } - - init() } - override fun createCenterPanel(): JComponent = composePanel - @Composable - private fun CredentialsContent() { + override fun Content(onOkAction: (callback: () -> Unit) -> Unit) { val descriptors = remember { translator.credentialDefinitions } Column( modifier = Modifier - .fillMaxSize() - .padding(horizontal = 20.dp, vertical = 16.dp) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) ) { descriptors.forEach { descriptor -> CredentialField(descriptor = descriptor) - Spacer(modifier = Modifier.height(12.dp)) } - translator.credentialHelpUrl?.takeUnless { it.isNullOrBlank() }?.let { url -> + translator.credentialHelpUrl?.takeUnless { it.isBlank() }?.let { url -> TextButton(onClick = { BrowserUtil.browse(url) }) { Text("How to obtain credentials?") } } } + + onOkAction { + credentialValuesState.forEach { (id, value) -> + translator.credentialDefinitions.firstOrNull { it.id == id }?.let { descriptor -> + settingsState.setCredential(translator.key, descriptor, value.trim()) + } + } + } } @Composable private fun CredentialField(descriptor: TranslatorCredentialDescriptor) { - Column(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.width(320.dp)) { Text( text = descriptor.label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(6.dp)) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), + IdeTextField( value = credentialValuesState[descriptor.id] ?: "", onValueChange = { newValue -> credentialValuesState[descriptor.id] = newValue.trimStart() }, + modifier = Modifier.fillMaxWidth(), singleLine = true, - visualTransformation = if (descriptor.isSecret) PasswordVisualTransformation() else VisualTransformation.None, - colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f), - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurface - ) + secureInput = descriptor.isSecret ) descriptor.description?.takeIf { it.isNotBlank() }?.let { helper -> Spacer(modifier = Modifier.height(4.dp)) @@ -111,13 +92,4 @@ class TranslatorCredentialsDialog( } } } - - override fun doOKAction() { - credentialValuesState.forEach { (id, value) -> - translator.credentialDefinitions.firstOrNull { it.id == id }?.let { descriptor -> - settingsState.setCredential(translator.key, descriptor, value.trim()) - } - } - super.doOKAction() - } -} +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt index aa9c47a..76fd4a2 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,57 +16,47 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.ComposePanel import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp -import com.airsaid.localization.ui.IdeTheme +import com.airsaid.localization.ui.ComposeDialog import com.airsaid.localization.ui.components.IdeCheckbox import com.airsaid.localization.ui.components.IdeTextField -import com.intellij.openapi.ui.DialogWrapper -import javax.swing.JComponent +import java.awt.Dimension -class GoogleTranslatorSettingsDialog : DialogWrapper(true) { +class GoogleTranslatorSettingsDialog : ComposeDialog() { private val settings = GoogleTranslatorSettings.getInstance() - private val composePanel = ComposePanel() init { title = "Google Translator Settings" - composePanel.setContent { - IdeTheme { - SettingsContent() - } - } - init() } - override fun createCenterPanel(): JComponent = composePanel + override fun preferredSize() = Dimension(400, 160) @Composable - private fun SettingsContent() { + override fun Content(onOkAction: (callback: () -> Unit) -> Unit) { var useCustomServer by remember { mutableStateOf(settings.useCustomServer) } var serverUrl by remember { mutableStateOf(settings.serverUrl) } val toggleInteraction = remember { MutableInteractionSource() } Column( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Row( - modifier = Modifier - .fillMaxWidth() - .toggleable( - value = useCustomServer, - interactionSource = toggleInteraction, - indication = null, - role = Role.Checkbox, - onValueChange = { - useCustomServer = it - if (!it) { - serverUrl = settings.serverUrl - } + modifier = Modifier.toggleable( + value = useCustomServer, + interactionSource = toggleInteraction, + indication = null, + role = Role.Checkbox, + onValueChange = { + useCustomServer = it + if (!it) { + serverUrl = settings.serverUrl } - ), + } + ), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { IdeCheckbox(checked = useCustomServer) @@ -83,9 +74,9 @@ class GoogleTranslatorSettingsDialog : DialogWrapper(true) { color = MaterialTheme.colorScheme.onSurfaceVariant ) IdeTextField( - modifier = Modifier.fillMaxWidth(), value = serverUrl, onValueChange = { serverUrl = it.trimStart() }, + modifier = Modifier.fillMaxWidth(), singleLine = true, enabled = useCustomServer, placeholder = { @@ -98,25 +89,20 @@ class GoogleTranslatorSettingsDialog : DialogWrapper(true) { ) } - Text( - text = "Defaults to translate.googleapis.com when not specified.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + SelectionContainer { + Text( + text = "Defaults to translate.googleapis.com when not specified.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - footer = { + onOkAction { settings.useCustomServer = useCustomServer if (useCustomServer) { settings.serverUrl = serverUrl.ifBlank { GoogleTranslatorSettings.DEFAULT_SERVER_URL } } } } - - private var footer: (() -> Unit)? = null - - override fun doOKAction() { - footer?.invoke() - super.doOKAction() - } -} +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt new file mode 100644 index 0000000..5b6cb6d --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt @@ -0,0 +1,41 @@ +package com.airsaid.localization.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.awt.ComposePanel +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.DialogWrapper +import java.awt.Dimension +import javax.swing.JComponent + +abstract class ComposeDialog( + project: Project? = null, + canBeParent: Boolean = true +) : DialogWrapper(project, canBeParent) { + + private val composePanel = ComposePanel() + private var onOkAction: (() -> Unit)? = null + + init { + preferredSize()?.let { composePanel.preferredSize = it } + composePanel.setContent { + IdeTheme { + Content( + onOkAction = { callback -> onOkAction = callback } + ) + } + } + init() + } + + override fun createCenterPanel(): JComponent = composePanel + + @Composable + protected abstract fun Content(onOkAction: (callback: () -> Unit) -> Unit) + + protected open fun preferredSize(): Dimension? = null + + override fun doOKAction() { + onOkAction?.invoke() + super.doOKAction() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt b/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt index 49cfb04..f4a953b 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt @@ -32,121 +32,123 @@ private val CompactFieldHeight = 36.dp @Composable @Suppress("LongParameterList") fun IdeTextField( - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - readOnly: Boolean = false, - placeholder: (@Composable (() -> Unit))? = null, - leadingIcon: (@Composable (() -> Unit))? = null, - trailingIcon: (@Composable (() -> Unit))? = null, - prefix: (@Composable (() -> Unit))? = null, - suffix: (@Composable (() -> Unit))? = null, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - secureInput: Boolean = false, - singleLine: Boolean = true, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + placeholder: (@Composable (() -> Unit))? = null, + leadingIcon: (@Composable (() -> Unit))? = null, + trailingIcon: (@Composable (() -> Unit))? = null, + prefix: (@Composable (() -> Unit))? = null, + suffix: (@Composable (() -> Unit))? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + secureInput: Boolean = false, + singleLine: Boolean = true, ) { - val interactionSource = remember { MutableInteractionSource() } - val colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f), - disabledBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurface, - disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), - cursorColor = MaterialTheme.colorScheme.primary, - focusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - ) + val interactionSource = remember { MutableInteractionSource() } + val colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f), + disabledBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + cursorColor = MaterialTheme.colorScheme.primary, + focusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + + val visualTransformation = if (secureInput) { + PasswordVisualTransformation() + } else { + VisualTransformation.None + } - BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = modifier - .heightIn(min = CompactFieldHeight) - .defaultMinSize(minHeight = CompactFieldHeight), - enabled = enabled, - readOnly = readOnly, - textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), - singleLine = singleLine, - keyboardOptions = keyboardOptions, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - interactionSource = interactionSource, - ) { innerTextField -> - val visualTransformation = if (secureInput) { - PasswordVisualTransformation() - } else { - VisualTransformation.None - } - OutlinedTextFieldDefaults.DecorationBox( - value = value, - visualTransformation = visualTransformation, - innerTextField = innerTextField, - placeholder = placeholder, - label = null, - prefix = prefix, - suffix = suffix, - supportingText = null, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - singleLine = singleLine, - enabled = enabled, - isError = false, - interactionSource = interactionSource, - colors = colors, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), - container = { - OutlinedTextFieldDefaults.Container( - enabled = enabled, - isError = false, - interactionSource = interactionSource, - colors = colors, - ) - } + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .heightIn(min = CompactFieldHeight) + .defaultMinSize(minHeight = CompactFieldHeight), + enabled = enabled, + readOnly = readOnly, + textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), + visualTransformation = visualTransformation, + singleLine = singleLine, + keyboardOptions = keyboardOptions, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + interactionSource = interactionSource, + ) { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = value, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = null, + prefix = prefix, + suffix = suffix, + supportingText = null, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + singleLine = singleLine, + enabled = enabled, + isError = false, + interactionSource = interactionSource, + colors = colors, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + container = { + OutlinedTextFieldDefaults.Container( + enabled = enabled, + isError = false, + interactionSource = interactionSource, + colors = colors, ) - } + } + ) + } } @Composable fun IdeCheckbox( - checked: Boolean, - modifier: Modifier = Modifier, - enabled: Boolean = true, + checked: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true, ) { - val shape = RoundedCornerShape(3.dp) - val colors = MaterialTheme.colorScheme - val backgroundColor = when { - !enabled -> colors.surface - checked -> colors.primary - else -> colors.surface - } - val borderColor = when { - !enabled -> colors.outline.copy(alpha = 0.3f) - checked -> colors.primary - else -> colors.outline.copy(alpha = 0.7f) - } + val shape = RoundedCornerShape(3.dp) + val colors = MaterialTheme.colorScheme + val backgroundColor = when { + !enabled -> colors.surface + checked -> colors.primary + else -> colors.surface + } + val borderColor = when { + !enabled -> colors.outline.copy(alpha = 0.3f) + checked -> colors.primary + else -> colors.outline.copy(alpha = 0.7f) + } - Box( - modifier = modifier - .size(16.dp) - .border(1.dp, borderColor, shape) - .background(backgroundColor, shape), - contentAlignment = Alignment.Center, - ) { - if (checked) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - tint = colors.onPrimary, - modifier = Modifier.size(10.dp), - ) - } + Box( + modifier = modifier + .size(16.dp) + .border(1.dp, borderColor, shape) + .background(backgroundColor, shape), + contentAlignment = Alignment.Center, + ) { + if (checked) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = colors.onPrimary, + modifier = Modifier.size(10.dp), + ) } + } } From 3803dcde645d44c533dba63fbc07958142992125 Mon Sep 17 00:00:00 2001 From: Yoo Zhou Date: Thu, 25 Sep 2025 00:14:27 +0800 Subject: [PATCH 27/58] Refactor DeepL translator --- .../localization/config/SettingsComponent.kt | 681 +++++++++--------- .../localization/config/SettingsState.kt | 251 +++---- .../config/TranslatorConfigurationManager.kt | 2 + .../config/TranslatorCredentialsDialog.kt | 45 +- .../impl/deepl/DeepLProTranslator.kt | 41 -- .../translate/impl/deepl/DeepLTranslator.kt | 14 +- .../deepl/DeepLTranslatorCredentialsDialog.kt | 36 + .../impl/deepl/DeepLTranslatorSettings.kt | 34 + .../google/GoogleTranslatorSettingsDialog.kt | 49 +- .../airsaid/localization/ui/ComposeDialog.kt | 18 +- .../ui/components/FormControls.kt | 88 ++- 11 files changed, 656 insertions(+), 603 deletions(-) delete mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorCredentialsDialog.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorSettings.kt diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt index bdca0c7..7778ba5 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt @@ -17,59 +17,31 @@ package com.airsaid.localization.config import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Arrangement as LayoutArrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposePanel import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.compose.ui.draw.scale import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.ui.IdeTheme import com.airsaid.localization.ui.SupportLanguagesDialog +import com.airsaid.localization.ui.components.IdeSwitch import com.airsaid.localization.ui.components.IdeTextField import com.intellij.openapi.diagnostic.Logger import com.intellij.util.ui.UIUtil @@ -79,99 +51,100 @@ import java.awt.image.BufferedImage import javax.swing.Icon import javax.swing.JComponent import kotlin.math.max +import androidx.compose.foundation.layout.Arrangement as LayoutArrangement /** * Compose implementation of the settings panel exposed through the IDE Settings. */ class SettingsComponent { - companion object { - private val LOG = Logger.getInstance(SettingsComponent::class.java) - } - - private val composePanel = ComposePanel() - - private val translatorsState = mutableStateListOf() - private val selectedTranslatorState = mutableStateOf(null) - private val enableCacheState = mutableStateOf(true) - private val maxCacheSizeState = mutableStateOf("500") - private val translationIntervalState = mutableStateOf("500") - - init { - composePanel.preferredSize = Dimension(680, 560) - composePanel.isOpaque = true - composePanel.background = UIUtil.getPanelBackground() - composePanel.setContent { - IdeTheme { - SettingsContent( - translators = translatorsState, - selectedTranslator = selectedTranslatorState.value, - defaultTranslatorKey = TranslatorService.getInstance().getDefaultTranslator().key, - enableCacheState = enableCacheState, - maxCacheSizeState = maxCacheSizeState, - translationIntervalState = translationIntervalState, - onTranslatorSelected = { translator -> applySelectedTranslator(translator) }, - onShowSupportedLanguages = { translator -> SupportLanguagesDialog(translator).show() }, - onConfigureTranslator = { translator -> - TranslatorConfigurationManager.showConfigurationDialog(translator) - } - ) - } - } + companion object { + private val LOG = Logger.getInstance(SettingsComponent::class.java) + } + + private val composePanel = ComposePanel() + + private val translatorsState = mutableStateListOf() + private val selectedTranslatorState = mutableStateOf(null) + private val enableCacheState = mutableStateOf(true) + private val maxCacheSizeState = mutableStateOf("500") + private val translationIntervalState = mutableStateOf("500") + + init { + composePanel.preferredSize = Dimension(680, 560) + composePanel.isOpaque = true + composePanel.background = UIUtil.getPanelBackground() + composePanel.setContent { + IdeTheme { + SettingsContent( + translators = translatorsState, + selectedTranslator = selectedTranslatorState.value, + defaultTranslatorKey = TranslatorService.getInstance().getDefaultTranslator().key, + enableCacheState = enableCacheState, + maxCacheSizeState = maxCacheSizeState, + translationIntervalState = translationIntervalState, + onTranslatorSelected = { translator -> applySelectedTranslator(translator) }, + onShowSupportedLanguages = { translator -> SupportLanguagesDialog(translator).show() }, + onConfigureTranslator = { translator -> + TranslatorConfigurationManager.showConfigurationDialog(translator) + } + ) + } } + } - val content: JComponent - get() = composePanel + val content: JComponent + get() = composePanel - val preferredFocusedComponent: JComponent - get() = composePanel + val preferredFocusedComponent: JComponent + get() = composePanel - val selectedTranslator: AbstractTranslator - get() = selectedTranslatorState.value ?: error("Translator not selected") + val selectedTranslator: AbstractTranslator + get() = selectedTranslatorState.value ?: error("Translator not selected") - fun setTranslators(translators: Map) { - LOG.info("setTranslators: ${translators.keys}") - translatorsState.clear() - translatorsState.addAll(translators.values) - if (selectedTranslatorState.value == null && translatorsState.isNotEmpty()) { - applySelectedTranslator(TranslatorService.getInstance().getDefaultTranslator()) - } + fun setTranslators(translators: Map) { + LOG.info("setTranslators: ${translators.keys}") + translatorsState.clear() + translatorsState.addAll(translators.values) + if (selectedTranslatorState.value == null && translatorsState.isNotEmpty()) { + applySelectedTranslator(TranslatorService.getInstance().getDefaultTranslator()) } + } - fun setSelectedTranslator(selected: AbstractTranslator) { - applySelectedTranslator(selected) - } + fun setSelectedTranslator(selected: AbstractTranslator) { + applySelectedTranslator(selected) + } - fun setEnableCache(isEnable: Boolean) { - enableCacheState.value = isEnable - } + fun setEnableCache(isEnable: Boolean) { + enableCacheState.value = isEnable + } - val isEnableCache: Boolean - get() = enableCacheState.value + val isEnableCache: Boolean + get() = enableCacheState.value - fun setMaxCacheSize(maxCacheSize: Int) { - maxCacheSizeState.value = maxCacheSize.toString() - } + fun setMaxCacheSize(maxCacheSize: Int) { + maxCacheSizeState.value = maxCacheSize.toString() + } - val maxCacheSize: Int - get() = maxCacheSizeState.value.toIntOrNull() ?: 0 + val maxCacheSize: Int + get() = maxCacheSizeState.value.toIntOrNull() ?: 0 - fun setTranslationInterval(intervalTime: Int) { - translationIntervalState.value = intervalTime.toString() - } + fun setTranslationInterval(intervalTime: Int) { + translationIntervalState.value = intervalTime.toString() + } - val translationInterval: Int - get() = translationIntervalState.value.toIntOrNull() ?: 0 + val translationInterval: Int + get() = translationIntervalState.value.toIntOrNull() ?: 0 - val isSelectedDefaultTranslator: Boolean - get() = selectedTranslatorState.value?.let { isSelectedDefaultTranslator(it) } ?: false + val isSelectedDefaultTranslator: Boolean + get() = selectedTranslatorState.value?.let { isSelectedDefaultTranslator(it) } ?: false - private fun isSelectedDefaultTranslator(selected: AbstractTranslator): Boolean { - return selected == TranslatorService.getInstance().getDefaultTranslator() - } + private fun isSelectedDefaultTranslator(selected: AbstractTranslator): Boolean { + return selected == TranslatorService.getInstance().getDefaultTranslator() + } - private fun applySelectedTranslator(translator: AbstractTranslator) { - selectedTranslatorState.value = translator - } + private fun applySelectedTranslator(translator: AbstractTranslator) { + selectedTranslatorState.value = translator + } } private val LabelColumnWidth = 140.dp @@ -184,296 +157,296 @@ private const val MAX_REQUEST_INTERVAL_MS = 60_000 @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SettingsContent( - translators: SnapshotStateList, - selectedTranslator: AbstractTranslator?, - defaultTranslatorKey: String?, - enableCacheState: androidx.compose.runtime.MutableState, - maxCacheSizeState: androidx.compose.runtime.MutableState, - translationIntervalState: androidx.compose.runtime.MutableState, - onTranslatorSelected: (AbstractTranslator) -> Unit, - onShowSupportedLanguages: (AbstractTranslator) -> Unit, - onConfigureTranslator: (AbstractTranslator) -> Unit, + translators: SnapshotStateList, + selectedTranslator: AbstractTranslator?, + defaultTranslatorKey: String?, + enableCacheState: MutableState, + maxCacheSizeState: MutableState, + translationIntervalState: MutableState, + onTranslatorSelected: (AbstractTranslator) -> Unit, + onShowSupportedLanguages: (AbstractTranslator) -> Unit, + onConfigureTranslator: (AbstractTranslator) -> Unit, ) { - val scrollState = rememberScrollState() - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .verticalScroll(scrollState) - .padding(horizontal = 24.dp, vertical = 16.dp), - verticalArrangement = LayoutArrangement.spacedBy(20.dp) - ) { - SectionHeader(title = "Translator") - - SettingsFormRow(label = "Provider") { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = LayoutArrangement.spacedBy(8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = LayoutArrangement.spacedBy(FormContentSpacing) - ) { - TranslatorDropdown( - modifier = Modifier.width(TranslatorDropdownWidth), - translators = translators, - selectedTranslator = selectedTranslator, - defaultTranslatorKey = defaultTranslatorKey, - onTranslatorSelected = onTranslatorSelected - ) - - selectedTranslator?.let { translator -> - if (TranslatorConfigurationManager.hasConfiguration(translator)) { - IconButton(onClick = { onConfigureTranslator(translator) }) { - Icon( - imageVector = Icons.Filled.Settings, - contentDescription = "Configure translator", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - - selectedTranslator?.let { translator -> - TextButton( - onClick = { onShowSupportedLanguages(translator) }, - modifier = Modifier.align(Alignment.Start) - ) { - Text("See supported languages") - } - } + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .verticalScroll(scrollState) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = LayoutArrangement.spacedBy(20.dp) + ) { + SectionHeader(title = "Translator") + + SettingsFormRow(label = "Provider") { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = LayoutArrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = LayoutArrangement.spacedBy(FormContentSpacing) + ) { + TranslatorDropdown( + modifier = Modifier.width(TranslatorDropdownWidth), + translators = translators, + selectedTranslator = selectedTranslator, + defaultTranslatorKey = defaultTranslatorKey, + onTranslatorSelected = onTranslatorSelected + ) + + selectedTranslator?.let { translator -> + if (TranslatorConfigurationManager.hasConfiguration(translator)) { + IconButton(onClick = { onConfigureTranslator(translator) }) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = "Configure translator", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } + } } - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)) + selectedTranslator?.let { translator -> + TextButton( + onClick = { onShowSupportedLanguages(translator) }, + modifier = Modifier.align(Alignment.Start) + ) { + Text("See supported languages") + } + } + } + } - SectionHeader(title = "Caching") + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)) - SettingsFormRow(label = "Use cache") { - Switch( - modifier = Modifier.scale(0.8f), - checked = enableCacheState.value, - onCheckedChange = { enableCacheState.value = it } - ) - } + SectionHeader(title = "Caching") - SettingsFormRow( - label = "Max cache size", - helperText = if (enableCacheState.value) { - "Maximum cached translations before older ones are removed." - } else { - "Enable cache to edit the number of stored translations." - } - ) { - IdeTextField( - modifier = Modifier.widthIn(min = FieldMinWidth, max = 220.dp), - value = maxCacheSizeState.value, - onValueChange = { newValue -> - val digits = newValue.filter { it.isDigit() } - maxCacheSizeState.value = digits.ifEmpty { "0" } - }, - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), - enabled = enableCacheState.value - ) - } + SettingsFormRow(label = "Use cache") { + IdeSwitch( + checked = enableCacheState.value, + onCheckedChange = { enableCacheState.value = it } + ) + } - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)) + SettingsFormRow( + label = "Max cache size", + helperText = if (enableCacheState.value) { + "Maximum cached translations before older ones are removed." + } else { + "Enable cache to edit the number of stored translations." + } + ) { + IdeTextField( + modifier = Modifier.widthIn(min = FieldMinWidth, max = 220.dp), + value = maxCacheSizeState.value, + onValueChange = { newValue -> + val digits = newValue.filter { it.isDigit() } + maxCacheSizeState.value = digits.ifEmpty { "0" } + }, + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), + enabled = enableCacheState.value + ) + } - SectionHeader(title = "Requests") + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)) - SettingsFormRow( - label = "Interval", - helperText = "Delay between translation requests in milliseconds (max 60,000 ms ≈ 1 minute)." - ) { - IdeTextField( - modifier = Modifier.widthIn(min = FieldMinWidth, max = 220.dp), - value = translationIntervalState.value, - onValueChange = { newValue -> - val digits = newValue.filter { it.isDigit() } - val clampedValue = digits.toLongOrNull() - ?.coerceAtMost(MAX_REQUEST_INTERVAL_MS.toLong()) - ?.toInt() - translationIntervalState.value = when { - digits.isEmpty() -> "0" - clampedValue == null -> MAX_REQUEST_INTERVAL_MS.toString() - else -> clampedValue.toString() - } - }, - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), - suffix = { - Text( - text = "ms", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 4.dp) - ) - } - ) + SectionHeader(title = "Requests") + + SettingsFormRow( + label = "Interval", + helperText = "Delay between translation requests in milliseconds (max 60,000 ms ≈ 1 minute)." + ) { + IdeTextField( + modifier = Modifier.widthIn(min = FieldMinWidth, max = 220.dp), + value = translationIntervalState.value, + onValueChange = { newValue -> + val digits = newValue.filter { it.isDigit() } + val clampedValue = digits.toLongOrNull() + ?.coerceAtMost(MAX_REQUEST_INTERVAL_MS.toLong()) + ?.toInt() + translationIntervalState.value = when { + digits.isEmpty() -> "0" + clampedValue == null -> MAX_REQUEST_INTERVAL_MS.toString() + else -> clampedValue.toString() + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number), + suffix = { + Text( + text = "ms", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 4.dp) + ) } + ) } + } } @Composable private fun SectionHeader(title: String) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface - ) + Text( + text = title, + style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface + ) } @Composable private fun SettingsFormRow( - label: String, - helperText: String? = null, - content: @Composable RowScope.() -> Unit, + label: String, + helperText: String? = null, + content: @Composable RowScope.() -> Unit, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .width(LabelColumnWidth) - .padding(end = 12.dp) - ) - - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = LayoutArrangement.spacedBy(FormContentSpacing), - content = content - ) - } + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .width(LabelColumnWidth) + .padding(end = 12.dp) + ) + + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = LayoutArrangement.spacedBy(FormContentSpacing), + content = content + ) + } - helperText?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = LabelColumnWidth + FormContentSpacing, top = 4.dp) - ) - } + helperText?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = LabelColumnWidth + FormContentSpacing, top = 4.dp) + ) } + } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TranslatorDropdown( - modifier: Modifier = Modifier, - translators: List, - selectedTranslator: AbstractTranslator?, - defaultTranslatorKey: String?, - onTranslatorSelected: (AbstractTranslator) -> Unit, + modifier: Modifier = Modifier, + translators: List, + selectedTranslator: AbstractTranslator?, + defaultTranslatorKey: String?, + onTranslatorSelected: (AbstractTranslator) -> Unit, ) { - var expanded by remember { mutableStateOf(false) } - val selectedPainter = selectedTranslator?.let { translatorIconPainter(it) } + var expanded by remember { mutableStateOf(false) } + val selectedPainter = selectedTranslator?.let { translatorIconPainter(it) } + val selectedName = selectedTranslator?.name.orEmpty() + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable, true) + .fillMaxWidth() + .heightIn(min = CompactFieldHeight), + value = selectedName, + onValueChange = {}, + readOnly = true, + label = { Text("Translator") }, + placeholder = { Text("Select translator") }, + singleLine = true, + leadingIcon = { + selectedPainter?.let { + Icon( + painter = it, + contentDescription = selectedTranslator.name, + modifier = Modifier.size(18.dp), + tint = Color.Unspecified + ) + } + }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) } + ) - ExposedDropdownMenuBox( - modifier = modifier, - expanded = expanded, - onExpandedChange = { expanded = !expanded } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } ) { - OutlinedTextField( - modifier = Modifier - .menuAnchor(MenuAnchorType.PrimaryNotEditable, true) - .fillMaxWidth() - .heightIn(min = CompactFieldHeight), - value = selectedTranslator?.name.orEmpty(), - onValueChange = {}, - readOnly = true, - label = { Text("Translator") }, - placeholder = { Text("Select translator") }, - singleLine = true, - leadingIcon = { - selectedPainter?.let { - Icon( - painter = it, - contentDescription = selectedTranslator?.name, - modifier = Modifier.size(18.dp), - tint = Color.Unspecified - ) - } - }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) } - ) - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - translators.forEach { translator -> - val itemPainter = translatorIconPainter(translator) - val isDefault = translator.key == defaultTranslatorKey - - DropdownMenuItem( - text = { Text(translator.name) }, - leadingIcon = { - itemPainter?.let { - Icon( - painter = it, - contentDescription = translator.name, - modifier = Modifier.size(18.dp), - tint = Color.Unspecified - ) - } - }, - trailingIcon = { - if (isDefault) { - DefaultBadge() - } - }, - onClick = { - expanded = false - onTranslatorSelected(translator) - }, - modifier = Modifier.widthIn(min = 240.dp) - ) + translators.forEach { translator -> + val itemPainter = translatorIconPainter(translator) + val isDefault = translator.key == defaultTranslatorKey + + DropdownMenuItem( + text = { Text(translator.name) }, + leadingIcon = { + itemPainter?.let { + Icon( + painter = it, + contentDescription = translator.name, + modifier = Modifier.size(18.dp), + tint = Color.Unspecified + ) } - } + }, + trailingIcon = { + if (isDefault) { + DefaultBadge() + } + }, + onClick = { + expanded = false + onTranslatorSelected(translator) + }, + modifier = Modifier.widthIn(min = 240.dp) + ) + } } + } } @Composable private fun DefaultBadge() { - Text( - text = "Default", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier - .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(6.dp)) - .padding(horizontal = 8.dp, vertical = 2.dp) - ) + Text( + text = "Default", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier + .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) } @Composable private fun translatorIconPainter(translator: AbstractTranslator): Painter? { - val icon = translator.icon ?: return null - return remember(icon) { - val buffered = icon.toBufferedImageSafely() - BitmapPainter(buffered.toComposeImageBitmap()) - } + val icon = translator.icon ?: return null + return remember(icon) { + val buffered = icon.toBufferedImageSafely() + BitmapPainter(buffered.toComposeImageBitmap()) + } } private fun Icon.toBufferedImageSafely(): BufferedImage { - val width = max(1, iconWidth) - val height = max(1, iconHeight) - val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) - val graphics = image.createGraphics() - graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC) - paintIcon(null, graphics, 0, 0) - graphics.dispose() - return image + val width = max(1, iconWidth) + val height = max(1, iconHeight) + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC) + paintIcon(null, graphics, 0, 0) + graphics.dispose() + return image } diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt index e4f8e0a..8021f75 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt @@ -30,163 +30,146 @@ import com.intellij.openapi.util.text.StringUtil * @author airsaid */ @State( - name = "com.airsaid.localization.config.SettingsState", - storages = [Storage("androidLocalizeSettings.xml")] + name = "com.airsaid.localization.config.SettingsState", + storages = [Storage("androidLocalizeSettings.xml")] ) @Service class SettingsState : PersistentStateComponent { - companion object { - private val LOG = Logger.getInstance(SettingsState::class.java) + companion object { + private val LOG = Logger.getInstance(SettingsState::class.java) - fun getInstance(): SettingsState { - return ServiceManager.getService(SettingsState::class.java) - } + fun getInstance(): SettingsState { + return ServiceManager.getService(SettingsState::class.java) } - - private val credentialSecureStorage = mutableMapOf() - private var state = State() - - fun initSetting() { - val translatorService = TranslatorService.getInstance() - LOG.info("initSetting") - translatorService.setSelectedTranslator(this.selectedTranslator) - translatorService.setEnableCache(isEnableCache) - translatorService.maxCacheSize = maxCacheSize - translatorService.translationInterval = translationInterval - - AndroidValuesService.getInstance().isSkipNonTranslatable = isSkipNonTranslatable + } + + private val credentialSecureStorage = mutableMapOf() + private var state = State() + + fun initSetting() { + val translatorService = TranslatorService.getInstance() + translatorService.setSelectedTranslator(this.selectedTranslator) + translatorService.setEnableCache(isEnableCache) + translatorService.maxCacheSize = maxCacheSize + translatorService.translationInterval = translationInterval + + AndroidValuesService.getInstance().isSkipNonTranslatable = isSkipNonTranslatable + } + + var selectedTranslator: AbstractTranslator + get() = if (StringUtil.isEmpty(state.selectedTranslatorKey)) { + TranslatorService.getInstance().getDefaultTranslator() + } else { + TranslatorService.getInstance().getTranslators()[state.selectedTranslatorKey] + ?: TranslatorService.getInstance().getDefaultTranslator() } - - var selectedTranslator: AbstractTranslator - get() = if (StringUtil.isEmpty(state.selectedTranslatorKey)) { - TranslatorService.getInstance().getDefaultTranslator() - } else { - TranslatorService.getInstance().getTranslators()[state.selectedTranslatorKey] - ?: TranslatorService.getInstance().getDefaultTranslator() - } - set(translator) { - state.selectedTranslatorKey = translator.key - } - - fun setCredential(translatorKey: String, descriptor: TranslatorCredentialDescriptor, value: String) { - if (descriptor.isSecret) { - secureStorage(translatorKey, descriptor.id).save(value) - } else { - val credentials = state.credentials.getOrPut(translatorKey) { mutableMapOf() } - if (value.isBlank()) { - credentials.remove(descriptor.id) - if (credentials.isEmpty()) { - state.credentials.remove(translatorKey) - } - } else { - credentials[descriptor.id] = value - } - } + set(translator) { + state.selectedTranslatorKey = translator.key } - fun getCredential(translatorKey: String, descriptor: TranslatorCredentialDescriptor): String { - return if (descriptor.isSecret) { - readSecret(translatorKey, descriptor) - } else { - state.credentials[translatorKey]?.get(descriptor.id) ?: "" + fun setCredential(translatorKey: String, descriptor: TranslatorCredentialDescriptor, value: String) { + if (descriptor.isSecret) { + secureStorage(translatorKey, descriptor.id).save(value) + } else { + val credentials = state.credentials.getOrPut(translatorKey) { mutableMapOf() } + if (value.isBlank()) { + credentials.remove(descriptor.id) + if (credentials.isEmpty()) { + state.credentials.remove(translatorKey) } + } else { + credentials[descriptor.id] = value + } } + } - fun getCredentials(translatorKey: String, descriptors: List): Map { - if (descriptors.isEmpty()) return emptyMap() - return buildMap { - descriptors.forEach { descriptor -> - put(descriptor.id, getCredential(translatorKey, descriptor)) - } - } + fun getCredential(translatorKey: String, descriptor: TranslatorCredentialDescriptor): String { + return if (descriptor.isSecret) { + readSecret(translatorKey, descriptor) + } else { + state.credentials[translatorKey]?.get(descriptor.id) ?: "" } + } + + fun getCredentials(translatorKey: String, descriptors: List): Map { + if (descriptors.isEmpty()) return emptyMap() + return buildMap { + descriptors.forEach { descriptor -> + put(descriptor.id, getCredential(translatorKey, descriptor)) + } + } + } - var isEnableCache: Boolean - get() = state.isEnableCache - set(isEnable) { - state.isEnableCache = isEnable - } - - var maxCacheSize: Int - get() = state.maxCacheSize - set(maxCacheSize) { - state.maxCacheSize = maxCacheSize - } - - var translationInterval: Int - get() = state.translationInterval - set(intervalTime) { - state.translationInterval = intervalTime - } - - var isSkipNonTranslatable: Boolean - get() = state.isSkipNonTranslatable - set(isSkipNonTranslatable) { - state.isSkipNonTranslatable = isSkipNonTranslatable - } - - override fun getState(): State { - return state + var isEnableCache: Boolean + get() = state.isEnableCache + set(isEnable) { + state.isEnableCache = isEnable } - override fun loadState(state: State) { - this.state = state - normalizeTranslationInterval() - migrateLegacyAppIds() + var maxCacheSize: Int + get() = state.maxCacheSize + set(maxCacheSize) { + state.maxCacheSize = maxCacheSize } - data class State( - var selectedTranslatorKey: String? = null, - var credentials: MutableMap> = mutableMapOf(), - @Deprecated("Replaced by credentials") - var appIds: MutableMap = mutableMapOf(), - var isEnableCache: Boolean = true, - var maxCacheSize: Int = 500, - var translationInterval: Int = 500, // milliseconds - var isSkipNonTranslatable: Boolean = false - ) - - private fun secureStorage(translatorKey: String, credentialId: String): SecureStorage { - val key = "$translatorKey::$credentialId" - return credentialSecureStorage.getOrPut(key) { SecureStorage(key) } + var translationInterval: Int + get() = state.translationInterval + set(intervalTime) { + state.translationInterval = intervalTime } - private fun normalizeTranslationInterval() { - if (state.translationInterval in 1..10) { - state.translationInterval *= 1000 - } - if (state.translationInterval <= 0) { - state.translationInterval = 500 - } + var isSkipNonTranslatable: Boolean + get() = state.isSkipNonTranslatable + set(isSkipNonTranslatable) { + state.isSkipNonTranslatable = isSkipNonTranslatable } - private fun readSecret(translatorKey: String, descriptor: TranslatorCredentialDescriptor): String { - val storage = secureStorage(translatorKey, descriptor.id) - val value = storage.read() - if (value.isNotEmpty()) { - return value - } + override fun getState(): State { + return state + } + + override fun loadState(state: State) { + this.state = state + normalizeTranslationInterval() + } + + data class State( + var selectedTranslatorKey: String? = null, + var credentials: MutableMap> = mutableMapOf(), + var isEnableCache: Boolean = true, + var maxCacheSize: Int = 500, + var translationInterval: Int = 500, // milliseconds + var isSkipNonTranslatable: Boolean = false, + ) + + private fun secureStorage(translatorKey: String, credentialId: String): SecureStorage { + val key = "$translatorKey::$credentialId" + return credentialSecureStorage.getOrPut(key) { SecureStorage(key) } + } + + private fun normalizeTranslationInterval() { + if (state.translationInterval in 1..10) { + state.translationInterval *= 1000 + } + if (state.translationInterval <= 0) { + state.translationInterval = 500 + } + } - // Backwards compatibility: migrate legacy key-only storage if present. - val legacy = credentialSecureStorage.getOrPut(translatorKey) { SecureStorage(translatorKey) } - val legacyValue = legacy.read() - if (legacyValue.isNotEmpty()) { - storage.save(legacyValue) - return legacyValue - } - return "" + private fun readSecret(translatorKey: String, descriptor: TranslatorCredentialDescriptor): String { + val storage = secureStorage(translatorKey, descriptor.id) + val value = storage.read() + if (value.isNotEmpty()) { + return value } - private fun migrateLegacyAppIds() { - if (state.appIds.isEmpty()) return - val translators = TranslatorService.getInstance().getTranslators() - for ((translatorKey, appId) in state.appIds) { - val translator = translators[translatorKey] ?: continue - val descriptor = translator.credentialDefinitions.firstOrNull { it.id == "appId" } - if (descriptor != null && appId.isNotBlank()) { - state.credentials.getOrPut(translatorKey) { mutableMapOf() }[descriptor.id] = appId - } - } - state.appIds.clear() + // Backwards compatibility: migrate legacy key-only storage if present. + val legacy = credentialSecureStorage.getOrPut(translatorKey) { SecureStorage(translatorKey) } + val legacyValue = legacy.read() + if (legacyValue.isNotEmpty()) { + storage.save(legacyValue) + return legacyValue } + return "" + } } diff --git a/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt b/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt index 92d1beb..3e1a5e3 100644 --- a/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt +++ b/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt @@ -1,6 +1,7 @@ package com.airsaid.localization.config import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.impl.deepl.DeepLTranslatorCredentialsDialog import com.airsaid.localization.translate.impl.google.GoogleTranslatorSettingsDialog import com.intellij.openapi.diagnostic.Logger @@ -11,6 +12,7 @@ object TranslatorConfigurationManager { fun showConfigurationDialog(translator: AbstractTranslator): Boolean { return when (translator.key) { "Google" -> GoogleTranslatorSettingsDialog().showAndGet() + "DeepL" -> DeepLTranslatorCredentialsDialog(translator, SettingsState.getInstance()).showAndGet() else -> showCredentialDialog(translator) } } diff --git a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt index 702fc0e..05aba96 100644 --- a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt @@ -1,12 +1,6 @@ package com.airsaid.localization.config -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -22,12 +16,12 @@ import com.airsaid.localization.ui.ComposeDialog import com.airsaid.localization.ui.components.IdeTextField import com.intellij.ide.BrowserUtil -class TranslatorCredentialsDialog( - private val translator: AbstractTranslator, - private val settingsState: SettingsState, +open class TranslatorCredentialsDialog( + protected val translator: AbstractTranslator, + protected val settingsState: SettingsState, ) : ComposeDialog() { - private val credentialValuesState: SnapshotStateMap = mutableStateMapOf() + protected val credentialValuesState: SnapshotStateMap = mutableStateMapOf() init { title = "${translator.name} Settings" @@ -38,7 +32,7 @@ class TranslatorCredentialsDialog( } @Composable - override fun Content(onOkAction: (callback: () -> Unit) -> Unit) { + override fun Content() { val descriptors = remember { translator.credentialDefinitions } Column( @@ -46,6 +40,8 @@ class TranslatorCredentialsDialog( .padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp) ) { + Header() + descriptors.forEach { descriptor -> CredentialField(descriptor = descriptor) } @@ -55,14 +51,8 @@ class TranslatorCredentialsDialog( Text("How to obtain credentials?") } } - } - onOkAction { - credentialValuesState.forEach { (id, value) -> - translator.credentialDefinitions.firstOrNull { it.id == id }?.let { descriptor -> - settingsState.setCredential(translator.key, descriptor, value.trim()) - } - } + Footer() } } @@ -92,4 +82,19 @@ class TranslatorCredentialsDialog( } } } -} \ No newline at end of file + + override fun doOKAction() { + super.doOKAction() + credentialValuesState.forEach { (id, value) -> + translator.credentialDefinitions.firstOrNull { it.id == id }?.let { descriptor -> + settingsState.setCredential(translator.key, descriptor, value.trim()) + } + } + } + + @Composable + protected open fun Header() = Unit + + @Composable + protected open fun Footer() = Unit +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.kt deleted file mode 100644 index 8399081..0000000 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLProTranslator.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.deepl - -import com.airsaid.localization.translate.AbstractTranslator -import com.airsaid.localization.translate.lang.Lang -import com.google.auto.service.AutoService - -/** - * @author airsaid - */ -@AutoService(AbstractTranslator::class) -class DeepLProTranslator : DeepLTranslator() { - - companion object { - private const val KEY = "DeepLPro" - private const val HOST_URL = "https://api.deepl.com/v2" - private const val TRANSLATE_URL = "$HOST_URL/translate" - } - - override val key: String = KEY - - override val name: String = KEY - - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = TRANSLATE_URL -} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt index 46ddeb7..e8d42ed 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt @@ -39,13 +39,17 @@ open class DeepLTranslator : AbstractTranslator() { companion object { private val LOG = Logger.getInstance(DeepLTranslator::class.java) private const val KEY = "DeepL" - private const val HOST_URL = "https://api-free.deepl.com/v2" - private const val TRANSLATE_URL = "$HOST_URL/translate" + private const val FREE_HOST_URL = "https://api-free.deepl.com/v2" + private const val PRO_HOST_URL = "https://api.deepl.com/v2" + private const val TRANSLATE_PATH = "/translate" private const val APPLY_APP_ID_URL = "https://www.deepl.com/pro-api?cta=header-pro-api/" } private var _supportedLanguages: MutableList? = null + private val deeplSettings: DeepLTranslatorSettings + get() = DeepLTranslatorSettings.getInstance() + override val key: String = KEY override val name: String = "DeepL" @@ -98,8 +102,10 @@ open class DeepLTranslator : AbstractTranslator() { return _supportedLanguages!! } - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = - UrlBuilder(TRANSLATE_URL).build() + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + val baseUrl = if (deeplSettings.usePro) PRO_HOST_URL else FREE_HOST_URL + return UrlBuilder(baseUrl + TRANSLATE_PATH).build() + } override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { return listOf( diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorCredentialsDialog.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorCredentialsDialog.kt new file mode 100644 index 0000000..3c879da --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorCredentialsDialog.kt @@ -0,0 +1,36 @@ +package com.airsaid.localization.translate.impl.deepl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.airsaid.localization.config.SettingsState +import com.airsaid.localization.config.TranslatorCredentialsDialog +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.ui.components.IdeCheckBox + +class DeepLTranslatorCredentialsDialog( + translator: AbstractTranslator, + settingsState: SettingsState, +) : TranslatorCredentialsDialog(translator, settingsState) { + + private val deeplSettings = DeepLTranslatorSettings.getInstance() + private var useDeepLPro by mutableStateOf(deeplSettings.usePro) + + @Composable + override fun Header() { + IdeCheckBox( + checked = useDeepLPro, + onValueChange = { + useDeepLPro = !useDeepLPro + }, + title = "Use DeepL Pro", + subTitle = "Route requests through the paid DeepL API endpoint." + ) + } + + override fun doOKAction() { + super.doOKAction() + deeplSettings.usePro = useDeepLPro + } +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorSettings.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorSettings.kt new file mode 100644 index 0000000..f54a2f3 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorSettings.kt @@ -0,0 +1,34 @@ +package com.airsaid.localization.translate.impl.deepl + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service + +@Service +@State(name = "com.airsaid.localization.DeepLTranslatorSettings", storages = [Storage("deeplTranslatorSettings.xml")]) +class DeepLTranslatorSettings : PersistentStateComponent { + + data class State( + var usePro: Boolean = false + ) + + private var state = State() + + var usePro: Boolean + get() = state.usePro + set(value) { + state = state.copy(usePro = value) + } + + override fun getState(): State = state + + override fun loadState(state: State) { + this.state = state + } + + companion object { + fun getInstance(): DeepLTranslatorSettings = service() + } +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt index 76fd4a2..c4e5c60 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt @@ -1,25 +1,17 @@ package com.airsaid.localization.translate.impl.google -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.airsaid.localization.ui.ComposeDialog -import com.airsaid.localization.ui.components.IdeCheckbox +import com.airsaid.localization.ui.components.IdeCheckBox import com.airsaid.localization.ui.components.IdeTextField import java.awt.Dimension @@ -34,38 +26,25 @@ class GoogleTranslatorSettingsDialog : ComposeDialog() { override fun preferredSize() = Dimension(400, 160) @Composable - override fun Content(onOkAction: (callback: () -> Unit) -> Unit) { + override fun Content() { var useCustomServer by remember { mutableStateOf(settings.useCustomServer) } var serverUrl by remember { mutableStateOf(settings.serverUrl) } - val toggleInteraction = remember { MutableInteractionSource() } Column( modifier = Modifier .padding(horizontal = 20.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Row( - modifier = Modifier.toggleable( - value = useCustomServer, - interactionSource = toggleInteraction, - indication = null, - role = Role.Checkbox, - onValueChange = { - useCustomServer = it - if (!it) { - serverUrl = settings.serverUrl - } + IdeCheckBox( + checked = useCustomServer, + onValueChange = { + useCustomServer = it + if (!it) { + serverUrl = settings.serverUrl } - ), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - IdeCheckbox(checked = useCustomServer) - Text( - text = "Use custom server", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } + }, + title = "Use custom server", + ) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( @@ -98,11 +77,11 @@ class GoogleTranslatorSettingsDialog : ComposeDialog() { } } - onOkAction { + OnClickOK { settings.useCustomServer = useCustomServer if (useCustomServer) { settings.serverUrl = serverUrl.ifBlank { GoogleTranslatorSettings.DEFAULT_SERVER_URL } } } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt index 5b6cb6d..57f931d 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt @@ -1,6 +1,7 @@ package com.airsaid.localization.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.awt.ComposePanel import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper @@ -13,15 +14,13 @@ abstract class ComposeDialog( ) : DialogWrapper(project, canBeParent) { private val composePanel = ComposePanel() - private var onOkAction: (() -> Unit)? = null + private var onClickOKCallback: (() -> Unit)? = null init { preferredSize()?.let { composePanel.preferredSize = it } composePanel.setContent { IdeTheme { - Content( - onOkAction = { callback -> onOkAction = callback } - ) + Content() } } init() @@ -30,12 +29,19 @@ abstract class ComposeDialog( override fun createCenterPanel(): JComponent = composePanel @Composable - protected abstract fun Content(onOkAction: (callback: () -> Unit) -> Unit) + protected abstract fun Content() + + @Composable + protected fun OnClickOK(callback: () -> Unit) { + LaunchedEffect(callback) { + onClickOKCallback = callback + } + } protected open fun preferredSize(): Dimension? = null override fun doOKAction() { - onOkAction?.invoke() + onClickOKCallback?.invoke() super.doOKAction() } } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt b/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt index f4a953b..f0314db 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt @@ -3,25 +3,21 @@ package com.airsaid.localization.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp @@ -116,6 +112,50 @@ fun IdeTextField( } } +@Composable +fun IdeCheckBox( + checked: Boolean, + onValueChange: (checked: Boolean) -> Unit, + modifier: Modifier = Modifier, + title: String? = null, + subTitle: String? = null, + enabled: Boolean = true, +) { + val toggleInteraction = remember { MutableInteractionSource() } + Row( + modifier = modifier.toggleable( + value = checked, + interactionSource = toggleInteraction, + indication = null, + role = Role.Checkbox, + onValueChange = onValueChange + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IdeCheckbox(checked = checked) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (!title.isNullOrEmpty()) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + + if (!subTitle.isNullOrEmpty()) { + Text( + text = subTitle, + style = MaterialTheme.typography.bodySmall, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.6f + ) + ) + } + } + } +} + @Composable fun IdeCheckbox( checked: Boolean, @@ -152,3 +192,33 @@ fun IdeCheckbox( } } } + +@Composable +fun IdeSwitch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val scheme = MaterialTheme.colorScheme + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + modifier = modifier.scale(0.85f), + colors = SwitchDefaults.colors( + checkedThumbColor = scheme.onPrimary, + checkedTrackColor = scheme.primary, + checkedBorderColor = scheme.primary.copy(alpha = 0.2f), + checkedIconColor = scheme.primary, + uncheckedThumbColor = scheme.surface, + uncheckedTrackColor = scheme.outline.copy(alpha = 0.6f), + uncheckedBorderColor = scheme.outline.copy(alpha = 0.4f), + uncheckedIconColor = scheme.onSurfaceVariant, + disabledCheckedTrackColor = scheme.primary.copy(alpha = 0.3f), + disabledCheckedThumbColor = scheme.onSurface.copy(alpha = 0.3f), + disabledUncheckedTrackColor = scheme.onSurfaceVariant.copy(alpha = 0.2f), + disabledUncheckedThumbColor = scheme.onSurface.copy(alpha = 0.2f), + ) + ) +} From 6eb5161b90c4d0adf38a119ebed8e0ae2c4a6f25 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Thu, 25 Sep 2025 18:57:20 +0800 Subject: [PATCH 28/58] Update credential labels for YoudaoTranslator --- .../localization/translate/impl/youdao/YoudaoTranslator.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt index 9d54609..267d86c 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt @@ -156,8 +156,8 @@ class YoudaoTranslator : AbstractTranslator() { } override val credentialDefinitions = listOf( - TranslatorCredentialDescriptor(id = "appId", label = "应用 ID", isSecret = false), - TranslatorCredentialDescriptor(id = "appKey", label = "应用秘钥", isSecret = true) + TranslatorCredentialDescriptor(id = "appId", label = "APP ID", isSecret = false), + TranslatorCredentialDescriptor(id = "appKey", label = "APP KEY", isSecret = true) ) override val credentialHelpUrl: String? = APPLY_APP_ID_URL From 2a23fd6a9cd370414c002bfdb30fc9dcac3e02dc Mon Sep 17 00:00:00 2001 From: Airsaid Date: Thu, 25 Sep 2025 21:05:13 +0800 Subject: [PATCH 29/58] Refactor OpenAI translator --- README.md | 2 +- README_CN.md | 2 +- .../config/TranslatorConfigurationManager.kt | 4 +- .../translate/impl/openai/ChatGPTMessage.kt | 23 -- .../impl/openai/ChatGPTTranslator.kt | 88 ------ .../translate/impl/openai/OpenAIModels.kt | 23 ++ .../translate/impl/openai/OpenAIRequest.kt | 23 -- .../translate/impl/openai/OpenAIResponse.kt | 62 ++-- .../translate/impl/openai/OpenAITranslator.kt | 105 +++++++ .../impl/openai/OpenAITranslatorSettings.kt | 127 +++++++++ .../openai/OpenAITranslatorSettingsDialog.kt | 264 ++++++++++++++++++ .../ui/components/FormControls.kt | 102 +++++++ .../AbstractTranslatorNetworkTest.kt | 2 - 13 files changed, 660 insertions(+), 167 deletions(-) delete mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.kt delete mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIModels.kt delete mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIRequest.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt create mode 100644 src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettingsDialog.kt diff --git a/README.md b/README.md index aabfd00..ec3e3d6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Android localization plugin. supports multiple languages and multiple translator - Youdao translator. - Ali translator. - DeepL translator. - - OpenAI ChatGPT translator. + - OpenAI translator. - Supports up to 100+ languages. - One key generates all translation files. - Support no translation of existing string. diff --git a/README_CN.md b/README_CN.md index d26c8ac..9c6b550 100644 --- a/README_CN.md +++ b/README_CN.md @@ -17,7 +17,7 @@ Android 本地化插件,支持多种语言和翻译器。 - 有道翻译。 - 阿里翻译。 - DeepL 翻译。 - - OpenAI ChatGPT 翻译。 + - OpenAI 翻译。 - 支持最多 100+ 语言。 - 一键生成所有翻译文件。 - 支持不翻译已经存在的 string。 diff --git a/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt b/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt index 3e1a5e3..7e5b556 100644 --- a/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt +++ b/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt @@ -3,6 +3,7 @@ package com.airsaid.localization.config import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.impl.deepl.DeepLTranslatorCredentialsDialog import com.airsaid.localization.translate.impl.google.GoogleTranslatorSettingsDialog +import com.airsaid.localization.translate.impl.openai.OpenAITranslatorSettingsDialog import com.intellij.openapi.diagnostic.Logger object TranslatorConfigurationManager { @@ -12,13 +13,14 @@ object TranslatorConfigurationManager { fun showConfigurationDialog(translator: AbstractTranslator): Boolean { return when (translator.key) { "Google" -> GoogleTranslatorSettingsDialog().showAndGet() + "OpenAI" -> OpenAITranslatorSettingsDialog(translator, SettingsState.getInstance()).showAndGet() "DeepL" -> DeepLTranslatorCredentialsDialog(translator, SettingsState.getInstance()).showAndGet() else -> showCredentialDialog(translator) } } fun hasConfiguration(translator: AbstractTranslator): Boolean { - return translator.key == "Google" || translator.credentialDefinitions.isNotEmpty() + return translator.key == "Google" || translator.key == "OpenAI" || translator.credentialDefinitions.isNotEmpty() } private fun showCredentialDialog(translator: AbstractTranslator): Boolean { diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.kt deleted file mode 100644 index 7660172..0000000 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTMessage.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.openai - -data class ChatGPTMessage( - var role: String, - var content: String -) \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt deleted file mode 100644 index 027c19c..0000000 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/ChatGPTTranslator.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.openai - -import com.airsaid.localization.translate.AbstractTranslator -import com.airsaid.localization.translate.TranslatorCredentialDescriptor -import com.airsaid.localization.translate.lang.Lang -import com.airsaid.localization.translate.lang.Languages -import com.airsaid.localization.translate.util.GsonUtil -import com.google.auto.service.AutoService -import com.intellij.openapi.diagnostic.Logger -import com.intellij.util.io.RequestBuilder -import icons.PluginIcons -import javax.swing.Icon - -@AutoService(AbstractTranslator::class) -class ChatGPTTranslator : AbstractTranslator() { - companion object { - private val LOG = Logger.getInstance(ChatGPTTranslator::class.java) - private const val KEY = "ChatGPT" - } - - override val key: String - get() = KEY - - override val name: String - get() = "OpenAI ChatGPT" - - override val icon: Icon? - get() = PluginIcons.OPENAI_ICON - - override val credentialDefinitions: List - get() = listOf( - TranslatorCredentialDescriptor(id = "appKey", label = "API Key", isSecret = true) - ) - - override val supportedLanguages: List - get() = Languages.getLanguages() - - override val requestContentType: String - get() = "application/json" - - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { - return "https://api.openai.com/v1/chat/completions" - } - - override fun getRequestBody(fromLang: Lang, toLang: Lang, text: String): String { - val lang = toLang.englishName - val roleSystem = String.format( - "Translate the user provided text into high quality, well written %s. Apply these 4 translation rules; 1.Keep the exact original formatting and style, 2.Keep translations concise and just repeat the original text for unchanged translations (e.g. 'OK'), 3.Audience: native %s speakers, 4.Text can be used in Android app UI (limited space, concise translations!).", - lang, lang - ) - - val role = ChatGPTMessage("system", roleSystem) - val msg = ChatGPTMessage("user", String.format("Text to translate: %s", text)) - - val body = OpenAIRequest("gpt-3.5-turbo", listOf(role, msg)) - - return GsonUtil.getInstance().gson.toJson(body) - } - - override fun configureRequestBuilder(requestBuilder: RequestBuilder) { - requestBuilder.tuner { connection -> - connection.setRequestProperty("Authorization", "Bearer ${credentialValue("appKey")}") - connection.setRequestProperty("Content-Type", "application/json") - } - } - - override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { - LOG.info("parsingResult ChatGPT: $resultText") - return GsonUtil.getInstance().gson.fromJson(resultText, OpenAIResponse::class.java).translation - } -} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIModels.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIModels.kt new file mode 100644 index 0000000..75a704c --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIModels.kt @@ -0,0 +1,23 @@ +package com.airsaid.localization.translate.impl.openai + +import com.google.gson.annotations.SerializedName + +data class OpenAIRequest( + var model: String, + var messages: List +) + +data class OpenAIMessage( + var role: String, + var content: String +) + +data class OpenAIModelsResponse( + @SerializedName("data") + val data: List = emptyList() +) + +data class OpenAIModel( + @SerializedName("id") + val id: String = "" +) diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIRequest.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIRequest.kt deleted file mode 100644 index 006b4a8..0000000 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIRequest.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.impl.openai - -data class OpenAIRequest( - var model: String, - var messages: List -) \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt index 2b0db7b..b353143 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt @@ -17,37 +17,43 @@ package com.airsaid.localization.translate.impl.openai +import com.google.gson.annotations.SerializedName + data class OpenAIResponse( - var choices: List?, - var created: Int?, - var id: String?, - var `object`: String?, - var usage: Usage? + var choices: List?, + var created: Int?, + var id: String?, + var `object`: String?, + var usage: Usage? ) { - val translation: String - get() { - return if (!choices.isNullOrEmpty()) { - val result = choices!![0].message?.content - result?.trim() ?: "" - } else { - "" - } - } + val translation: String + get() { + return if (!choices.isNullOrEmpty()) { + val result = choices!![0].message?.content + result?.trim() ?: "" + } else { + "" + } + } - data class Choice( - var finish_reason: String?, - var index: Int?, - var message: Message? - ) + data class Choice( + @SerializedName("finish_reason") + var finishReason: String?, + var index: Int?, + var message: Message? + ) - data class Message( - var content: String?, - var role: String? - ) + data class Message( + var content: String?, + var role: String? + ) - data class Usage( - var completion_tokens: Int?, - var prompt_tokens: Int?, - var total_tokens: Int? - ) + data class Usage( + @SerializedName("completion_tokens") + var completionTokens: Int?, + @SerializedName("prompt_tokens") + var promptTokens: Int?, + @SerializedName("total_tokens") + var totalTokens: Int? + ) } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt new file mode 100644 index 0000000..f1a630b --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.airsaid.localization.translate.impl.openai + +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.TranslatorCredentialDescriptor +import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.util.GsonUtil +import com.google.auto.service.AutoService +import com.intellij.openapi.diagnostic.Logger +import com.intellij.util.io.RequestBuilder +import icons.PluginIcons +import javax.swing.Icon + +@AutoService(AbstractTranslator::class) +class OpenAITranslator : AbstractTranslator() { + companion object { + private val LOG = Logger.getInstance(OpenAITranslator::class.java) + private const val KEY = "OpenAI" + private const val CHAT_COMPLETIONS_PATH = "/v1/chat/completions" + } + + private val settings: OpenAITranslatorSettings + get() = OpenAITranslatorSettings.getInstance() + + override val key = KEY + + override val name = KEY + + override val icon = PluginIcons.OPENAI_ICON + + override val credentialDefinitions: List + get() = listOf( + TranslatorCredentialDescriptor(id = "appKey", label = "API Key", isSecret = true) + ) + + override val supportedLanguages: List + get() = Languages.getLanguages() + + override val requestContentType: String + get() = "application/json" + + @Throws(TranslationException::class) + override fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { + if (credentialValue("appKey").isBlank()) { + throw TranslationException(fromLang, toLang, text, "OpenAI API key is required. Add it in OpenAI Settings.") + } + return super.doTranslate(fromLang, toLang, text) + } + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + return buildUrl(settings.resolvedBaseUrl(), CHAT_COMPLETIONS_PATH) + } + + override fun getRequestBody(fromLang: Lang, toLang: Lang, text: String): String { + val lang = toLang.englishName + val roleSystem = String.format( + "Translate the user provided text into high quality, well written %s. Apply these 4 translation rules; 1.Keep the exact original formatting and style, 2.Keep translations concise and just repeat the original text for unchanged translations (e.g. 'OK'), 3.Audience: native %s speakers, 4.Text can be used in Android app UI (limited space, concise translations!).", + lang, lang + ) + + val role = OpenAIMessage("system", roleSystem) + val msg = OpenAIMessage("user", String.format("Text to translate: %s", text)) + + val body = OpenAIRequest(settings.resolvedModel(), listOf(role, msg)) + + return GsonUtil.getInstance().gson.toJson(body) + } + + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + val apiKey = credentialValue("appKey").trim() + requestBuilder.tuner { connection -> + connection.setRequestProperty("Authorization", "Bearer $apiKey") + connection.setRequestProperty("Content-Type", "application/json") + } + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult OpenAI: $resultText") + return GsonUtil.getInstance().gson.fromJson(resultText, OpenAIResponse::class.java).translation + } + + private fun buildUrl(base: String, path: String): String { + val prefix = base.trimEnd('/') + val suffix = path.trimStart('/') + return "$prefix/$suffix" + } +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt new file mode 100644 index 0000000..ccb5274 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt @@ -0,0 +1,127 @@ +package com.airsaid.localization.translate.impl.openai + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.components.service +import java.net.URI + +@Service +@State( + name = "com.airsaid.localization.OpenAITranslatorSettings", + storages = [Storage("openAITranslatorSettings.xml")] +) +class OpenAITranslatorSettings : PersistentStateComponent { + + data class State( + var selectedModel: String = DEFAULT_MODEL, + var useCustomModel: Boolean = false, + var customModel: String = "", + var apiHost: String = "", + var cachedModels: MutableList = mutableListOf(), + var lastModelSyncTimestamp: Long = 0L, + ) + + private var state = State() + + var selectedModel: String + get() = state.selectedModel.ifBlank { DEFAULT_MODEL } + set(value) { + state = state.copy(selectedModel = value.ifBlank { DEFAULT_MODEL }) + } + + var useCustomModel: Boolean + get() = state.useCustomModel + set(value) { + state = state.copy(useCustomModel = value) + } + + var customModel: String + get() = state.customModel + set(value) { + state = state.copy(customModel = value) + } + + var apiHost: String + get() = state.apiHost + set(value) { + state = state.copy(apiHost = value.trim()) + } + + var cachedModels: List + get() = state.cachedModels.takeIf { it.isNotEmpty() } ?: DEFAULT_MODELS + private set(value) { + state = state.copy(cachedModels = value.toMutableList()) + } + + var lastModelSyncTimestamp: Long + get() = state.lastModelSyncTimestamp + private set(value) { + state = state.copy(lastModelSyncTimestamp = value) + } + + fun resolvedModel(): String { + val custom = customModel.trim() + return if (useCustomModel && custom.isNotEmpty()) { + custom + } else { + selectedModel + } + } + + fun resolvedBaseUrl(): String = normalizeBaseUrl(apiHost) + + fun shouldRefreshModels(currentTimeMillis: Long = System.currentTimeMillis()): Boolean { + return currentTimeMillis - lastModelSyncTimestamp >= MODEL_CACHE_TTL_MS + } + + fun updateCachedModels(models: List, fetchTimestamp: Long = System.currentTimeMillis()) { + val sanitized = models.filter { it.isNotBlank() }.map { it.trim() } + cachedModels = if (sanitized.isEmpty()) DEFAULT_MODELS else sanitized.distinct().sorted() + lastModelSyncTimestamp = fetchTimestamp + + if (!useCustomModel && selectedModel !in cachedModels) { + selectedModel = cachedModels.firstOrNull() ?: DEFAULT_MODEL + } + } + + override fun getState(): State = state + + override fun loadState(state: State) { + this.state = state.copy( + cachedModels = state.cachedModels.takeIf { it.isNotEmpty() }?.toMutableList() ?: DEFAULT_MODELS.toMutableList() + ) + } + + companion object { + const val DEFAULT_API_HOST = "https://api.openai.com" + val DEFAULT_MODELS: List = listOf( + "gpt-5", "gpt-5-mini", "gpt-5-nano", + "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano" + ) + val DEFAULT_MODEL: String = DEFAULT_MODELS.first() + private val MODEL_CACHE_TTL_MS: Long = java.time.Duration.ofDays(1).toMillis() + + fun getInstance(): OpenAITranslatorSettings = service() + + fun normalizeBaseUrl(host: String): String { + val rawHost = host.trim().ifBlank { DEFAULT_API_HOST } + val hostWithScheme = ensureScheme(rawHost) + val sanitized = hostWithScheme.removeSuffix("/") + return runCatching { URI(sanitized) } + .map { uri -> + URI(uri.scheme, uri.userInfo, uri.host, uri.port, uri.path, uri.query, uri.fragment).toString() + } + .getOrDefault(sanitized) + } + + private fun ensureScheme(host: String): String { + return if (host.startsWith("http://") || host.startsWith("https://")) { + host + } else { + "https://$host" + } + } + } +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettingsDialog.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettingsDialog.kt new file mode 100644 index 0000000..8671f99 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettingsDialog.kt @@ -0,0 +1,264 @@ +package com.airsaid.localization.translate.impl.openai + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.airsaid.localization.config.SettingsState +import com.airsaid.localization.config.TranslatorCredentialsDialog +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.util.GsonUtil +import com.airsaid.localization.translate.util.HttpRequestFactory +import com.airsaid.localization.ui.components.IdeCheckBox +import com.airsaid.localization.ui.components.IdeDropdownField +import com.airsaid.localization.ui.components.IdeTextField +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.Dimension + +class OpenAITranslatorSettingsDialog( + translator: AbstractTranslator, + settingsState: SettingsState, +) : TranslatorCredentialsDialog(translator, settingsState) { + + private val openAISettings = OpenAITranslatorSettings.getInstance() + + init { + title = "OpenAI Settings" + } + + override fun preferredSize() = Dimension(480, 400) + + @Composable + override fun Content() { + var selectedModel by remember { mutableStateOf(openAISettings.selectedModel) } + var useCustomModel by remember { mutableStateOf(openAISettings.useCustomModel) } + var customModel by remember { mutableStateOf(openAISettings.customModel) } + var apiHost by remember { mutableStateOf(openAISettings.apiHost) } + var availableModels by remember { mutableStateOf(openAISettings.cachedModels) } + var loadingModels by remember { mutableStateOf(false) } + var loadErrorMessage by remember { mutableStateOf(null) } + var lastSyncedAt by remember { mutableStateOf(openAISettings.lastModelSyncTimestamp) } + var lastFetchedBase by remember { mutableStateOf(openAISettings.resolvedBaseUrl()) } + + fun buildBaseUrl(): String = OpenAITranslatorSettings.normalizeBaseUrl(apiHost) + + fun ensureSelectedModel(models: List) { + if (!useCustomModel) { + if (models.isEmpty()) { + selectedModel = "" + } else if (selectedModel.isBlank() || selectedModel !in models) { + selectedModel = models.first() + } + } + } + + val apiKeyState = credentialValuesState[API_KEY_ID].orEmpty() + + LaunchedEffect(Unit) { + ensureSelectedModel(availableModels) + } + + LaunchedEffect(apiKeyState, apiHost, lastSyncedAt) { + val trimmedKey = apiKeyState.trim() + if (trimmedKey.isBlank()) { + availableModels = openAISettings.cachedModels + ensureSelectedModel(availableModels) + return@LaunchedEffect + } + + if (loadingModels) return@LaunchedEffect + + val normalizedBase = buildBaseUrl() + val shouldRefresh = + availableModels.isEmpty() || openAISettings.shouldRefreshModels() || normalizedBase != lastFetchedBase + if (!shouldRefresh) { + availableModels = openAISettings.cachedModels + ensureSelectedModel(availableModels) + return@LaunchedEffect + } + + loadingModels = true + loadErrorMessage = null + val result = withContext(Dispatchers.IO) { + runCatching { fetchModels(normalizedBase, trimmedKey) } + } + loadingModels = false + result.onSuccess { models -> + openAISettings.updateCachedModels(models) + availableModels = openAISettings.cachedModels + ensureSelectedModel(availableModels) + if (!useCustomModel) { + selectedModel = openAISettings.selectedModel + } + lastSyncedAt = openAISettings.lastModelSyncTimestamp + lastFetchedBase = normalizedBase + }.onFailure { error -> + loadErrorMessage = "Failed to load models: ${error.message}" + } + } + + Column( + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = "API Key", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + IdeTextField( + value = credentialValuesState[API_KEY_ID].orEmpty(), + onValueChange = { newValue -> credentialValuesState[API_KEY_ID] = newValue.trimStart() }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + secureInput = true, + placeholder = { + Text( + text = "sk-...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "API Endpoint", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + IdeTextField( + value = apiHost, + onValueChange = { apiHost = it.trimStart() }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { + Text( + text = OpenAITranslatorSettings.DEFAULT_API_HOST, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + Text( + text = "Leave blank to use the default OpenAI endpoint.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Text( + text = "Model", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + IdeCheckBox( + checked = useCustomModel, + onValueChange = { + useCustomModel = it + if (!it) { + ensureSelectedModel(availableModels) + selectedModel = openAISettings.selectedModel + } + }, + title = "Use custom model" + ) + + if (useCustomModel) { + IdeTextField( + value = customModel, + onValueChange = { customModel = it.trimStart() }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { + Text( + text = "e.g. gpt-4.1-custom", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + } else { + IdeDropdownField( + value = selectedModel, + enabled = availableModels.isNotEmpty(), + options = availableModels, + placeholder = if (availableModels.isEmpty()) "No models available" else "Select model", + loading = loadingModels, + onOptionSelected = { model -> selectedModel = model } + ) + + loadErrorMessage?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + if (!loadingModels && loadErrorMessage == null) { + val helperText = if (lastSyncedAt > 0L) { + val formattedDate = java.time.Instant.ofEpochMilli(lastSyncedAt) + .atZone(java.time.ZoneId.systemDefault()) + .toLocalDate() + "Model list refreshes daily when an API key is configured. Last synced: $formattedDate" + } else { + "Model list refreshes daily when an API key is configured." + } + Text( + text = helperText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + OnClickOK { + openAISettings.selectedModel = selectedModel + openAISettings.useCustomModel = useCustomModel + openAISettings.customModel = customModel.trim() + openAISettings.apiHost = apiHost.trim() + } + } + + @Throws(Exception::class) + private fun fetchModels(baseUrl: String, apiKey: String): List { + val request = HttpRequestFactory.get(buildUrl(baseUrl, "/v1/models")) + request.tuner { connection -> + connection.setRequestProperty("Authorization", "Bearer $apiKey") + connection.setRequestProperty("Content-Type", "application/json") + } + val json = request.connect { it.readString() } + val response = GsonUtil.getInstance().gson.fromJson(json, OpenAIModelsResponse::class.java) + return response.data.mapNotNull { it.id.takeIf { id -> id.isNotBlank() } }.sorted() + } + + private fun buildUrl(base: String, path: String): String { + val prefix = base.trimEnd('/') + val suffix = path.trimStart('/') + return "$prefix/$suffix" + } + + companion object { + private const val API_KEY_ID = "appKey" + } +} diff --git a/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt b/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt index f0314db..ea74d98 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt @@ -12,7 +12,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale @@ -23,6 +26,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp private val CompactFieldHeight = 36.dp +private val CompactDropdownHeight = 32.dp @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -112,6 +116,104 @@ fun IdeTextField( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IdeDropdownField( + value: String, + options: List, + onOptionSelected: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: String? = null, + loading: Boolean = false, +) { + var expanded by remember { mutableStateOf(false) } + + val dropdownEnabled = enabled && options.isNotEmpty() && !loading + val colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f), + disabledBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + cursorColor = MaterialTheme.colorScheme.primary, + focusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + + if (!dropdownEnabled && expanded) { + expanded = false + } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + if (dropdownEnabled) { + expanded = !expanded + } + } + ) { + val fieldModifier = modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable, true) + .fillMaxWidth() + .heightIn(min = CompactDropdownHeight) + .defaultMinSize(minHeight = CompactDropdownHeight) + + OutlinedTextField( + value = value, + onValueChange = {}, + modifier = fieldModifier, + readOnly = true, + singleLine = true, + enabled = dropdownEnabled, + textStyle = MaterialTheme.typography.bodyMedium, + placeholder = placeholder?.let { placeholderText -> + { + Text( + text = placeholderText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + trailingIcon = { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + }, + colors = colors, + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + expanded = false + onOptionSelected(option) + } + ) + } + } + } +} + @Composable fun IdeCheckBox( checked: Boolean, diff --git a/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt b/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt index 7f80906..e131f2a 100644 --- a/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt +++ b/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt @@ -136,8 +136,6 @@ class AbstractTranslatorNetworkTest { private open inner class TestTranslator(private val endpoint: String) : AbstractTranslator() { override val key: String = "Test" override val name: String = "Test" - override val isNeedAppId: Boolean = false - override val isNeedAppKey: Boolean = false override val supportedLanguages: List = listOf(Languages.ENGLISH) override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { From 616b59937787a1c4f8c49f5d187190f5f15286cc Mon Sep 17 00:00:00 2001 From: Airsaid Date: Thu, 25 Sep 2025 21:37:21 +0800 Subject: [PATCH 30/58] Fix OpenAI translator including auto language support --- .../translate/AbstractTranslator.kt | 5 + .../impl/google/AbsGoogleTranslator.kt | 44 +-- .../translate/impl/openai/OpenAITranslator.kt | 7 +- .../localization/translate/lang/Languages.kt | 300 +++++++++--------- 4 files changed, 180 insertions(+), 176 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt index befa818..7cde15b 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt @@ -19,6 +19,7 @@ package com.airsaid.localization.translate import com.airsaid.localization.config.SettingsState import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages import com.airsaid.localization.translate.util.HttpRequestFactory import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.util.Pair @@ -33,8 +34,12 @@ import javax.swing.Icon abstract class AbstractTranslator : Translator, TranslatorConfigurable { abstract override val key: String + abstract override val name: String + override val supportedLanguages: List + get() = Languages.getAllSupportedLanguages() + companion object { protected val LOG = Logger.getInstance(AbstractTranslator::class.java) private const val DEFAULT_CONTENT_TYPE = "application/x-www-form-urlencoded" diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt index 1edb29d..4569a27 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt @@ -29,31 +29,31 @@ import javax.swing.Icon */ abstract class AbsGoogleTranslator : AbstractTranslator() { - protected var _supportedLanguages: MutableList? = null + protected var _supportedLanguages: MutableList? = null - override val icon: Icon = PluginIcons.GOOGLE_ICON + override val icon: Icon = PluginIcons.GOOGLE_ICON - override val credentialDefinitions: List = emptyList() + override val credentialDefinitions: List = emptyList() - override val supportedLanguages: List - get() { - if (_supportedLanguages == null) { - val languages = Languages.getLanguages() - _supportedLanguages = mutableListOf().apply { - for (i in 1..104) { - var lang = languages[i] - lang = when (lang) { - Languages.CHINESE_SIMPLIFIED -> lang.setTranslationCode("zh-CN") - Languages.CHINESE_TRADITIONAL -> lang.setTranslationCode("zh-TW") - Languages.FILIPINO -> lang.setTranslationCode("tl") - Languages.INDONESIAN -> lang.setTranslationCode("id") - Languages.JAVANESE -> lang.setTranslationCode("jw") - else -> lang - } - add(lang) - } - } + override val supportedLanguages: List + get() { + if (_supportedLanguages == null) { + val languages = Languages.getLanguages() + _supportedLanguages = mutableListOf().apply { + for (i in 1..104) { + var lang = languages[i] + lang = when (lang) { + Languages.CHINESE_SIMPLIFIED -> lang.setTranslationCode("zh-CN") + Languages.CHINESE_TRADITIONAL -> lang.setTranslationCode("zh-TW") + Languages.FILIPINO -> lang.setTranslationCode("tl") + Languages.INDONESIAN -> lang.setTranslationCode("id") + Languages.JAVANESE -> lang.setTranslationCode("jw") + else -> lang } - return _supportedLanguages!! + add(lang) + } } + } + return _supportedLanguages!! + } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt index f1a630b..1589466 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt @@ -18,16 +18,14 @@ package com.airsaid.localization.translate.impl.openai import com.airsaid.localization.translate.AbstractTranslator -import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.TranslationException +import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang -import com.airsaid.localization.translate.lang.Languages import com.airsaid.localization.translate.util.GsonUtil import com.google.auto.service.AutoService import com.intellij.openapi.diagnostic.Logger import com.intellij.util.io.RequestBuilder import icons.PluginIcons -import javax.swing.Icon @AutoService(AbstractTranslator::class) class OpenAITranslator : AbstractTranslator() { @@ -51,9 +49,6 @@ class OpenAITranslator : AbstractTranslator() { TranslatorCredentialDescriptor(id = "appKey", label = "API Key", isSecret = true) ) - override val supportedLanguages: List - get() = Languages.getLanguages() - override val requestContentType: String get() = "application/json" diff --git a/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt b/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt index 9c2cd13..52e36d6 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt @@ -23,154 +23,158 @@ package com.airsaid.localization.translate.lang // Some language codes and names cannot pass the compiler check @Suppress("SpellCheckingInspection", "unused") object Languages { - val AUTO = Lang(0, "auto", "Auto", "Auto") - val ALBANIAN = Lang(1, "sq", "Shqiptar", "Albanian") - val ARABIC = Lang(2, "ar", "العربية", "Arabic") - val AMHARIC = Lang(3, "am", "አማርኛ", "Amharic") - val AZERBAIJANI = Lang(4, "az", "азәрбајҹан", "Azerbaijani") - val IRISH = Lang(5, "ga", "Gaeilge", "Irish") - val ESTONIAN = Lang(6, "et", "Eesti", "Estonian") - val BASQUE = Lang(7, "eu", "Euskal", "Basque") - val BELARUSIAN = Lang(8, "be", "беларускі", "Belarusian") - val BULGARIAN = Lang(9, "bg", "Български", "Bulgarian") - val ICELANDIC = Lang(10, "is", "Íslenska", "Icelandic") - val POLISH = Lang(11, "pl", "Polski", "Polish") - val BOSNIAN = Lang(12, "bs", "Bosanski", "Bosnian") - val PERSIAN = Lang(13, "fa", "Persian", "Persian") - val AFRIKAANS = Lang(14, "af", "Afrikaans", "Afrikaans") - val DANISH = Lang(15, "da", "Dansk", "Danish") - val GERMAN = Lang(16, "de", "Deutsch", "German") - val RUSSIAN = Lang(17, "ru", "Русский", "Russian") - val FRENCH = Lang(18, "fr", "Français", "French") - val FILIPINO = Lang(19, "fil", "Filipino", "Filipino") - val FINNISH = Lang(20, "fi", "Suomi", "Finnish") - val FRISIAN = Lang(21, "fy", "Frysk", "Frisian") - val KHMER = Lang(22, "km", "ខ្មែរ", "Khmer") - val GEORGIAN = Lang(23, "ka", "ქართული", "Georgian") - val GUJARATI = Lang(24, "gu", "ગુજરાતી", "Gujarati") - val KAZAKH = Lang(25, "kk", "Kazakh", "Kazakh") - val HAITIAN_CREOLE = Lang(26, "ht", "Haitian Creole", "Haitian Creole") - val KOREAN = Lang(27, "ko", "한국어", "Korean") - val HAUSA = Lang(28, "ha", "Hausa", "Hausa") - val DUTCH = Lang(29, "nl", "Nederlands", "Dutch") - val KYRGYZ = Lang(30, "ky", "Кыргыз тили", "Kyrgyz") - val GALICIAN = Lang(31, "gl", "Galego", "Galician") - val CATALAN = Lang(32, "ca", "Català", "Catalan") - val CZECH = Lang(33, "cs", "Čeština", "Czech") - val KANNADA = Lang(34, "kn", "ಕನ್ನಡ", "Kannada") - val CORSICAN = Lang(35, "co", "Corsa", "Corsican") - val CROATIAN = Lang(36, "hr", "Hrvatski", "Croatian") - val KURDISH = Lang(37, "ku", "Kurdî", "Kurdish") - val LATIN = Lang(38, "la", "Latina", "Latin") - val LATVIAN = Lang(39, "lv", "Latviešu", "Latvian") - val LAO = Lang(40, "lo", "ລາວ", "Lao") - val LITHUANIAN = Lang(41, "lt", "Lietuvių", "Lithuanian") - val LUXEMBOURGISH = Lang(42, "lb", "Lëtzebuergesch", "Luxembourgish") - val ROMANIAN = Lang(43, "ro", "Română", "Romanian") - val MALAGASY = Lang(44, "mg", "Malagasy", "Malagasy") - val MALTESE = Lang(45, "mt", "Il-Malti", "Maltese") - val MARATHI = Lang(46, "mr", "मराठी", "Marathi") - val MALAYALAM = Lang(47, "ml", "മലയാളം", "Malayalam") - val MALAY = Lang(48, "ms", "Melayu", "Malay") - val MACEDONIAN = Lang(49, "mk", "Македонски", "Macedonian") - val MAORI = Lang(50, "mi", "Māori", "Maori") - val MONGOLIAN = Lang(51, "mn", "Монгол хэл", "Mongolian") - val BANGLA = Lang(52, "bn", "বাংল", "Bangla") - val BURMESE = Lang(53, "my", "မြန်မာ", "Burmese") - val HMONG = Lang(54, "hmn", "Hmoob", "Hmong") - val XHOSA = Lang(55, "xh", "IsiXhosa", "Xhosa") - val ZULU = Lang(56, "zu", "Zulu", "Zulu") - val NEPALI = Lang(57, "ne", "नेपाली", "Nepali") - val NORWEGIAN = Lang(58, "no", "Norsk", "Norwegian") - val PUNJABI = Lang(59, "pa", "ਪੰਜਾਬੀ", "Punjabi") - val PORTUGUESE = Lang(60, "pt", "Português", "Portuguese") - val PASHTO = Lang(61, "ps", "Pashto", "Pashto") - val CHICHEWA = Lang(62, "ny", "Chichewa", "Chichewa") - val JAPANESE = Lang(63, "ja", "日本語", "Japanese") - val SWEDISH = Lang(64, "sv", "Svenska", "Swedish") - val SAMOAN = Lang(65, "sm", "Samoa", "Samoan") - val SERBIAN = Lang(66, "sr", "Српски", "Serbian") - val SOTHO = Lang(67, "st", "Sesotho", "Sotho") - val SINHALA = Lang(68, "si", "සිංහල", "Sinhala") - val ESPERANTO = Lang(69, "eo", "Esperanta", "Esperanto") - val SLOVAK = Lang(70, "sk", "Slovenčina", "Slovak") - val SLOVENIAN = Lang(71, "sl", "Slovenščina", "Slovenian") - val SWAHILI = Lang(72, "sw", "Kiswahili", "Swahili") - val SCOTTISH_GAELIC = Lang(73, "gd", "Gàidhlig na h-Alba", "Scottish Gaelic") - val CEBUANO = Lang(74, "ceb", "Cebuano", "Cebuano") - val SOMALI = Lang(75, "so", "Somali", "Somali") - val TAJIK = Lang(76, "tg", "Тоҷикӣ", "Tajik") - val TELUGU = Lang(77, "te", "తెలుగు", "Telugu") - val TAMIL = Lang(78, "ta", "தமிழ்", "Tamil") - val THAI = Lang(79, "th", "ไทย", "Thai") - val TURKISH = Lang(80, "tr", "Türkçe", "Turkish") - val WELSH = Lang(81, "cy", "Cymraeg", "Welsh") - val URDU = Lang(82, "ur", "اردو", "Urdu") - val UKRAINIAN = Lang(83, "uk", "Українська", "Ukrainian") - val UZBEK = Lang(84, "uz", "O'zbek", "Uzbek") - val SPANISH = Lang(85, "es", "Español", "Spanish") - val HEBREW = Lang(86, "iw", "עברית", "Hebrew") - val GREEK = Lang(87, "el", "Ελληνικά", "Greek") - val HAWAIIAN = Lang(88, "haw", "Hawaiian", "Hawaiian") - val SINDHI = Lang(89, "sd", "سنڌي", "Sindhi") - val HUNGARIAN = Lang(90, "hu", "Magyar", "Hungarian") - val SHONA = Lang(91, "sn", "Shona", "Shona") - val ARMENIAN = Lang(92, "hy", "Հայերեն", "Armenian") - val IGBO = Lang(93, "ig", "Igbo", "Igbo") - val ITALIAN = Lang(94, "it", "Italiano", "Italian") - val YIDDISH = Lang(95, "yi", "ייִדיש", "Yiddish") - val HINDI = Lang(96, "hi", "हिंदी", "Hindi") - val SUNDANESE = Lang(97, "su", "Sunda", "Sundanese") - val INDONESIAN = Lang(98, "in-rID", "Indonesia", "Indonesian") - val JAVANESE = Lang(99, "jv", "Wong Jawa", "Javanese") - val ENGLISH = Lang(100, "en", "English", "English") - val YORUBA = Lang(101, "yo", "Yorùbá", "Yoruba") - val VIETNAMESE = Lang(102, "vi", "Tiếng Việt", "Vietnamese") - val CHINESE_TRADITIONAL = Lang(103, "zh-rTW", "正體中文", "Chinese Traditional") - val CHINESE_SIMPLIFIED = Lang(104, "zh-rCN", "简体中文", "Chinese Simplified") - val ASSAMESE = Lang(105, "as", "Assamese", "Assamese") - val DARI = Lang(106, "prs", "Dari", "Dari") - val FIJIAN = Lang(107, "fj", "Fijian", "Fijian") - val HMONG_DAW = Lang(108, "mww", "Hmong Daw", "Hmong Daw") - val INUKTITUT = Lang(109, "iu", "ᐃᓄᒃᑎᑐᑦ", "Inuktitut") - val KLINGON_LATIN = Lang(110, "tlh-Latn", "Klingon (Latin)", "Klingon (Latin)") - val KLINGON_PIQAD = Lang(111, "tlh-Piqd", "Klingon (pIqaD)", "Klingon (pIqaD)") - val ODIA = Lang(112, "or", "Odia", "Odia") - val QUERETARO_OTOMI = Lang(113, "otq", "Querétaro Otomi", "Querétaro Otomi") - val TAHITIAN = Lang(114, "ty", "Tahitian", "Tahitian") - val TIGRINYA = Lang(115, "ti", "ትግርኛ", "Tigrinya") - val TONGAN = Lang(116, "to", "lea fakatonga", "Tongan") - val YUCATEC_MAYA = Lang(117, "yua", "Yucatec Maya", "Yucatec Maya") + val AUTO = Lang(0, "auto", "Auto", "Auto") + val ALBANIAN = Lang(1, "sq", "Shqiptar", "Albanian") + val ARABIC = Lang(2, "ar", "العربية", "Arabic") + val AMHARIC = Lang(3, "am", "አማርኛ", "Amharic") + val AZERBAIJANI = Lang(4, "az", "азәрбајҹан", "Azerbaijani") + val IRISH = Lang(5, "ga", "Gaeilge", "Irish") + val ESTONIAN = Lang(6, "et", "Eesti", "Estonian") + val BASQUE = Lang(7, "eu", "Euskal", "Basque") + val BELARUSIAN = Lang(8, "be", "беларускі", "Belarusian") + val BULGARIAN = Lang(9, "bg", "Български", "Bulgarian") + val ICELANDIC = Lang(10, "is", "Íslenska", "Icelandic") + val POLISH = Lang(11, "pl", "Polski", "Polish") + val BOSNIAN = Lang(12, "bs", "Bosanski", "Bosnian") + val PERSIAN = Lang(13, "fa", "Persian", "Persian") + val AFRIKAANS = Lang(14, "af", "Afrikaans", "Afrikaans") + val DANISH = Lang(15, "da", "Dansk", "Danish") + val GERMAN = Lang(16, "de", "Deutsch", "German") + val RUSSIAN = Lang(17, "ru", "Русский", "Russian") + val FRENCH = Lang(18, "fr", "Français", "French") + val FILIPINO = Lang(19, "fil", "Filipino", "Filipino") + val FINNISH = Lang(20, "fi", "Suomi", "Finnish") + val FRISIAN = Lang(21, "fy", "Frysk", "Frisian") + val KHMER = Lang(22, "km", "ខ្មែរ", "Khmer") + val GEORGIAN = Lang(23, "ka", "ქართული", "Georgian") + val GUJARATI = Lang(24, "gu", "ગુજરાતી", "Gujarati") + val KAZAKH = Lang(25, "kk", "Kazakh", "Kazakh") + val HAITIAN_CREOLE = Lang(26, "ht", "Haitian Creole", "Haitian Creole") + val KOREAN = Lang(27, "ko", "한국어", "Korean") + val HAUSA = Lang(28, "ha", "Hausa", "Hausa") + val DUTCH = Lang(29, "nl", "Nederlands", "Dutch") + val KYRGYZ = Lang(30, "ky", "Кыргыз тили", "Kyrgyz") + val GALICIAN = Lang(31, "gl", "Galego", "Galician") + val CATALAN = Lang(32, "ca", "Català", "Catalan") + val CZECH = Lang(33, "cs", "Čeština", "Czech") + val KANNADA = Lang(34, "kn", "ಕನ್ನಡ", "Kannada") + val CORSICAN = Lang(35, "co", "Corsa", "Corsican") + val CROATIAN = Lang(36, "hr", "Hrvatski", "Croatian") + val KURDISH = Lang(37, "ku", "Kurdî", "Kurdish") + val LATIN = Lang(38, "la", "Latina", "Latin") + val LATVIAN = Lang(39, "lv", "Latviešu", "Latvian") + val LAO = Lang(40, "lo", "ລາວ", "Lao") + val LITHUANIAN = Lang(41, "lt", "Lietuvių", "Lithuanian") + val LUXEMBOURGISH = Lang(42, "lb", "Lëtzebuergesch", "Luxembourgish") + val ROMANIAN = Lang(43, "ro", "Română", "Romanian") + val MALAGASY = Lang(44, "mg", "Malagasy", "Malagasy") + val MALTESE = Lang(45, "mt", "Il-Malti", "Maltese") + val MARATHI = Lang(46, "mr", "मराठी", "Marathi") + val MALAYALAM = Lang(47, "ml", "മലയാളം", "Malayalam") + val MALAY = Lang(48, "ms", "Melayu", "Malay") + val MACEDONIAN = Lang(49, "mk", "Македонски", "Macedonian") + val MAORI = Lang(50, "mi", "Māori", "Maori") + val MONGOLIAN = Lang(51, "mn", "Монгол хэл", "Mongolian") + val BANGLA = Lang(52, "bn", "বাংল", "Bangla") + val BURMESE = Lang(53, "my", "မြန်မာ", "Burmese") + val HMONG = Lang(54, "hmn", "Hmoob", "Hmong") + val XHOSA = Lang(55, "xh", "IsiXhosa", "Xhosa") + val ZULU = Lang(56, "zu", "Zulu", "Zulu") + val NEPALI = Lang(57, "ne", "नेपाली", "Nepali") + val NORWEGIAN = Lang(58, "no", "Norsk", "Norwegian") + val PUNJABI = Lang(59, "pa", "ਪੰਜਾਬੀ", "Punjabi") + val PORTUGUESE = Lang(60, "pt", "Português", "Portuguese") + val PASHTO = Lang(61, "ps", "Pashto", "Pashto") + val CHICHEWA = Lang(62, "ny", "Chichewa", "Chichewa") + val JAPANESE = Lang(63, "ja", "日本語", "Japanese") + val SWEDISH = Lang(64, "sv", "Svenska", "Swedish") + val SAMOAN = Lang(65, "sm", "Samoa", "Samoan") + val SERBIAN = Lang(66, "sr", "Српски", "Serbian") + val SOTHO = Lang(67, "st", "Sesotho", "Sotho") + val SINHALA = Lang(68, "si", "සිංහල", "Sinhala") + val ESPERANTO = Lang(69, "eo", "Esperanta", "Esperanto") + val SLOVAK = Lang(70, "sk", "Slovenčina", "Slovak") + val SLOVENIAN = Lang(71, "sl", "Slovenščina", "Slovenian") + val SWAHILI = Lang(72, "sw", "Kiswahili", "Swahili") + val SCOTTISH_GAELIC = Lang(73, "gd", "Gàidhlig na h-Alba", "Scottish Gaelic") + val CEBUANO = Lang(74, "ceb", "Cebuano", "Cebuano") + val SOMALI = Lang(75, "so", "Somali", "Somali") + val TAJIK = Lang(76, "tg", "Тоҷикӣ", "Tajik") + val TELUGU = Lang(77, "te", "తెలుగు", "Telugu") + val TAMIL = Lang(78, "ta", "தமிழ்", "Tamil") + val THAI = Lang(79, "th", "ไทย", "Thai") + val TURKISH = Lang(80, "tr", "Türkçe", "Turkish") + val WELSH = Lang(81, "cy", "Cymraeg", "Welsh") + val URDU = Lang(82, "ur", "اردو", "Urdu") + val UKRAINIAN = Lang(83, "uk", "Українська", "Ukrainian") + val UZBEK = Lang(84, "uz", "O'zbek", "Uzbek") + val SPANISH = Lang(85, "es", "Español", "Spanish") + val HEBREW = Lang(86, "iw", "עברית", "Hebrew") + val GREEK = Lang(87, "el", "Ελληνικά", "Greek") + val HAWAIIAN = Lang(88, "haw", "Hawaiian", "Hawaiian") + val SINDHI = Lang(89, "sd", "سنڌي", "Sindhi") + val HUNGARIAN = Lang(90, "hu", "Magyar", "Hungarian") + val SHONA = Lang(91, "sn", "Shona", "Shona") + val ARMENIAN = Lang(92, "hy", "Հայերեն", "Armenian") + val IGBO = Lang(93, "ig", "Igbo", "Igbo") + val ITALIAN = Lang(94, "it", "Italiano", "Italian") + val YIDDISH = Lang(95, "yi", "ייִדיש", "Yiddish") + val HINDI = Lang(96, "hi", "हिंदी", "Hindi") + val SUNDANESE = Lang(97, "su", "Sunda", "Sundanese") + val INDONESIAN = Lang(98, "in-rID", "Indonesia", "Indonesian") + val JAVANESE = Lang(99, "jv", "Wong Jawa", "Javanese") + val ENGLISH = Lang(100, "en", "English", "English") + val YORUBA = Lang(101, "yo", "Yorùbá", "Yoruba") + val VIETNAMESE = Lang(102, "vi", "Tiếng Việt", "Vietnamese") + val CHINESE_TRADITIONAL = Lang(103, "zh-rTW", "正體中文", "Chinese Traditional") + val CHINESE_SIMPLIFIED = Lang(104, "zh-rCN", "简体中文", "Chinese Simplified") + val ASSAMESE = Lang(105, "as", "Assamese", "Assamese") + val DARI = Lang(106, "prs", "Dari", "Dari") + val FIJIAN = Lang(107, "fj", "Fijian", "Fijian") + val HMONG_DAW = Lang(108, "mww", "Hmong Daw", "Hmong Daw") + val INUKTITUT = Lang(109, "iu", "ᐃᓄᒃᑎᑐᑦ", "Inuktitut") + val KLINGON_LATIN = Lang(110, "tlh-Latn", "Klingon (Latin)", "Klingon (Latin)") + val KLINGON_PIQAD = Lang(111, "tlh-Piqd", "Klingon (pIqaD)", "Klingon (pIqaD)") + val ODIA = Lang(112, "or", "Odia", "Odia") + val QUERETARO_OTOMI = Lang(113, "otq", "Querétaro Otomi", "Querétaro Otomi") + val TAHITIAN = Lang(114, "ty", "Tahitian", "Tahitian") + val TIGRINYA = Lang(115, "ti", "ትግርኛ", "Tigrinya") + val TONGAN = Lang(116, "to", "lea fakatonga", "Tongan") + val YUCATEC_MAYA = Lang(117, "yua", "Yucatec Maya", "Yucatec Maya") - private val languages: Map = mapOf( - 0 to AUTO, 1 to ALBANIAN, 2 to ARABIC, 3 to AMHARIC, 4 to AZERBAIJANI, - 5 to IRISH, 6 to ESTONIAN, 7 to BASQUE, 8 to BELARUSIAN, 9 to BULGARIAN, - 10 to ICELANDIC, 11 to POLISH, 12 to BOSNIAN, 13 to PERSIAN, 14 to AFRIKAANS, - 15 to DANISH, 16 to GERMAN, 17 to RUSSIAN, 18 to FRENCH, 19 to FILIPINO, - 20 to FINNISH, 21 to FRISIAN, 22 to KHMER, 23 to GEORGIAN, 24 to GUJARATI, - 25 to KAZAKH, 26 to HAITIAN_CREOLE, 27 to KOREAN, 28 to HAUSA, 29 to DUTCH, - 30 to KYRGYZ, 31 to GALICIAN, 32 to CATALAN, 33 to CZECH, 34 to KANNADA, - 35 to CORSICAN, 36 to CROATIAN, 37 to KURDISH, 38 to LATIN, 39 to LATVIAN, - 40 to LAO, 41 to LITHUANIAN, 42 to LUXEMBOURGISH, 43 to ROMANIAN, 44 to MALAGASY, - 45 to MALTESE, 46 to MARATHI, 47 to MALAYALAM, 48 to MALAY, 49 to MACEDONIAN, - 50 to MAORI, 51 to MONGOLIAN, 52 to BANGLA, 53 to BURMESE, 54 to HMONG, - 55 to XHOSA, 56 to ZULU, 57 to NEPALI, 58 to NORWEGIAN, 59 to PUNJABI, - 60 to PORTUGUESE, 61 to PASHTO, 62 to CHICHEWA, 63 to JAPANESE, 64 to SWEDISH, - 65 to SAMOAN, 66 to SERBIAN, 67 to SOTHO, 68 to SINHALA, 69 to ESPERANTO, - 70 to SLOVAK, 71 to SLOVENIAN, 72 to SWAHILI, 73 to SCOTTISH_GAELIC, 74 to CEBUANO, - 75 to SOMALI, 76 to TAJIK, 77 to TELUGU, 78 to TAMIL, 79 to THAI, - 80 to TURKISH, 81 to WELSH, 82 to URDU, 83 to UKRAINIAN, 84 to UZBEK, - 85 to SPANISH, 86 to HEBREW, 87 to GREEK, 88 to HAWAIIAN, 89 to SINDHI, - 90 to HUNGARIAN, 91 to SHONA, 92 to ARMENIAN, 93 to IGBO, 94 to ITALIAN, - 95 to YIDDISH, 96 to HINDI, 97 to SUNDANESE, 98 to INDONESIAN, 99 to JAVANESE, - 100 to ENGLISH, 101 to YORUBA, 102 to VIETNAMESE, 103 to CHINESE_TRADITIONAL, - 104 to CHINESE_SIMPLIFIED, 105 to ASSAMESE, 106 to DARI, 107 to FIJIAN, - 108 to HMONG_DAW, 109 to INUKTITUT, 110 to KLINGON_LATIN, 111 to KLINGON_PIQAD, - 112 to ODIA, 113 to QUERETARO_OTOMI, 114 to TAHITIAN, 115 to TIGRINYA, - 116 to TONGAN, 117 to YUCATEC_MAYA - ) + private val languages: Map = mapOf( + 0 to AUTO, 1 to ALBANIAN, 2 to ARABIC, 3 to AMHARIC, 4 to AZERBAIJANI, + 5 to IRISH, 6 to ESTONIAN, 7 to BASQUE, 8 to BELARUSIAN, 9 to BULGARIAN, + 10 to ICELANDIC, 11 to POLISH, 12 to BOSNIAN, 13 to PERSIAN, 14 to AFRIKAANS, + 15 to DANISH, 16 to GERMAN, 17 to RUSSIAN, 18 to FRENCH, 19 to FILIPINO, + 20 to FINNISH, 21 to FRISIAN, 22 to KHMER, 23 to GEORGIAN, 24 to GUJARATI, + 25 to KAZAKH, 26 to HAITIAN_CREOLE, 27 to KOREAN, 28 to HAUSA, 29 to DUTCH, + 30 to KYRGYZ, 31 to GALICIAN, 32 to CATALAN, 33 to CZECH, 34 to KANNADA, + 35 to CORSICAN, 36 to CROATIAN, 37 to KURDISH, 38 to LATIN, 39 to LATVIAN, + 40 to LAO, 41 to LITHUANIAN, 42 to LUXEMBOURGISH, 43 to ROMANIAN, 44 to MALAGASY, + 45 to MALTESE, 46 to MARATHI, 47 to MALAYALAM, 48 to MALAY, 49 to MACEDONIAN, + 50 to MAORI, 51 to MONGOLIAN, 52 to BANGLA, 53 to BURMESE, 54 to HMONG, + 55 to XHOSA, 56 to ZULU, 57 to NEPALI, 58 to NORWEGIAN, 59 to PUNJABI, + 60 to PORTUGUESE, 61 to PASHTO, 62 to CHICHEWA, 63 to JAPANESE, 64 to SWEDISH, + 65 to SAMOAN, 66 to SERBIAN, 67 to SOTHO, 68 to SINHALA, 69 to ESPERANTO, + 70 to SLOVAK, 71 to SLOVENIAN, 72 to SWAHILI, 73 to SCOTTISH_GAELIC, 74 to CEBUANO, + 75 to SOMALI, 76 to TAJIK, 77 to TELUGU, 78 to TAMIL, 79 to THAI, + 80 to TURKISH, 81 to WELSH, 82 to URDU, 83 to UKRAINIAN, 84 to UZBEK, + 85 to SPANISH, 86 to HEBREW, 87 to GREEK, 88 to HAWAIIAN, 89 to SINDHI, + 90 to HUNGARIAN, 91 to SHONA, 92 to ARMENIAN, 93 to IGBO, 94 to ITALIAN, + 95 to YIDDISH, 96 to HINDI, 97 to SUNDANESE, 98 to INDONESIAN, 99 to JAVANESE, + 100 to ENGLISH, 101 to YORUBA, 102 to VIETNAMESE, 103 to CHINESE_TRADITIONAL, + 104 to CHINESE_SIMPLIFIED, 105 to ASSAMESE, 106 to DARI, 107 to FIJIAN, + 108 to HMONG_DAW, 109 to INUKTITUT, 110 to KLINGON_LATIN, 111 to KLINGON_PIQAD, + 112 to ODIA, 113 to QUERETARO_OTOMI, 114 to TAHITIAN, 115 to TIGRINYA, + 116 to TONGAN, 117 to YUCATEC_MAYA + ) - fun getLanguages(): List { - return ArrayList(languages.values) - } + fun getLanguages(): List { + return ArrayList(languages.values) + } + + fun getAllSupportedLanguages(): List { + return getLanguages().filter { it != AUTO } + } } \ No newline at end of file From 3f29e85e3c20d7859abeb1d8bf8842eea4e38469 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Thu, 25 Sep 2025 23:12:19 +0800 Subject: [PATCH 31/58] Improve translation error handling and logging --- .../localization/task/TranslateTask.kt | 409 +++++++++--------- .../translate/AbstractTranslator.kt | 3 +- .../translate/TranslationException.kt | 7 +- 3 files changed, 215 insertions(+), 204 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt index ac33eb9..abbcf07 100644 --- a/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt +++ b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt @@ -36,229 +36,242 @@ import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile -import com.intellij.psi.xml.* +import com.intellij.psi.xml.XmlTag +import com.intellij.psi.xml.XmlText import java.io.File import java.util.* import java.util.stream.Collectors -import kotlin.collections.ArrayList /** * @author airsaid */ class TranslateTask( - project: Project?, - title: String, - private val toLanguages: List, - private val values: List, - valueFile: PsiFile + project: Project?, + title: String, + private val toLanguages: List, + private val values: List, + valueFile: PsiFile ) : Task.Backgroundable(project, title) { - companion object { - private const val NAME_TAG_STRING = "string" - private const val NAME_TAG_PLURALS = "plurals" - private const val NAME_TAG_STRING_ARRAY = "string-array" - private val LOG = Logger.getInstance(TranslateTask::class.java) - } - - interface OnTranslateListener { - fun onTranslateSuccess() - fun onTranslateError(e: Throwable) - } - - private val valueFile: VirtualFile = valueFile.virtualFile - private val translatorService = TranslatorService.getInstance() - private val valueService = AndroidValuesService.getInstance() - private var onTranslateListener: OnTranslateListener? = null - private var translationError: TranslationException? = null - - /** - * Set translate result listener. - * - * @param listener callback interface. success or fail. - */ - fun setOnTranslateListener(listener: OnTranslateListener) { - onTranslateListener = listener - } - - override fun run(progressIndicator: ProgressIndicator) { - val isOverwriteExistingString = PropertiesComponent.getInstance(project) - .getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING) - LOG.info("run isOverwriteExistingString: $isOverwriteExistingString") - - for (toLanguage in toLanguages) { - if (progressIndicator.isCanceled) break - - progressIndicator.text = "Translation to ${toLanguage.englishName}..." - - val resourceDir = valueFile.parent.parent - val valueFileName = valueFile.name - val toValuePsiFile = valueService.getValuePsiFile(project, resourceDir, toLanguage, valueFileName) - LOG.info("Translating language: ${toLanguage.englishName}, toValuePsiFile: $toValuePsiFile") - - val translatedValues = if (toValuePsiFile != null) { - val toValues = valueService.loadValues(toValuePsiFile) - val toValuesMap = toValues.stream().collect(Collectors.toMap( - { psiElement -> - if (psiElement is XmlTag) { - ApplicationManager.getApplication().runReadAction(Computable { - psiElement.getAttributeValue("name") ?: UUID.randomUUID().toString() - }) - } else { - UUID.randomUUID().toString() - } - }, - { it } - )) - val translated = doTranslate(progressIndicator, toLanguage, toValuesMap, isOverwriteExistingString) - writeTranslatedValues(progressIndicator, File(toValuePsiFile.virtualFile.path), translated) - translated + companion object { + private const val NAME_TAG_STRING = "string" + private const val NAME_TAG_PLURALS = "plurals" + private const val NAME_TAG_STRING_ARRAY = "string-array" + private val LOG = Logger.getInstance(TranslateTask::class.java) + } + + interface OnTranslateListener { + fun onTranslateSuccess() + fun onTranslateError(e: Throwable) + } + + private val valueFile: VirtualFile = valueFile.virtualFile + private val translatorService = TranslatorService.getInstance() + private val valueService = AndroidValuesService.getInstance() + private var onTranslateListener: OnTranslateListener? = null + + @Volatile + private var translationError: TranslationException? = null + + /** + * Set translate result listener. + * + * @param listener callback interface. success or fail. + */ + fun setOnTranslateListener(listener: OnTranslateListener) { + onTranslateListener = listener + } + + override fun run(progressIndicator: ProgressIndicator) { + val isOverwriteExistingString = PropertiesComponent.getInstance(project) + .getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING) + LOG.info("run isOverwriteExistingString: $isOverwriteExistingString") + + for (toLanguage in toLanguages) { + if (progressIndicator.isCanceled) break + + progressIndicator.text = "Translation to ${toLanguage.englishName}..." + + val resourceDir = valueFile.parent.parent + val valueFileName = valueFile.name + val toValuePsiFile = valueService.getValuePsiFile(project, resourceDir, toLanguage, valueFileName) + LOG.info("Translating language: ${toLanguage.englishName}, toValuePsiFile: $toValuePsiFile") + + if (toValuePsiFile != null) { + val toValues = valueService.loadValues(toValuePsiFile) + val toValuesMap = toValues.stream().collect( + Collectors.toMap( + { psiElement -> + if (psiElement is XmlTag) { + ApplicationManager.getApplication().runReadAction(Computable { + psiElement.getAttributeValue("name") ?: UUID.randomUUID().toString() + }) } else { - val translated = doTranslate(progressIndicator, toLanguage, null, isOverwriteExistingString) - val valueFile = valueService.getValueFile(resourceDir, toLanguage, valueFileName) - writeTranslatedValues(progressIndicator, valueFile, translated) - translated + UUID.randomUUID().toString() } + }, + { it } + )) + val translatedValues = doTranslate(progressIndicator, toLanguage, toValuesMap, isOverwriteExistingString) + translationError?.let { return } + writeTranslatedValues(progressIndicator, File(toValuePsiFile.virtualFile.path), translatedValues) + } else { + val translatedValues = doTranslate(progressIndicator, toLanguage, null, isOverwriteExistingString) + translationError?.let { return } + val valueFile = valueService.getValueFile(resourceDir, toLanguage, valueFileName) + writeTranslatedValues(progressIndicator, valueFile, translatedValues) + } + + // If an exception occurs during the translation of the language, + // terminate the task to avoid reporting an IDE error and leave the listener to handle the failure. + translationError?.let { return } + } + } + + private fun doTranslate( + progressIndicator: ProgressIndicator, + toLanguage: Lang, + toValues: Map?, + isOverwrite: Boolean + ): List { + LOG.info("doTranslate toLanguage: ${toLanguage.englishName}, toValues: $toValues, isOverwrite: $isOverwrite") + + val translatedValues = ArrayList() + for (value in values) { + if (translationError != null) break + if (progressIndicator.isCanceled) break + + if (value is XmlTag) { + if (!valueService.isTranslatable(value)) { + translatedValues.add(value) + continue + } + + val name = ApplicationManager.getApplication().runReadAction(Computable { + value.getAttributeValue("name") + }) - // If an exception occurs during the translation of the language, - // the translation of the subsequent languages is terminated. - // This prevents the loss of successfully translated strings in that language. - translationError?.let { throw it } + if (!isOverwrite && toValues != null && toValues.containsKey(name)) { + toValues[name]?.let { translatedValues.add(it) } + continue } - } - private fun doTranslate( - progressIndicator: ProgressIndicator, - toLanguage: Lang, - toValues: Map?, - isOverwrite: Boolean - ): List { - LOG.info("doTranslate toLanguage: ${toLanguage.englishName}, toValues: $toValues, isOverwrite: $isOverwrite") - - val translatedValues = ArrayList() - for (value in values) { - if (progressIndicator.isCanceled) break - - if (value is XmlTag) { - if (!valueService.isTranslatable(value)) { - translatedValues.add(value) - continue - } - - val name = ApplicationManager.getApplication().runReadAction(Computable { - value.getAttributeValue("name") - }) - - if (!isOverwrite && toValues != null && toValues.containsKey(name)) { - toValues[name]?.let { translatedValues.add(it) } - continue - } - - val translateValue = ApplicationManager.getApplication().runReadAction(Computable { - value.copy() as XmlTag - }) - - translatedValues.add(translateValue) - when (translateValue.name) { - NAME_TAG_STRING -> { - doTranslate(progressIndicator, toLanguage, translateValue) - } - NAME_TAG_STRING_ARRAY, NAME_TAG_PLURALS -> { - val subTags = ApplicationManager.getApplication() - .runReadAction(Computable { translateValue.subTags }) - for (subTag in subTags) { - doTranslate(progressIndicator, toLanguage, subTag) - } - } - } - } else { - translatedValues.add(value) + val translateValue = ApplicationManager.getApplication().runReadAction(Computable { + value.copy() as XmlTag + }) + + translatedValues.add(translateValue) + when (translateValue.name) { + NAME_TAG_STRING -> { + doTranslate(progressIndicator, toLanguage, translateValue) + } + + NAME_TAG_STRING_ARRAY, NAME_TAG_PLURALS -> { + val subTags = ApplicationManager.getApplication() + .runReadAction(Computable { translateValue.subTags }) + for (subTag in subTags) { + if (translationError != null) break + doTranslate(progressIndicator, toLanguage, subTag) } + } } - return translatedValues + } else { + translatedValues.add(value) + } } - - private fun doTranslate( - progressIndicator: ProgressIndicator, - toLanguage: Lang, - xmlTag: XmlTag - ) { - if (progressIndicator.isCanceled || isXliffTag(xmlTag)) return - - val xmlTagValue = ApplicationManager.getApplication() - .runReadAction(Computable { xmlTag.value }) - val children = xmlTagValue.children - - for (child in children) { - when (child) { - is XmlText -> { - val text = ApplicationManager.getApplication() - .runReadAction(Computable { child.value }) - if (TextUtil.isEmptyOrSpacesLineBreak(text)) { - continue - } - try { - val translatedText = translatorService.doTranslate(Languages.AUTO, toLanguage, text) - ApplicationManager.getApplication().runReadAction { - child.setValue(translatedText) - } - } catch (e: TranslationException) { - LOG.warn(e) - // Just catch the error and wait for that file to be translated and released. - translationError = e - } - } - is XmlTag -> { - doTranslate(progressIndicator, toLanguage, child) - } + return translatedValues + } + + private fun doTranslate( + progressIndicator: ProgressIndicator, + toLanguage: Lang, + xmlTag: XmlTag + ) { + if (progressIndicator.isCanceled || isXliffTag(xmlTag) || translationError != null) return + + val xmlTagValue = ApplicationManager.getApplication() + .runReadAction(Computable { xmlTag.value }) + val children = xmlTagValue.children + + for (child in children) { + if (translationError != null) break + when (child) { + is XmlText -> { + val text = ApplicationManager.getApplication() + .runReadAction(Computable { child.value }) + if (TextUtil.isEmptyOrSpacesLineBreak(text)) { + continue + } + try { + val translatedText = translatorService.doTranslate(Languages.AUTO, toLanguage, text) + ApplicationManager.getApplication().runReadAction { + child.setValue(translatedText) } + } catch (e: TranslationException) { + LOG.warn(e) + // Just catch the error and wait for that file to be translated and released. + translationError = e + return + } } - } - - private fun writeTranslatedValues( - progressIndicator: ProgressIndicator, - valueFile: File, - translatedValues: List - ) { - LOG.info("writeTranslatedValues valueFile: $valueFile, translatedValues: $translatedValues") - - if (progressIndicator.isCanceled || translatedValues.isEmpty()) return - - progressIndicator.text = "Writing to ${valueFile.parentFile.name} data..." - valueService.writeValueFile(translatedValues, valueFile) - - refreshAndOpenFile(valueFile) - } - private fun refreshAndOpenFile(file: File) { - val virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) - val isOpenTranslatedFile = PropertiesComponent.getInstance(project) - .getBoolean(Constants.KEY_IS_OPEN_TRANSLATED_FILE) - if (virtualFile != null && isOpenTranslatedFile) { - ApplicationManager.getApplication().invokeLater { - FileEditorManager.getInstance(project).openFile(virtualFile, true) - } + is XmlTag -> { + doTranslate(progressIndicator, toLanguage, child) } + } } - - private fun isXliffTag(xmlTag: XmlTag?): Boolean { - return xmlTag != null && "xliff:g" == xmlTag.name + } + + private fun writeTranslatedValues( + progressIndicator: ProgressIndicator, + valueFile: File, + translatedValues: List + ) { + LOG.info("writeTranslatedValues valueFile: $valueFile, translatedValues: $translatedValues") + + if (progressIndicator.isCanceled || translatedValues.isEmpty()) return + + progressIndicator.text = "Writing to ${valueFile.parentFile.name} data..." + valueService.writeValueFile(translatedValues, valueFile) + + refreshAndOpenFile(valueFile) + } + + private fun refreshAndOpenFile(file: File) { + val virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) + val isOpenTranslatedFile = PropertiesComponent.getInstance(project) + .getBoolean(Constants.KEY_IS_OPEN_TRANSLATED_FILE) + if (virtualFile != null && isOpenTranslatedFile) { + ApplicationManager.getApplication().invokeLater { + FileEditorManager.getInstance(project).openFile(virtualFile, true) + } } - - override fun onSuccess() { - super.onSuccess() - translateSuccess() + } + + private fun isXliffTag(xmlTag: XmlTag?): Boolean { + return xmlTag != null && "xliff:g" == xmlTag.name + } + + override fun onSuccess() { + super.onSuccess() + val error = translationError + if (error != null) { + translateError(error) + } else { + translateSuccess() } + } - override fun onThrowable(error: Throwable) { - super.onThrowable(error) - translateError(error) - } + override fun onThrowable(error: Throwable) { + super.onThrowable(error) + translateError(error) + } - private fun translateSuccess() { - onTranslateListener?.onTranslateSuccess() - } + private fun translateSuccess() { + onTranslateListener?.onTranslateSuccess() + } - private fun translateError(error: Throwable) { - onTranslateListener?.onTranslateError(error) - } -} \ No newline at end of file + private fun translateError(error: Throwable) { + onTranslateListener?.onTranslateError(error) + } +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt index 7cde15b..dfac0b2 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt @@ -68,8 +68,7 @@ abstract class AbstractTranslator : Translator, TranslatorConfigurable { parsingResult(fromLang, toLang, text, resultText) } } catch (e: Exception) { - e.printStackTrace() - LOG.error(e.message, e) + LOG.warn("Translation request failed: ${e.message}", e) throw TranslationException(fromLang, toLang, text, e) } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/TranslationException.kt b/src/main/kotlin/com/airsaid/localization/translate/TranslationException.kt index ecc142d..aa4c064 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/TranslationException.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/TranslationException.kt @@ -40,8 +40,7 @@ class TranslationException : RuntimeException { this.fromLang = fromLang this.toLang = toLang this.text = text - cause.printStackTrace() - LOG.error("TranslationException: ${cause.message}", cause) + LOG.warn("TranslationException: ${cause.message}", cause) } constructor(fromLang: Lang, toLang: Lang, text: String, message: String) : super( @@ -50,6 +49,6 @@ class TranslationException : RuntimeException { this.fromLang = fromLang this.toLang = toLang this.text = text - LOG.error("TranslationException: $message") + LOG.warn("TranslationException: $message") } -} \ No newline at end of file +} From b5c502e650d7bcb58c88b13c4565e8dfaebbb2f9 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 01:19:41 +0800 Subject: [PATCH 32/58] Refactor languages --- .../services/AndroidValuesService.kt | 326 ++++--- .../localization/task/TranslateTask.kt | 25 +- .../translate/AbstractTranslator.kt | 5 +- .../translate/impl/ali/AliTranslator.kt | 175 ++-- .../impl/baidu/BaiduTranslationResult.kt | 50 +- .../translate/impl/baidu/BaiduTranslator.kt | 170 ++-- .../impl/deepl/DeepLTranslationResult.kt | 36 +- .../translate/impl/deepl/DeepLTranslator.kt | 189 ++-- .../impl/google/AbsGoogleTranslator.kt | 32 +- .../translate/impl/google/GoogleHttp.kt | 2 +- .../translate/impl/google/GoogleToken.kt | 2 + .../translate/impl/google/GoogleTranslator.kt | 7 +- .../microsoft/MicrosoftEdgeAuthService.kt | 6 +- .../microsoft/MicrosoftTranslationResult.kt | 36 +- .../impl/microsoft/MicrosoftTranslator.kt | 283 +++--- .../translate/impl/openai/OpenAITranslator.kt | 2 - .../impl/openai/OpenAITranslatorSettings.kt | 7 +- .../impl/youdao/YoudaoTranslationResult.kt | 67 +- .../translate/impl/youdao/YoudaoTranslator.kt | 354 ++++---- .../EscapeCharactersInterceptor.kt | 32 +- .../localization/translate/lang/Lang.kt | 74 +- .../translate/lang/LanguageFlags.kt | 155 ---- .../localization/translate/lang/Languages.kt | 304 ++++--- .../services/TranslationCacheService.kt | 84 +- .../translate/services/TranslatorService.kt | 211 ++--- .../localization/ui/SelectLanguagesDialog.kt | 836 +++++++++--------- .../AbstractTranslatorNetworkTest.kt | 11 +- 27 files changed, 1637 insertions(+), 1844 deletions(-) delete mode 100644 src/main/kotlin/com/airsaid/localization/translate/lang/LanguageFlags.kt diff --git a/src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt b/src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt index 57c3a4f..d05bb22 100644 --- a/src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt +++ b/src/main/kotlin/com/airsaid/localization/services/AndroidValuesService.kt @@ -27,7 +27,6 @@ import com.intellij.openapi.util.Computable import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.openapi.vfs.VirtualFile -import com.intellij.psi.PsiDirectory import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.psi.PsiManager @@ -46,183 +45,180 @@ import java.util.regex.Pattern @Service class AndroidValuesService { - var isSkipNonTranslatable: Boolean = false - companion object { - private val LOG = Logger.getInstance(AndroidValuesService::class.java) - private val STRINGS_FILE_NAME_PATTERN = Pattern.compile(".+\\.xml") - - /** - * Returns the [AndroidValuesService] object instance. - * - * @return the [AndroidValuesService] object instance. - */ - fun getInstance(): AndroidValuesService { - return ServiceManager.getService(AndroidValuesService::class.java) - } - } + var isSkipNonTranslatable: Boolean = false - /** - * Asynchronous loading the value file as the [PsiElement] collection. - * - * @param valueFile the value file. - * @param consumer load result. called in the event dispatch thread. - */ - fun loadValuesByAsync(valueFile: PsiFile, consumer: Consumer>) { - ApplicationManager.getApplication().executeOnPooledThread { - val values = loadValues(valueFile) - ApplicationManager.getApplication().invokeLater { - consumer.consume(values) - } - } - } + companion object { + private val LOG = Logger.getInstance(AndroidValuesService::class.java) + private val STRINGS_FILE_NAME_PATTERN = Pattern.compile(".+\\.xml") /** - * Loading the value file as the [PsiElement] collection. + * Returns the [AndroidValuesService] object instance. * - * @param valueFile the value file. - * @return [PsiElement] collection. + * @return the [AndroidValuesService] object instance. */ - fun loadValues(valueFile: PsiFile): List { - return ApplicationManager.getApplication().runReadAction(Computable { - LOG.info("loadValues valueFile: ${valueFile.name}") - val values = parseValuesXml(valueFile) - LOG.info("loadValues parsed ${valueFile.name} result: $values") - values - }) + fun getInstance(): AndroidValuesService { + return ServiceManager.getService(AndroidValuesService::class.java) } - - private fun parseValuesXml(valueFile: PsiFile): List { - val xmlFile = valueFile as XmlFile - - val document = xmlFile.document ?: return emptyList() - val rootTag = document.rootTag ?: return emptyList() - - val subTags = rootTag.children - - if (!isSkipNonTranslatable) { - return subTags.toList() - } - - val values = mutableListOf() - var skipNext = false - - for (element in subTags) { - if (skipNext) { - skipNext = false - if (element !is XmlTag) { - continue - } - } - if (element is XmlTag && !isTranslatable(element)) { - skipNext = true - } else { - values.add(element) - } - } - - return values + } + + /** + * Asynchronous loading the value file as the [PsiElement] collection. + * + * @param valueFile the value file. + * @param consumer load result. called in the event dispatch thread. + */ + fun loadValuesByAsync(valueFile: PsiFile, consumer: Consumer>) { + ApplicationManager.getApplication().executeOnPooledThread { + val values = loadValues(valueFile) + ApplicationManager.getApplication().invokeLater { + consumer.consume(values) + } } - - /** - * Write [PsiElement] collection data to the specified file. - * - * @param values specified [PsiElement] collection data. - * @param valueFile specified file. - */ - fun writeValueFile(values: List, valueFile: File) { - val isCreateSuccess = FileUtil.createIfDoesntExist(valueFile) - if (!isCreateSuccess) { - LOG.error("Failed to write to ${valueFile.path} file: create failed!") - return - } - - ApplicationManager.getApplication().invokeLater { - ApplicationManager.getApplication().runWriteAction { - try { - BufferedWriter(OutputStreamWriter(FileOutputStream(valueFile, false), StandardCharsets.UTF_8)).use { bw -> - for (value in values) { - bw.write(value.text) - } - bw.flush() - } - } catch (e: IOException) { - e.printStackTrace() - LOG.error("Failed to write to ${valueFile.path} file.", e) - } - } - } + } + + /** + * Loading the value file as the [PsiElement] collection. + * + * @param valueFile the value file. + * @return [PsiElement] collection. + */ + fun loadValues(valueFile: PsiFile): List { + return ApplicationManager.getApplication().runReadAction(Computable { + LOG.info("loadValues valueFile: ${valueFile.name}") + val values = parseValuesXml(valueFile) + LOG.info("loadValues parsed ${valueFile.name} result: $values") + values + }) + } + + private fun parseValuesXml(valueFile: PsiFile): List { + val xmlFile = valueFile as XmlFile + + val document = xmlFile.document ?: return emptyList() + val rootTag = document.rootTag ?: return emptyList() + + val subTags = rootTag.children + + if (!isSkipNonTranslatable) { + return subTags.toList() } - /** - * Verify that the specified file is a string resource file in the values directory. - * - * @param file the verify file. - * @return true: the file is a string resource file in the values directory. - */ - fun isValueFile(file: PsiFile?): Boolean { - if (file == null) return false - - val parent = file.parent ?: return false - val parentName = parent.name - if ("values" != parentName) return false - - val fileName = file.name - return STRINGS_FILE_NAME_PATTERN.matcher(fileName).matches() - } + val values = mutableListOf() + var skipNext = false - /** - * Get the value file of the specified language in the specified project resource directory. - * - * @param project current project. - * @param resourceDir specified resource directory. - * @param lang specified language. - * @param fileName the name of value file. - * @return null if not exist, otherwise return the value file. - */ - fun getValuePsiFile( - project: Project, - resourceDir: VirtualFile, - lang: Lang, - fileName: String - ): PsiFile? { - return ApplicationManager.getApplication().runReadAction(Computable { - val virtualFile = LocalFileSystem.getInstance().findFileByIoFile(getValueFile(resourceDir, lang, fileName)) - ?: return@Computable null - PsiManager.getInstance(project).findFile(virtualFile) - }) + for (element in subTags) { + if (skipNext) { + skipNext = false + if (element !is XmlTag) { + continue + } + } + if (element is XmlTag && !isTranslatable(element)) { + skipNext = true + } else { + values.add(element) + } } - /** - * Get the value file in the `values` directory of the specified language in the resource directory. - * - * @param resourceDir specified resource directory. - * @param lang specified language. - * @param fileName the name of value file. - * @return the value file. - */ - fun getValueFile(resourceDir: VirtualFile, lang: Lang, fileName: String): File { - return File(resourceDir.path + File.separator + getValuesDirectoryName(lang), fileName) + return values + } + + /** + * Write [PsiElement] collection data to the specified file. + * + * @param values specified [PsiElement] collection data. + * @param valueFile specified file. + */ + fun writeValueFile(values: List, valueFile: File) { + val isCreateSuccess = FileUtil.createIfDoesntExist(valueFile) + if (!isCreateSuccess) { + LOG.error("Failed to write to ${valueFile.path} file: create failed!") + return } - private fun getValuesDirectoryName(lang: Lang): String { - val parts = lang.code.split("-") - return if (parts.size > 1) { - "values-${parts[0]}-r${parts[1].uppercase()}" - } else { - "values-${lang.code}" + ApplicationManager.getApplication().invokeLater { + ApplicationManager.getApplication().runWriteAction { + try { + BufferedWriter(OutputStreamWriter(FileOutputStream(valueFile, false), StandardCharsets.UTF_8)).use { bw -> + for (value in values) { + bw.write(value.text) + } + bw.flush() + } + } catch (e: IOException) { + e.printStackTrace() + LOG.error("Failed to write to ${valueFile.path} file.", e) } + } } - - /** - * Returns whether the specified xml tag (string entry) needs to be translated. - * - * @param xmlTag the specified xml tag of string entry. - * @return true: need translation. false: no translation is needed. - */ - fun isTranslatable(xmlTag: XmlTag): Boolean { - return ApplicationManager.getApplication().runReadAction(Computable { - val translatableStr = xmlTag.getAttributeValue("translatable") - (translatableStr ?: "true").toBoolean() - }) - } -} \ No newline at end of file + } + + /** + * Verify that the specified file is a string resource file in the values directory. + * + * @param file the verify file. + * @return true: the file is a string resource file in the values directory. + */ + fun isValueFile(file: PsiFile?): Boolean { + if (file == null) return false + + val parent = file.parent ?: return false + val parentName = parent.name + if ("values" != parentName) return false + + val fileName = file.name + return STRINGS_FILE_NAME_PATTERN.matcher(fileName).matches() + } + + /** + * Get the value file of the specified language in the specified project resource directory. + * + * @param project current project. + * @param resourceDir specified resource directory. + * @param lang specified language. + * @param fileName the name of value file. + * @return null if not exist, otherwise return the value file. + */ + fun getValuePsiFile( + project: Project, + resourceDir: VirtualFile, + lang: Lang, + fileName: String + ): PsiFile? { + return ApplicationManager.getApplication().runReadAction(Computable { + val virtualFile = LocalFileSystem.getInstance().findFileByIoFile(getValueFile(resourceDir, lang, fileName)) + ?: return@Computable null + PsiManager.getInstance(project).findFile(virtualFile) + }) + } + + /** + * Get the value file in the `values` directory of the specified language in the resource directory. + * + * @param resourceDir specified resource directory. + * @param lang specified language. + * @param fileName the name of value file. + * @return the value file. + */ + fun getValueFile(resourceDir: VirtualFile, lang: Lang, fileName: String): File { + return File(resourceDir.path + File.separator + getValuesDirectoryName(lang), fileName) + } + + private fun getValuesDirectoryName(lang: Lang): String { + val directoryName = lang.directoryName + return if (directoryName.isBlank()) "values" else "values-$directoryName" + } + + /** + * Returns whether the specified xml tag (string entry) needs to be translated. + * + * @param xmlTag the specified xml tag of string entry. + * @return true: need translation. false: no translation is needed. + */ + fun isTranslatable(xmlTag: XmlTag): Boolean { + return ApplicationManager.getApplication().runReadAction(Computable { + val translatableStr = xmlTag.getAttributeValue("translatable") + (translatableStr ?: "true").toBoolean() + }) + } +} diff --git a/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt index abbcf07..33b7c72 100644 --- a/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt +++ b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt @@ -22,6 +22,7 @@ import com.airsaid.localization.services.AndroidValuesService import com.airsaid.localization.translate.TranslationException import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.lang.toLang import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.utils.TextUtil import com.intellij.ide.util.PropertiesComponent @@ -101,17 +102,17 @@ class TranslateTask( val toValues = valueService.loadValues(toValuePsiFile) val toValuesMap = toValues.stream().collect( Collectors.toMap( - { psiElement -> - if (psiElement is XmlTag) { - ApplicationManager.getApplication().runReadAction(Computable { - psiElement.getAttributeValue("name") ?: UUID.randomUUID().toString() - }) - } else { - UUID.randomUUID().toString() - } - }, - { it } - )) + { psiElement -> + if (psiElement is XmlTag) { + ApplicationManager.getApplication().runReadAction(Computable { + psiElement.getAttributeValue("name") ?: UUID.randomUUID().toString() + }) + } else { + UUID.randomUUID().toString() + } + }, + { it } + )) val translatedValues = doTranslate(progressIndicator, toLanguage, toValuesMap, isOverwriteExistingString) translationError?.let { return } writeTranslatedValues(progressIndicator, File(toValuePsiFile.virtualFile.path), translatedValues) @@ -203,7 +204,7 @@ class TranslateTask( continue } try { - val translatedText = translatorService.doTranslate(Languages.AUTO, toLanguage, text) + val translatedText = translatorService.doTranslate(Languages.AUTO.toLang(), toLanguage, text) ApplicationManager.getApplication().runReadAction { child.setValue(translatedText) } diff --git a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt index dfac0b2..d93a5de 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt @@ -35,10 +35,11 @@ abstract class AbstractTranslator : Translator, TranslatorConfigurable { abstract override val key: String - abstract override val name: String + override val name: String + get() = key override val supportedLanguages: List - get() = Languages.getAllSupportedLanguages() + get() = Languages.allSupportedLanguages() companion object { protected val LOG = Logger.getInstance(AbstractTranslator::class.java) diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt index 6cb17ae..a44a4c2 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt @@ -22,6 +22,7 @@ import com.airsaid.localization.translate.TranslationException import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.lang.toLang import com.aliyun.alimt20181012.Client import com.aliyun.alimt20181012.models.TranslateGeneralRequest import com.aliyun.alimt20181012.models.TranslateGeneralResponse @@ -29,7 +30,6 @@ import com.aliyun.teaopenapi.models.Config import com.aliyun.teautil.models.RuntimeOptions import com.google.auto.service.AutoService import icons.PluginIcons -import javax.swing.Icon /** * @author airsaid @@ -37,102 +37,95 @@ import javax.swing.Icon @AutoService(AbstractTranslator::class) class AliTranslator : AbstractTranslator() { - companion object { - private const val KEY = "Ali" - private const val ENDPOINT = "mt.aliyuncs.com" - private const val APPLY_APP_ID_URL = "https://www.aliyun.com/product/ai/base_alimt" - } - - private var _supportedLanguages: MutableList? = null - - override val key: String = KEY - - override val name: String = "Ali" - - override val icon: Icon? = PluginIcons.ALI_ICON - - override val credentialDefinitions = listOf( - TranslatorCredentialDescriptor(id = "appId", label = "AccessKey ID", isSecret = false), - TranslatorCredentialDescriptor(id = "appKey", label = "AccessKey Secret", isSecret = true) - ) - - override val credentialHelpUrl: String? = APPLY_APP_ID_URL - - override val supportedLanguages: List - get() { - if (_supportedLanguages == null) { - _supportedLanguages = mutableListOf().apply { - val languages = Languages.getLanguages() - for (i in 1 until languages.size) { - var lang = languages[i] - if (lang == Languages.UKRAINIAN || lang == Languages.DARI) { - continue - } - - lang = when (lang) { - Languages.CHINESE_SIMPLIFIED -> lang.setTranslationCode("zh") - Languages.CHINESE_TRADITIONAL -> lang.setTranslationCode("zh-tw") - Languages.INDONESIAN -> lang.setTranslationCode("id") - Languages.CROATIAN -> lang.setTranslationCode("hbs") - Languages.HEBREW -> lang.setTranslationCode("he") - else -> lang - } - add(lang) - } - } + companion object { + private const val KEY = "Ali" + private const val ENDPOINT = "mt.aliyuncs.com" + private const val APPLY_APP_ID_URL = "https://www.aliyun.com/product/ai/base_alimt" + } + + override val key = KEY + + override val icon = PluginIcons.ALI_ICON + + override val credentialDefinitions = listOf( + TranslatorCredentialDescriptor(id = "appId", label = "AccessKey ID", isSecret = false), + TranslatorCredentialDescriptor(id = "appKey", label = "AccessKey Secret", isSecret = true) + ) + + override val credentialHelpUrl: String? = APPLY_APP_ID_URL + + override val supportedLanguages: List by lazy { + Languages.entries + .asSequence() + .filter { language -> + language != Languages.AUTO && + language != Languages.UKRAINIAN && + language != Languages.DARI + } + .map { language -> + val lang = language.toLang() + when (language) { + Languages.CHINESE_SIMPLIFIED -> lang.setTranslationCode("zh") + Languages.CHINESE_TRADITIONAL -> lang.setTranslationCode("zh-tw") + Languages.INDONESIAN -> lang.setTranslationCode("id") + Languages.CROATIAN -> lang.setTranslationCode("hbs") + Languages.HEBREW -> lang.setTranslationCode("he") + else -> lang } - return _supportedLanguages!! + } + .toList() + } + + @Throws(TranslationException::class) + override fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { + checkSupportedLanguages(fromLang, toLang, text) + + val credentials = resolveCredentials(fromLang, toLang, text) + + val config = Config() + .setAccessKeyId(credentials.first) + .setAccessKeySecret(credentials.second) + .setEndpoint(ENDPOINT) + val client = try { + Client(config) + } catch (e: Exception) { + throw TranslationException(fromLang, toLang, text, e) } - @Throws(TranslationException::class) - override fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { - checkSupportedLanguages(fromLang, toLang, text) - - val credentials = resolveCredentials(fromLang, toLang, text) - - val config = Config() - .setAccessKeyId(credentials.first) - .setAccessKeySecret(credentials.second) - .setEndpoint(ENDPOINT) - val client = try { - Client(config) - } catch (e: Exception) { - throw TranslationException(fromLang, toLang, text, e) - } - - val request = TranslateGeneralRequest() - .setFormatType("text") - .setSourceLanguage(fromLang.translationCode) - .setTargetLanguage(toLang.translationCode) - .setSourceText(text) - .setScene("general") + val request = TranslateGeneralRequest() + .setFormatType("text") + .setSourceLanguage(fromLang.translationCode) + .setTargetLanguage(toLang.translationCode) + .setSourceText(text) + .setScene("general") - val runtime = RuntimeOptions() - val response: TranslateGeneralResponse + val runtime = RuntimeOptions() + val response: TranslateGeneralResponse - try { - response = client.translateGeneralWithOptions(request, runtime) - } catch (e: Exception) { - throw TranslationException(fromLang, toLang, text, e) - } + try { + response = client.translateGeneralWithOptions(request, runtime) + } catch (e: Exception) { + throw TranslationException(fromLang, toLang, text, e) + } - val body = response.body - return if (body.code == 200) { - body.data.translated - } else { - throw TranslationException(fromLang, toLang, text, "${body.message}(${body.code})") - } + val body = response.body + return if (body.code == 200) { + body.data.translated + } else { + throw TranslationException(fromLang, toLang, text, "${body.message}(${body.code})") } - private fun resolveCredentials( - fromLang: Lang, - toLang: Lang, - text: String - ): Pair { - val accessKeyId = credentialValue("appId").takeIf { it.isNotBlank() } - val accessKeySecret = credentialValue("appKey").takeIf { it.isNotBlank() } - if (accessKeyId == null || accessKeySecret == null) { - throw TranslationException(fromLang, toLang, text, "AccessKey credentials are not configured") - } - return Pair(accessKeyId, accessKeySecret) + } + + private fun resolveCredentials( + fromLang: Lang, + toLang: Lang, + text: String + ): Pair { + val accessKeyId = credentialValue("appId").takeIf { it.isNotBlank() } + val accessKeySecret = credentialValue("appKey").takeIf { it.isNotBlank() } + if (accessKeyId == null || accessKeySecret == null) { + throw TranslationException(fromLang, toLang, text, "AccessKey credentials are not configured") } + return Pair(accessKeyId, accessKeySecret) + } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.kt index 02351b3..1cd8fb6 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.kt @@ -25,33 +25,33 @@ import com.intellij.openapi.util.text.StringUtil * @author airsaid */ data class BaiduTranslationResult( - var from: String? = null, - var to: String? = null, - @SerializedName("trans_result") - var contents: List? = null, - @SerializedName("error_code") - var errorCode: String? = null, - @SerializedName("error_msg") - var errorMsg: String? = null + var from: String? = null, + var to: String? = null, + @SerializedName("trans_result") + var contents: List? = null, + @SerializedName("error_code") + var errorCode: String? = null, + @SerializedName("error_msg") + var errorMsg: String? = null ) : TranslationResult { - fun isSuccess(): Boolean { - val errorCode = this.errorCode - return StringUtil.isEmpty(errorCode) || "52000" == errorCode - } + fun isSuccess(): Boolean { + val errorCode = this.errorCode + return StringUtil.isEmpty(errorCode) || "52000" == errorCode + } - override val translationResult: String - get() { - val contents = this.contents - if (contents.isNullOrEmpty()) { - return "" - } - val dst = contents[0].dst - return dst ?: "" - } + override val translationResult: String + get() { + val contents = this.contents + if (contents.isNullOrEmpty()) { + return "" + } + val dst = contents[0].dst + return dst ?: "" + } - data class Content( - var src: String? = null, - var dst: String? = null - ) + data class Content( + var src: String? = null, + var dst: String? = null + ) } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt index 644b614..7e0ac6d 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt @@ -22,6 +22,7 @@ import com.airsaid.localization.translate.TranslationException import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.lang.toLang import com.airsaid.localization.translate.util.GsonUtil import com.airsaid.localization.translate.util.MD5 import com.google.auto.service.AutoService @@ -29,7 +30,6 @@ import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.util.Pair import com.intellij.util.io.RequestBuilder import icons.PluginIcons -import javax.swing.Icon /** * @author airsaid @@ -37,96 +37,88 @@ import javax.swing.Icon @AutoService(AbstractTranslator::class) class BaiduTranslator : AbstractTranslator() { - companion object { - private val LOG = Logger.getInstance(BaiduTranslator::class.java) - private const val KEY = "Baidu" - private const val HOST_URL = "http://api.fanyi.baidu.com" - private const val TRANSLATE_URL = "$HOST_URL/api/trans/vip/translate" - private const val APPLY_APP_ID_URL = "http://api.fanyi.baidu.com/api/trans/product/desktop?req=developer" - } - - private var _supportedLanguages: MutableList? = null - - override val key: String = KEY - - override val name: String = "Baidu" - - override val icon: Icon? = PluginIcons.BAIDU_ICON - - override val supportedLanguages: List - get() { - if (_supportedLanguages == null) { - _supportedLanguages = mutableListOf().apply { - add(Languages.CHINESE_SIMPLIFIED.setTranslationCode("zh")) - add(Languages.ENGLISH) - add(Languages.JAPANESE.setTranslationCode("jp")) - add(Languages.KOREAN.setTranslationCode("kor")) - add(Languages.FRENCH.setTranslationCode("fra")) - add(Languages.SPANISH.setTranslationCode("spa")) - add(Languages.THAI) - add(Languages.ARABIC.setTranslationCode("ara")) - add(Languages.RUSSIAN) - add(Languages.PORTUGUESE) - add(Languages.GERMAN) - add(Languages.ITALIAN) - add(Languages.GREEK) - add(Languages.DUTCH) - add(Languages.POLISH) - add(Languages.BULGARIAN.setTranslationCode("bul")) - add(Languages.ESTONIAN.setTranslationCode("est")) - add(Languages.DANISH.setTranslationCode("dan")) - add(Languages.FINNISH.setTranslationCode("fin")) - add(Languages.CZECH) - add(Languages.ROMANIAN.setTranslationCode("rom")) - add(Languages.SLOVENIAN.setTranslationCode("slo")) - add(Languages.SWEDISH.setTranslationCode("swe")) - add(Languages.HUNGARIAN) - add(Languages.CHINESE_TRADITIONAL.setTranslationCode("cht")) - add(Languages.VIETNAMESE.setTranslationCode("vie")) - } - } - return _supportedLanguages!! - } - - override val credentialDefinitions = listOf( - TranslatorCredentialDescriptor(id = "appId", label = "APP ID", isSecret = false), - TranslatorCredentialDescriptor(id = "appKey", label = "APP KEY", isSecret = true) + companion object { + private val LOG = Logger.getInstance(BaiduTranslator::class.java) + private const val KEY = "Baidu" + private const val HOST_URL = "http://api.fanyi.baidu.com" + private const val TRANSLATE_URL = "$HOST_URL/api/trans/vip/translate" + private const val APPLY_APP_ID_URL = "http://api.fanyi.baidu.com/api/trans/product/desktop?req=developer" + } + + override val key = KEY + + override val icon = PluginIcons.BAIDU_ICON + + override val supportedLanguages: List by lazy { + listOf( + Languages.CHINESE_SIMPLIFIED.toLang().setTranslationCode("zh"), + Languages.ENGLISH.toLang(), + Languages.JAPANESE.toLang().setTranslationCode("jp"), + Languages.KOREAN.toLang().setTranslationCode("kor"), + Languages.FRENCH.toLang().setTranslationCode("fra"), + Languages.SPANISH.toLang().setTranslationCode("spa"), + Languages.THAI.toLang(), + Languages.ARABIC.toLang().setTranslationCode("ara"), + Languages.RUSSIAN.toLang(), + Languages.PORTUGUESE.toLang(), + Languages.GERMAN.toLang(), + Languages.ITALIAN.toLang(), + Languages.GREEK.toLang(), + Languages.DUTCH.toLang(), + Languages.POLISH.toLang(), + Languages.BULGARIAN.toLang().setTranslationCode("bul"), + Languages.ESTONIAN.toLang().setTranslationCode("est"), + Languages.DANISH.toLang().setTranslationCode("dan"), + Languages.FINNISH.toLang().setTranslationCode("fin"), + Languages.CZECH.toLang(), + Languages.ROMANIAN.toLang().setTranslationCode("rom"), + Languages.SLOVENIAN.toLang().setTranslationCode("slo"), + Languages.SWEDISH.toLang().setTranslationCode("swe"), + Languages.HUNGARIAN.toLang(), + Languages.CHINESE_TRADITIONAL.toLang().setTranslationCode("cht"), + Languages.VIETNAMESE.toLang().setTranslationCode("vie"), ) + } + + override val credentialDefinitions = listOf( + TranslatorCredentialDescriptor(id = "appId", label = "APP ID", isSecret = false), + TranslatorCredentialDescriptor(id = "appKey", label = "APP KEY", isSecret = true) + ) + + override val credentialHelpUrl: String? = APPLY_APP_ID_URL + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = TRANSLATE_URL + + override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + val salt = System.currentTimeMillis().toString() + val appId = credentialValue("appId") + val securityKey = credentialValue("appKey") + val sign = MD5.md5("$appId$text$salt$securityKey") + + return listOf( + Pair.create("from", fromLang.translationCode), + Pair.create("to", toLang.translationCode), + Pair.create("appid", appId), + Pair.create("salt", salt), + Pair.create("sign", sign), + Pair.create("q", text) + ) + } - override val credentialHelpUrl: String? = APPLY_APP_ID_URL - - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = TRANSLATE_URL - - override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { - val salt = System.currentTimeMillis().toString() - val appId = credentialValue("appId") - val securityKey = credentialValue("appKey") - val sign = MD5.md5("$appId$text$salt$securityKey") - - return listOf( - Pair.create("from", fromLang.translationCode), - Pair.create("to", toLang.translationCode), - Pair.create("appid", appId), - Pair.create("salt", salt), - Pair.create("sign", sign), - Pair.create("q", text) - ) - } - - override fun configureRequestBuilder(requestBuilder: RequestBuilder) { - requestBuilder.tuner { connection -> - connection.setRequestProperty("Referer", HOST_URL) - } + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.tuner { connection -> + connection.setRequestProperty("Referer", HOST_URL) } - - override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { - LOG.info("parsingResult: $resultText") - val baiduTranslationResult = GsonUtil.getInstance().gson.fromJson(resultText, BaiduTranslationResult::class.java) - return if (baiduTranslationResult.isSuccess()) { - baiduTranslationResult.translationResult - } else { - val message = "${baiduTranslationResult.errorMsg}(${baiduTranslationResult.errorCode})" - throw TranslationException(fromLang, toLang, text, message) - } + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult: $resultText") + val baiduTranslationResult = GsonUtil.getInstance().gson.fromJson(resultText, BaiduTranslationResult::class.java) + return if (baiduTranslationResult.isSuccess()) { + baiduTranslationResult.translationResult + } else { + val message = "${baiduTranslationResult.errorMsg}(${baiduTranslationResult.errorCode})" + throw TranslationException(fromLang, toLang, text, message) } + } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.kt index 5143507..82d002c 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.kt @@ -23,26 +23,26 @@ import com.airsaid.localization.translate.TranslationResult * @author musagil */ data class DeepLTranslationResult( - var translations: List? = null + var translations: List? = null ) : TranslationResult { - override val translationResult: String - get() { - return if (!translations.isNullOrEmpty()) { - val result = translations!![0].text - result ?: "" - } else { - "" - } - } + override val translationResult: String + get() { + return if (!translations.isNullOrEmpty()) { + val result = translations!![0].text + result ?: "" + } else { + "" + } + } - data class Translation( - var text: String? = null, - var to: String? = null - ) + data class Translation( + var text: String? = null, + var to: String? = null + ) - data class Error( - var code: String? = null, - var message: String? = null - ) + data class Error( + var code: String? = null, + var message: String? = null + ) } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt index e8d42ed..b0f8c6e 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt @@ -21,6 +21,7 @@ import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.lang.toLang import com.airsaid.localization.translate.util.GsonUtil import com.airsaid.localization.translate.util.UrlBuilder import com.google.auto.service.AutoService @@ -28,7 +29,6 @@ import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.util.Pair import com.intellij.util.io.RequestBuilder import icons.PluginIcons -import javax.swing.Icon /** * @author musagil @@ -36,93 +36,116 @@ import javax.swing.Icon @AutoService(AbstractTranslator::class) open class DeepLTranslator : AbstractTranslator() { - companion object { - private val LOG = Logger.getInstance(DeepLTranslator::class.java) - private const val KEY = "DeepL" - private const val FREE_HOST_URL = "https://api-free.deepl.com/v2" - private const val PRO_HOST_URL = "https://api.deepl.com/v2" - private const val TRANSLATE_PATH = "/translate" - private const val APPLY_APP_ID_URL = "https://www.deepl.com/pro-api?cta=header-pro-api/" + companion object { + private val LOG = Logger.getInstance(DeepLTranslator::class.java) + private const val KEY = "DeepL" + private const val FREE_HOST_URL = "https://api-free.deepl.com/v2" + private const val PRO_HOST_URL = "https://api.deepl.com/v2" + private const val TRANSLATE_PATH = "/translate" + private const val APPLY_APP_ID_URL = "https://www.deepl.com/pro-api?cta=header-pro-api/" + } + + private val deeplSettings by lazy { DeepLTranslatorSettings.getInstance() } + + override val key = KEY + + override val icon = PluginIcons.DEEP_L_ICON + + override val credentialDefinitions = listOf( + TranslatorCredentialDescriptor(id = "appKey", label = "KEY", isSecret = true) + ) + + override val credentialHelpUrl: String? = APPLY_APP_ID_URL + + override val supportedLanguages: List by lazy { + buildList { + add(Languages.BULGARIAN.toLang()) + add(Languages.CZECH.toLang()) + add(Languages.DANISH.toLang()) + add(Languages.GERMAN.toLang()) + add(Languages.GREEK.toLang()) + add( + Languages.ENGLISH.toLang().copy( + id = 118, + code = "en-gb", + name = "English (British)", + englishName = "English (British)", + directoryName = "en-rGB", + ).setTranslationCode("en-gb") + ) + add( + Languages.ENGLISH.toLang().copy( + id = 119, + code = "en-us", + name = "English (American)", + englishName = "English (American)", + directoryName = "en-rUS", + ).setTranslationCode("en-us") + ) + add(Languages.SPANISH.toLang()) + add(Languages.ESTONIAN.toLang()) + add(Languages.FINNISH.toLang()) + add(Languages.FRENCH.toLang()) + add(Languages.HUNGARIAN.toLang()) + add(Languages.INDONESIAN.toLang()) + add(Languages.ITALIAN.toLang()) + add(Languages.JAPANESE.toLang()) + add(Languages.KOREAN.toLang().setTranslationCode("KO")) + add(Languages.LITHUANIAN.toLang()) + add(Languages.LATVIAN.toLang()) + add(Languages.NORWEGIAN.toLang().setTranslationCode("NB")) + add(Languages.DUTCH.toLang()) + add(Languages.POLISH.toLang()) + add( + Languages.PORTUGUESE.toLang().copy( + id = 120, + code = "pt-br", + name = "Portuguese (Brazilian)", + englishName = "Portuguese (Brazilian)", + directoryName = "pt-rBR", + ).setTranslationCode("pt-br") + ) + add( + Languages.PORTUGUESE.toLang().copy( + id = 121, + code = "pt-pt", + name = "Portuguese (European)", + englishName = "Portuguese (European)", + directoryName = "pt-rPT", + ).setTranslationCode("pt-pt") + ) + add(Languages.ROMANIAN.toLang()) + add(Languages.RUSSIAN.toLang()) + add(Languages.SLOVAK.toLang()) + add(Languages.SLOVENIAN.toLang()) + add(Languages.SWEDISH.toLang()) + add(Languages.TURKISH.toLang()) + add(Languages.UKRAINIAN.toLang()) + add(Languages.CHINESE_SIMPLIFIED.toLang().setTranslationCode("zh")) } + } - private var _supportedLanguages: MutableList? = null + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + val baseUrl = if (deeplSettings.usePro) PRO_HOST_URL else FREE_HOST_URL + return UrlBuilder(baseUrl + TRANSLATE_PATH).build() + } - private val deeplSettings: DeepLTranslatorSettings - get() = DeepLTranslatorSettings.getInstance() - - override val key: String = KEY - - override val name: String = "DeepL" - - override val icon: Icon? = PluginIcons.DEEP_L_ICON - - override val credentialDefinitions = listOf( - TranslatorCredentialDescriptor(id = "appKey", label = "KEY", isSecret = true) + override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + return listOf( + Pair.create("text", text), + Pair.create("target_lang", toLang.code) ) + } - override val credentialHelpUrl: String? = APPLY_APP_ID_URL - - override val supportedLanguages: List - get() { - if (_supportedLanguages == null) { - _supportedLanguages = mutableListOf().apply { - add(Languages.BULGARIAN) - add(Languages.CZECH) - add(Languages.DANISH) - add(Languages.GERMAN) - add(Languages.GREEK) - add(Lang(118, "en-gb", "English (British)", "English (British)")) - add(Lang(119, "en-us", "English (American)", "English (American)")) - add(Languages.SPANISH) - add(Languages.ESTONIAN) - add(Languages.FINNISH) - add(Languages.FRENCH) - add(Languages.HUNGARIAN) - add(Lang(98, "id", "Indonesia", "Indonesian")) - add(Languages.ITALIAN) - add(Languages.JAPANESE) - add(Languages.KOREAN.setTranslationCode("KO")) - add(Languages.LITHUANIAN) - add(Languages.LATVIAN) - add(Languages.NORWEGIAN.setTranslationCode("NB")) - add(Languages.DUTCH) - add(Languages.POLISH) - add(Lang(120, "pt-br", "Portuguese (Brazilian)", "Portuguese (Brazilian)")) - add(Lang(121, "pt-pt", "Portuguese (European)", "Portuguese (European)")) - add(Languages.ROMANIAN) - add(Languages.RUSSIAN) - add(Languages.SLOVAK) - add(Languages.SLOVENIAN) - add(Languages.SWEDISH) - add(Languages.TURKISH) - add(Languages.UKRAINIAN) - add(Lang(104, "zh", "简体中文", "Chinese Simplified")) - } - } - return _supportedLanguages!! - } - - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { - val baseUrl = if (deeplSettings.usePro) PRO_HOST_URL else FREE_HOST_URL - return UrlBuilder(baseUrl + TRANSLATE_PATH).build() + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.tuner { connection -> + connection.setRequestProperty("Authorization", "DeepL-Auth-Key ${credentialValue("appKey")}") + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") } + } - override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { - return listOf( - Pair.create("text", text), - Pair.create("target_lang", toLang.code) - ) - } - - override fun configureRequestBuilder(requestBuilder: RequestBuilder) { - requestBuilder.tuner { connection -> - connection.setRequestProperty("Authorization", "DeepL-Auth-Key ${credentialValue("appKey")}") - connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") - } - } - - override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { - LOG.info("parsingResult: $resultText") - return GsonUtil.getInstance().gson.fromJson(resultText, DeepLTranslationResult::class.java).translationResult - } + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult: $resultText") + return GsonUtil.getInstance().gson.fromJson(resultText, DeepLTranslationResult::class.java).translationResult + } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt index 4569a27..d2f077e 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt @@ -29,31 +29,21 @@ import javax.swing.Icon */ abstract class AbsGoogleTranslator : AbstractTranslator() { - protected var _supportedLanguages: MutableList? = null - override val icon: Icon = PluginIcons.GOOGLE_ICON override val credentialDefinitions: List = emptyList() - override val supportedLanguages: List - get() { - if (_supportedLanguages == null) { - val languages = Languages.getLanguages() - _supportedLanguages = mutableListOf().apply { - for (i in 1..104) { - var lang = languages[i] - lang = when (lang) { - Languages.CHINESE_SIMPLIFIED -> lang.setTranslationCode("zh-CN") - Languages.CHINESE_TRADITIONAL -> lang.setTranslationCode("zh-TW") - Languages.FILIPINO -> lang.setTranslationCode("tl") - Languages.INDONESIAN -> lang.setTranslationCode("id") - Languages.JAVANESE -> lang.setTranslationCode("jw") - else -> lang - } - add(lang) - } + override val supportedLanguages: List by lazy { + Languages.allSupportedLanguages() + .map { lang -> + when (lang.id) { + Languages.CHINESE_SIMPLIFIED.id -> lang.setTranslationCode("zh-CN") + Languages.CHINESE_TRADITIONAL.id -> lang.setTranslationCode("zh-TW") + Languages.FILIPINO.id -> lang.setTranslationCode("tl") + Languages.INDONESIAN.id -> lang.setTranslationCode("id") + Languages.JAVANESE.id -> lang.setTranslationCode("jw") + else -> lang } } - return _supportedLanguages!! - } + } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt index 3453eec..1c312f8 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt @@ -18,7 +18,7 @@ internal fun RequestBuilder.withGoogleHeaders(): RequestBuilder = apply { connection.setRequestProperty( "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + - "(KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" + "(KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" ) } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt index c5b3480..7832d78 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt @@ -77,6 +77,7 @@ internal fun String.tk(): String { bytes += (charCode shr 6 or 0xC0).toLong() bytes += (charCode and 0x3F or 0x80).toLong() } + charCode in 0xD800..0xDBFF && index + 1 < length -> { val next = this[index + 1].code if (next and 0xFC00 == 0xDC00) { @@ -92,6 +93,7 @@ internal fun String.tk(): String { bytes += (charCode and 0x3F or 0x80).toLong() } } + else -> { bytes += (charCode shr 12 or 0xE0).toLong() bytes += (charCode shr 6 and 0x3F or 0x80).toLong() diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt index 6ded492..14cdfc4 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt @@ -4,6 +4,7 @@ import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.TranslationException import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.lang.toLang import com.airsaid.localization.translate.util.GsonUtil import com.airsaid.localization.translate.util.UrlBuilder import com.google.auto.service.AutoService @@ -19,11 +20,11 @@ class GoogleTranslator : AbsGoogleTranslator() { private val log = Logger.getInstance(GoogleTranslator::class.java) override val key: String = KEY - override val name: String = "Google" + override val icon: Icon = PluginIcons.GOOGLE_ICON override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { - val source = fromLang.takeIf { it != Languages.AUTO }?.translationCode ?: "auto" + val source = if (fromLang.id == Languages.AUTO.id) "auto" else fromLang.translationCode val builder = UrlBuilder(googleApiUrl(TRANSLATE_PATH)) .addQueryParameter("client", "gtx") .addQueryParameter("sl", source) @@ -32,7 +33,7 @@ class GoogleTranslator : AbsGoogleTranslator() { .addQueryParameter("dj", "1") .addQueryParameter("ie", "UTF-8") .addQueryParameter("oe", "UTF-8") - .addQueryParameter("hl", Languages.ENGLISH.translationCode) + .addQueryParameter("hl", Languages.ENGLISH.toLang().translationCode) .addQueryParameter("tk", text.tk()) return builder.build() } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt index 79758e1..74b100a 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt @@ -10,10 +10,8 @@ import com.intellij.openapi.diagnostic.logger import com.intellij.util.concurrency.annotations.RequiresBackgroundThread import com.intellij.util.io.HttpRequests import java.io.IOException -import java.util.Base64 -import java.util.Date +import java.util.* import java.util.concurrent.atomic.AtomicReference -import kotlin.jvm.Volatile /** * Fetches and caches Microsoft Translator access tokens using the same public @@ -55,7 +53,7 @@ class MicrosoftEdgeAuthService { connection.setRequestProperty( "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + - "(KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" + "(KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" ) } .connect { request -> request.readString(null) } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.kt index 88697ea..5a25513 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.kt @@ -23,26 +23,26 @@ import com.airsaid.localization.translate.TranslationResult * @author airsaid */ data class MicrosoftTranslationResult( - var translations: List? = null + var translations: List? = null ) : TranslationResult { - override val translationResult: String - get() { - return if (!translations.isNullOrEmpty()) { - val result = translations!![0].text - result ?: "" - } else { - "" - } - } + override val translationResult: String + get() { + return if (!translations.isNullOrEmpty()) { + val result = translations!![0].text + result ?: "" + } else { + "" + } + } - data class Translation( - var text: String? = null, - var to: String? = null - ) + data class Translation( + var text: String? = null, + var to: String? = null + ) - data class Error( - var code: String? = null, - var message: String? = null - ) + data class Error( + var code: String? = null, + var message: String? = null + ) } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt index a967143..93075e4 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslator.kt @@ -21,14 +21,13 @@ import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.lang.toLang import com.airsaid.localization.translate.util.GsonUtil import com.airsaid.localization.translate.util.UrlBuilder import com.google.auto.service.AutoService import com.intellij.openapi.diagnostic.Logger import com.intellij.util.io.RequestBuilder import icons.PluginIcons -import javax.swing.Icon -import kotlin.jvm.Volatile /** * @author airsaid @@ -36,150 +35,142 @@ import kotlin.jvm.Volatile @AutoService(AbstractTranslator::class) class MicrosoftTranslator : AbstractTranslator() { - companion object { - private val LOG = Logger.getInstance(MicrosoftTranslator::class.java) - private const val KEY = "Microsoft" - private const val DEFAULT_HOST_URL = "https://api.cognitive.microsofttranslator.com" - - @Volatile - internal var hostOverride: String? = null - - private val HOST_URL: String - get() = hostOverride ?: DEFAULT_HOST_URL - - private val TRANSLATE_URL: String - get() = "$HOST_URL/translate" + companion object { + private val LOG = Logger.getInstance(MicrosoftTranslator::class.java) + private const val KEY = "Microsoft" + private const val DEFAULT_HOST_URL = "https://api.cognitive.microsofttranslator.com" + + @Volatile + internal var hostOverride: String? = null + + private val HOST_URL: String + get() = hostOverride ?: DEFAULT_HOST_URL + + private val TRANSLATE_URL: String + get() = "$HOST_URL/translate" + } + + override val key = KEY + + override val icon = PluginIcons.MICROSOFT_ICON + + override val credentialDefinitions: List = emptyList() + + override val credentialHelpUrl: String? = null + + override val supportedLanguages: List by lazy { + buildList { + add(Languages.AFRIKAANS.toLang()) + add(Languages.ALBANIAN.toLang()) + add(Languages.AMHARIC.toLang()) + add(Languages.ARABIC.toLang()) + add(Languages.ARMENIAN.toLang()) + add(Languages.ASSAMESE.toLang()) + add(Languages.AZERBAIJANI.toLang()) + add(Languages.BANGLA.toLang()) + add(Languages.BOSNIAN.toLang()) + add(Languages.BULGARIAN.toLang()) + add(Languages.CATALAN.toLang()) + add(Languages.CHINESE_SIMPLIFIED.toLang().setTranslationCode("zh-Hans")) + add(Languages.CHINESE_TRADITIONAL.toLang().setTranslationCode("zh-Hant")) + add(Languages.CROATIAN.toLang()) + add(Languages.CZECH.toLang()) + add(Languages.DANISH.toLang()) + add(Languages.DARI.toLang()) + add(Languages.DUTCH.toLang()) + add(Languages.ENGLISH.toLang()) + add(Languages.ESTONIAN.toLang()) + add(Languages.FIJIAN.toLang()) + add(Languages.FILIPINO.toLang().setTranslationCode("fil")) + add(Languages.FINNISH.toLang()) + add(Languages.FRENCH.toLang()) + add(Languages.GERMAN.toLang()) + add(Languages.GREEK.toLang()) + add(Languages.GUJARATI.toLang()) + add(Languages.HAITIAN_CREOLE.toLang()) + add(Languages.HEBREW.toLang().setTranslationCode("he")) + add(Languages.HINDI.toLang()) + add(Languages.HMONG_DAW.toLang()) + add(Languages.HUNGARIAN.toLang()) + add(Languages.ICELANDIC.toLang()) + add(Languages.INDONESIAN.toLang().setTranslationCode("id")) + add(Languages.INUKTITUT.toLang()) + add(Languages.IRISH.toLang()) + add(Languages.ITALIAN.toLang()) + add(Languages.JAPANESE.toLang()) + add(Languages.KANNADA.toLang()) + add(Languages.KAZAKH.toLang()) + add(Languages.KHMER.toLang()) + add(Languages.KOREAN.toLang()) + add(Languages.KURDISH.toLang()) + add(Languages.LAO.toLang()) + add(Languages.LATVIAN.toLang()) + add(Languages.LITHUANIAN.toLang()) + add(Languages.MALAGASY.toLang()) + add(Languages.MALAY.toLang()) + add(Languages.MALAYALAM.toLang()) + add(Languages.MALTESE.toLang()) + add(Languages.MAORI.toLang()) + add(Languages.MARATHI.toLang()) + add(Languages.BURMESE.toLang()) + add(Languages.NEPALI.toLang()) + add(Languages.NORWEGIAN.toLang().setTranslationCode("nb")) + add(Languages.ODIA.toLang()) + add(Languages.PASHTO.toLang()) + add(Languages.PERSIAN.toLang()) + add(Languages.PORTUGUESE.toLang()) + add(Languages.PUNJABI.toLang()) + add(Languages.QUERETARO_OTOMI.toLang()) + add(Languages.ROMANIAN.toLang()) + add(Languages.RUSSIAN.toLang()) + add(Languages.SAMOAN.toLang()) + add(Languages.SERBIAN.toLang()) + add(Languages.SLOVAK.toLang()) + add(Languages.SLOVENIAN.toLang()) + add(Languages.SPANISH.toLang()) + add(Languages.SWAHILI.toLang()) + add(Languages.SWEDISH.toLang()) + add(Languages.TAHITIAN.toLang()) + add(Languages.TAMIL.toLang()) + add(Languages.TELUGU.toLang()) + add(Languages.THAI.toLang()) + add(Languages.TIGRINYA.toLang()) + add(Languages.TONGAN.toLang()) + add(Languages.TURKISH.toLang()) + add(Languages.UKRAINIAN.toLang()) + add(Languages.URDU.toLang()) + add(Languages.VIETNAMESE.toLang()) + add(Languages.WELSH.toLang()) + add(Languages.YUCATEC_MAYA.toLang()) } - - private var _supportedLanguages: MutableList? = null - - override val key: String = KEY - - override val name: String = "Microsoft" - - override val icon: Icon? = PluginIcons.MICROSOFT_ICON - - override val credentialDefinitions: List = emptyList() - - override val credentialHelpUrl: String? = null - - override val supportedLanguages: List - get() { - if (_supportedLanguages == null) { - _supportedLanguages = mutableListOf().apply { - add(Languages.AFRIKAANS) - add(Languages.ALBANIAN) - add(Languages.AMHARIC) - add(Languages.ARABIC) - add(Languages.ARMENIAN) - add(Languages.ASSAMESE) - add(Languages.AZERBAIJANI) - add(Languages.BANGLA) - add(Languages.BOSNIAN) - add(Languages.BULGARIAN) - add(Languages.CATALAN) - add(Languages.CHINESE_SIMPLIFIED.setTranslationCode("zh-Hans")) - add(Languages.CHINESE_TRADITIONAL.setTranslationCode("zh-Hant")) - add(Languages.CROATIAN) - add(Languages.CZECH) - add(Languages.DANISH) - add(Languages.DARI) - add(Languages.DUTCH) - add(Languages.ENGLISH) - add(Languages.ESTONIAN) - add(Languages.FIJIAN) - add(Languages.FILIPINO.setTranslationCode("fil")) - add(Languages.FINNISH) - add(Languages.FRENCH) - add(Languages.GERMAN) - add(Languages.GREEK) - add(Languages.GUJARATI) - add(Languages.HAITIAN_CREOLE) - add(Languages.HEBREW.setTranslationCode("he")) - add(Languages.HINDI) - add(Languages.HMONG_DAW) - add(Languages.HUNGARIAN) - add(Languages.ICELANDIC) - add(Languages.INDONESIAN.setTranslationCode("id")) - add(Languages.INUKTITUT) - add(Languages.IRISH) - add(Languages.ITALIAN) - add(Languages.JAPANESE) - add(Languages.KANNADA) - add(Languages.KAZAKH) - add(Languages.KHMER) - add(Languages.KLINGON_LATIN) - add(Languages.KLINGON_PIQAD) - add(Languages.KOREAN) - add(Languages.KURDISH) - add(Languages.LAO) - add(Languages.LATVIAN) - add(Languages.LITHUANIAN) - add(Languages.MALAGASY) - add(Languages.MALAY) - add(Languages.MALAYALAM) - add(Languages.MALTESE) - add(Languages.MAORI) - add(Languages.MARATHI) - add(Languages.BURMESE) - add(Languages.NEPALI) - add(Languages.NORWEGIAN.setTranslationCode("nb")) - add(Languages.ODIA) - add(Languages.PASHTO) - add(Languages.PERSIAN) - add(Languages.PORTUGUESE) - add(Languages.PUNJABI) - add(Languages.QUERETARO_OTOMI) - add(Languages.ROMANIAN) - add(Languages.RUSSIAN) - add(Languages.SAMOAN) - add(Languages.SERBIAN) - add(Languages.SLOVAK) - add(Languages.SLOVENIAN) - add(Languages.SPANISH) - add(Languages.SWAHILI) - add(Languages.SWEDISH) - add(Languages.TAHITIAN) - add(Languages.TAMIL) - add(Languages.TELUGU) - add(Languages.THAI) - add(Languages.TIGRINYA) - add(Languages.TONGAN) - add(Languages.TURKISH) - add(Languages.UKRAINIAN) - add(Languages.URDU) - add(Languages.VIETNAMESE) - add(Languages.WELSH) - add(Languages.YUCATEC_MAYA) - } - } - return _supportedLanguages!! - } - - override val requestContentType: String - get() = "application/json" - - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { - return UrlBuilder(TRANSLATE_URL) - .addQueryParameter("api-version", "3.0") - .addQueryParameter("to", toLang.translationCode) - .build() - } - - override fun getRequestBody(fromLang: Lang, toLang: Lang, text: String): String { - return "[{\"Text\": \"$text\"}]" - } - - override fun configureRequestBuilder(requestBuilder: RequestBuilder) { - requestBuilder.tuner { connection -> - val token = MicrosoftEdgeAuthService.getInstance().getAccessToken() - connection.setRequestProperty("Authorization", "Bearer $token") - connection.setRequestProperty("Content-type", "application/json") - } - } - - override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { - LOG.info("parsingResult: $resultText") - return GsonUtil.getInstance().gson.fromJson(resultText, Array::class.java)[0].translationResult + } + + override val requestContentType = "application/json" + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + return UrlBuilder(TRANSLATE_URL) + .addQueryParameter("api-version", "3.0") + .addQueryParameter("to", toLang.translationCode) + .build() + } + + override fun getRequestBody(fromLang: Lang, toLang: Lang, text: String): String { + return "[{\"Text\": \"$text\"}]" + } + + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.tuner { connection -> + val token = MicrosoftEdgeAuthService.getInstance().getAccessToken() + connection.setRequestProperty("Authorization", "Bearer $token") + connection.setRequestProperty("Content-type", "application/json") } + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult: $resultText") + return GsonUtil.getInstance().gson.fromJson( + resultText, + Array::class.java + )[0].translationResult + } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt index 1589466..3236145 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt @@ -40,8 +40,6 @@ class OpenAITranslator : AbstractTranslator() { override val key = KEY - override val name = KEY - override val icon = PluginIcons.OPENAI_ICON override val credentialDefinitions: List diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt index ccb5274..410f5f1 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt @@ -1,10 +1,6 @@ package com.airsaid.localization.translate.impl.openai -import com.intellij.openapi.components.PersistentStateComponent -import com.intellij.openapi.components.Service -import com.intellij.openapi.components.State -import com.intellij.openapi.components.Storage -import com.intellij.openapi.components.service +import com.intellij.openapi.components.* import java.net.URI @Service @@ -101,6 +97,7 @@ class OpenAITranslatorSettings : PersistentStateComponent? = null + var requestId: String? = null, + var errorCode: String? = null, + var translation: List? = null ) : TranslationResult { - val isSuccess: Boolean - get() { - val errorCode = this.errorCode - return !StringUtil.isEmpty(errorCode) && "0" == errorCode - } - - override val translationResult: String - get() { - val translation = this.translation - return if (translation != null && translation.isNotEmpty()) { - translation[0] ?: "" - } else { - "" - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || javaClass != other.javaClass) return false - val that = other as YoudaoTranslationResult - return requestId == that.requestId + val isSuccess: Boolean + get() { + val errorCode = this.errorCode + return !errorCode.isNullOrEmpty() && "0" == errorCode } - override fun hashCode(): Int { - return requestId?.hashCode() ?: 0 + override val translationResult: String + get() { + val translation = this.translation + return if (translation != null && translation.isNotEmpty()) { + translation[0] + } else { + "" + } } - override fun toString(): String { - return "YoudaoTranslationResult{" + - "requestId='$requestId', " + - "errorCode='$errorCode', " + - "translation=$translation" + - '}' - } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val that = other as YoudaoTranslationResult + return requestId == that.requestId + } + + override fun hashCode(): Int { + return requestId?.hashCode() ?: 0 + } + + override fun toString(): String { + return "YoudaoTranslationResult{" + + "requestId='$requestId', " + + "errorCode='$errorCode', " + + "translation=$translation" + + '}' + } } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt index 267d86c..bdd2361 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslator.kt @@ -22,6 +22,7 @@ import com.airsaid.localization.translate.TranslationException import com.airsaid.localization.translate.TranslatorCredentialDescriptor import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.lang.toLang import com.airsaid.localization.translate.util.GsonUtil import com.google.auto.service.AutoService import com.intellij.openapi.diagnostic.Logger @@ -31,7 +32,6 @@ import icons.PluginIcons import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.security.NoSuchAlgorithmException -import javax.swing.Icon /** * @author airsaid @@ -39,189 +39,179 @@ import javax.swing.Icon @Suppress("SpellCheckingInspection", "unused") @AutoService(AbstractTranslator::class) class YoudaoTranslator : AbstractTranslator() { - companion object { - private val LOG = Logger.getInstance(YoudaoTranslator::class.java) - private const val KEY = "Youdao" - private const val HOST_URL = "https://openapi.youdao.com" - private const val TRANSLATE_URL = "$HOST_URL/api" - private const val APPLY_APP_ID_URL = "https://ai.youdao.com/DOCSIRMA/html/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E7%BF%BB%E8%AF%91/API%E6%96%87%E6%A1%A3/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1-API%E6%96%87%E6%A1%A3.html" - } - - private var _supportedLanguages: List? = null - - override val key: String - get() = KEY - - override val name: String - get() = "Youdao" - - override val icon: Icon? - get() = PluginIcons.YOUDAO_ICON - - override val supportedLanguages: List - get() { - if (_supportedLanguages == null) { - _supportedLanguages = mutableListOf().apply { - add(Languages.CHINESE_SIMPLIFIED.setTranslationCode("zh-CHS")) - add(Languages.ENGLISH) - add(Languages.JAPANESE) - add(Languages.KOREAN) - add(Languages.FRENCH) - add(Languages.SPANISH) - add(Languages.ITALIAN) - add(Languages.RUSSIAN) - add(Languages.VIETNAMESE) - add(Languages.GERMAN) - add(Languages.ARABIC) - add(Languages.INDONESIAN.setTranslationCode("id")) - add(Languages.AFRIKAANS) - add(Languages.BOSNIAN) - add(Languages.BULGARIAN) - add(Languages.CATALAN) - add(Languages.CROATIAN) - add(Languages.CZECH) - add(Languages.DANISH) - add(Languages.DUTCH) - add(Languages.ESTONIAN) - add(Languages.FINNISH) - add(Languages.HAITIAN_CREOLE) - add(Languages.HINDI) - add(Languages.HUNGARIAN) - add(Languages.SWAHILI) - add(Languages.LITHUANIAN) - add(Languages.MALAY) - add(Languages.MALTESE) - add(Languages.NORWEGIAN) - add(Languages.POLISH) - add(Languages.ROMANIAN) - add(Languages.SERBIAN.setTranslationCode("sr-Cyrl")) - add(Languages.SLOVAK) - add(Languages.SLOVENIAN) - add(Languages.SWEDISH) - add(Languages.THAI) - add(Languages.TURKISH) - add(Languages.UKRAINIAN) - add(Languages.URDU) - add(Languages.AMHARIC) - add(Languages.AZERBAIJANI) - add(Languages.BANGLA) - add(Languages.BASQUE) - add(Languages.BELARUSIAN) - add(Languages.CEBUANO) - add(Languages.CORSICAN) - add(Languages.ESPERANTO) - add(Languages.FILIPINO.setTranslationCode("tl")) - add(Languages.FRISIAN) - add(Languages.GUJARATI) - add(Languages.HAUSA) - add(Languages.HAWAIIAN) - add(Languages.ICELANDIC) - add(Languages.JAVANESE.setTranslationCode("jw")) - add(Languages.KANNADA) - add(Languages.KAZAKH) - add(Languages.KHMER) - add(Languages.KURDISH) - add(Languages.KYRGYZ) - add(Languages.LAO) - add(Languages.LATIN) - add(Languages.LUXEMBOURGISH) - add(Languages.MACEDONIAN) - add(Languages.MALAGASY) - add(Languages.MALAYALAM) - add(Languages.MARATHI) - add(Languages.MONGOLIAN) - add(Languages.BURMESE) - add(Languages.NEPALI) - add(Languages.CHICHEWA) - add(Languages.PASHTO) - add(Languages.PUNJABI) - add(Languages.SAMOAN) - add(Languages.SCOTTISH_GAELIC) - add(Languages.SOTHO) - add(Languages.SHONA) - add(Languages.SINDHI) - add(Languages.SLOVENIAN) - add(Languages.SOMALI) - add(Languages.SUNDANESE) - add(Languages.TAJIK) - add(Languages.TAMIL) - add(Languages.TELUGU) - add(Languages.UZBEK) - add(Languages.XHOSA) - add(Languages.YORUBA) - add(Languages.ZULU) - } - } - return _supportedLanguages!! - } - - override val credentialDefinitions = listOf( - TranslatorCredentialDescriptor(id = "appId", label = "APP ID", isSecret = false), - TranslatorCredentialDescriptor(id = "appKey", label = "APP KEY", isSecret = true) + companion object { + private val LOG = Logger.getInstance(YoudaoTranslator::class.java) + private const val KEY = "Youdao" + private const val HOST_URL = "https://openapi.youdao.com" + private const val TRANSLATE_URL = "$HOST_URL/api" + private const val APPLY_APP_ID_URL = + "https://ai.youdao.com/DOCSIRMA/html/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E7%BF%BB%E8%AF%91/API%E6%96%87%E6%A1%A3/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1/%E6%96%87%E6%9C%AC%E7%BF%BB%E8%AF%91%E6%9C%8D%E5%8A%A1-API%E6%96%87%E6%A1%A3.html" + } + + override val key = KEY + + override val icon = PluginIcons.YOUDAO_ICON + + override val supportedLanguages: List by lazy { + listOf( + Languages.CHINESE_SIMPLIFIED.toLang().setTranslationCode("zh-CHS"), + Languages.ENGLISH.toLang(), + Languages.JAPANESE.toLang(), + Languages.KOREAN.toLang(), + Languages.FRENCH.toLang(), + Languages.SPANISH.toLang(), + Languages.ITALIAN.toLang(), + Languages.RUSSIAN.toLang(), + Languages.VIETNAMESE.toLang(), + Languages.GERMAN.toLang(), + Languages.ARABIC.toLang(), + Languages.INDONESIAN.toLang().setTranslationCode("id"), + Languages.AFRIKAANS.toLang(), + Languages.BOSNIAN.toLang(), + Languages.BULGARIAN.toLang(), + Languages.CATALAN.toLang(), + Languages.CROATIAN.toLang(), + Languages.CZECH.toLang(), + Languages.DANISH.toLang(), + Languages.DUTCH.toLang(), + Languages.ESTONIAN.toLang(), + Languages.FINNISH.toLang(), + Languages.HAITIAN_CREOLE.toLang(), + Languages.HINDI.toLang(), + Languages.HUNGARIAN.toLang(), + Languages.SWAHILI.toLang(), + Languages.LITHUANIAN.toLang(), + Languages.MALAY.toLang(), + Languages.MALTESE.toLang(), + Languages.NORWEGIAN.toLang(), + Languages.POLISH.toLang(), + Languages.ROMANIAN.toLang(), + Languages.SERBIAN.toLang().setTranslationCode("sr-Cyrl"), + Languages.SLOVAK.toLang(), + Languages.SLOVENIAN.toLang(), + Languages.SWEDISH.toLang(), + Languages.THAI.toLang(), + Languages.TURKISH.toLang(), + Languages.UKRAINIAN.toLang(), + Languages.URDU.toLang(), + Languages.AMHARIC.toLang(), + Languages.AZERBAIJANI.toLang(), + Languages.BANGLA.toLang(), + Languages.BASQUE.toLang(), + Languages.BELARUSIAN.toLang(), + Languages.CEBUANO.toLang(), + Languages.CORSICAN.toLang(), + Languages.ESPERANTO.toLang(), + Languages.FILIPINO.toLang().setTranslationCode("tl"), + Languages.FRISIAN.toLang(), + Languages.GUJARATI.toLang(), + Languages.HAUSA.toLang(), + Languages.HAWAIIAN.toLang(), + Languages.ICELANDIC.toLang(), + Languages.JAVANESE.toLang().setTranslationCode("jw"), + Languages.KANNADA.toLang(), + Languages.KAZAKH.toLang(), + Languages.KHMER.toLang(), + Languages.KURDISH.toLang(), + Languages.KYRGYZ.toLang(), + Languages.LAO.toLang(), + Languages.LATIN.toLang(), + Languages.LUXEMBOURGISH.toLang(), + Languages.MACEDONIAN.toLang(), + Languages.MALAGASY.toLang(), + Languages.MALAYALAM.toLang(), + Languages.MARATHI.toLang(), + Languages.MONGOLIAN.toLang(), + Languages.BURMESE.toLang(), + Languages.NEPALI.toLang(), + Languages.CHICHEWA.toLang(), + Languages.PASHTO.toLang(), + Languages.PUNJABI.toLang(), + Languages.SAMOAN.toLang(), + Languages.SCOTTISH_GAELIC.toLang(), + Languages.SOTHO.toLang(), + Languages.SHONA.toLang(), + Languages.SINDHI.toLang(), + Languages.SLOVENIAN.toLang(), + Languages.SOMALI.toLang(), + Languages.SUNDANESE.toLang(), + Languages.TAJIK.toLang(), + Languages.TAMIL.toLang(), + Languages.TELUGU.toLang(), + Languages.UZBEK.toLang(), + Languages.XHOSA.toLang(), + Languages.YORUBA.toLang(), + Languages.ZULU.toLang(), ) - - override val credentialHelpUrl: String? = APPLY_APP_ID_URL - - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { - return TRANSLATE_URL + } + + override val credentialDefinitions = listOf( + TranslatorCredentialDescriptor(id = "appId", label = "APP ID", isSecret = false), + TranslatorCredentialDescriptor(id = "appKey", label = "APP KEY", isSecret = true) + ) + + override val credentialHelpUrl: String? = APPLY_APP_ID_URL + + override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { + return TRANSLATE_URL + } + + private fun truncate(q: String): String { + val len = q.length + return if (len <= 20) q else (q.substring(0, 10) + len + q.substring(len - 10, len)) + } + + private fun getDigest(string: String): String? { + val hexDigits = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F') + val btInput = string.toByteArray(StandardCharsets.UTF_8) + return try { + val mdInst = MessageDigest.getInstance("SHA-256") + mdInst.update(btInput) + val md = mdInst.digest() + val j = md.size + val str = CharArray(j * 2) + var k = 0 + for (byte0 in md) { + str[k++] = hexDigits[byte0.toInt() ushr 4 and 0xf] + str[k++] = hexDigits[byte0.toInt() and 0xf] + } + String(str) + } catch (e: NoSuchAlgorithmException) { + null } - - private fun truncate(q: String): String { - val len = q.length - return if (len <= 20) q else (q.substring(0, 10) + len + q.substring(len - 10, len)) - } - - private fun getDigest(string: String): String? { - val hexDigits = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F') - val btInput = string.toByteArray(StandardCharsets.UTF_8) - return try { - val mdInst = MessageDigest.getInstance("SHA-256") - mdInst.update(btInput) - val md = mdInst.digest() - val j = md.size - val str = CharArray(j * 2) - var k = 0 - for (byte0 in md) { - str[k++] = hexDigits[byte0.toInt() ushr 4 and 0xf] - str[k++] = hexDigits[byte0.toInt() and 0xf] - } - String(str) - } catch (e: NoSuchAlgorithmException) { - null - } - } - - override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { - val salt = System.currentTimeMillis().toString() - val curTime = (System.currentTimeMillis() / 1000).toString() - val appId = credentialValue("appId") - val appKey = credentialValue("appKey") - val sign = getDigest(appId + truncate(text) + salt + curTime + appKey) - val params = mutableListOf>() - params.add(Pair.create("from", fromLang.translationCode)) - params.add(Pair.create("to", toLang.translationCode)) - params.add(Pair.create("signType", "v3")) - params.add(Pair.create("curtime", curTime)) - params.add(Pair.create("appKey", appId)) - params.add(Pair.create("salt", salt)) - params.add(Pair.create("sign", sign)) - params.add(Pair.create("q", text)) - return params - } - - override fun configureRequestBuilder(requestBuilder: RequestBuilder) { - requestBuilder.tuner { connection -> - connection.setRequestProperty("Referer", HOST_URL) - } + } + + override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { + val salt = System.currentTimeMillis().toString() + val curTime = (System.currentTimeMillis() / 1000).toString() + val appId = credentialValue("appId") + val appKey = credentialValue("appKey") + val sign = getDigest(appId + truncate(text) + salt + curTime + appKey) + val params = mutableListOf>() + params.add(Pair.create("from", fromLang.translationCode)) + params.add(Pair.create("to", toLang.translationCode)) + params.add(Pair.create("signType", "v3")) + params.add(Pair.create("curtime", curTime)) + params.add(Pair.create("appKey", appId)) + params.add(Pair.create("salt", salt)) + params.add(Pair.create("sign", sign)) + params.add(Pair.create("q", text)) + return params + } + + override fun configureRequestBuilder(requestBuilder: RequestBuilder) { + requestBuilder.tuner { connection -> + connection.setRequestProperty("Referer", HOST_URL) } - - override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { - LOG.info("parsingResult: $resultText") - val translationResult = GsonUtil.getInstance().gson.fromJson(resultText, YoudaoTranslationResult::class.java) - return if (translationResult.isSuccess) { - translationResult.translationResult - } else { - throw TranslationException(fromLang, toLang, text, translationResult.errorCode ?: "Unknown error") - } + } + + override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { + LOG.info("parsingResult: $resultText") + val translationResult = GsonUtil.getInstance().gson.fromJson(resultText, YoudaoTranslationResult::class.java) + return if (translationResult.isSuccess) { + translationResult.translationResult + } else { + throw TranslationException(fromLang, toLang, text, translationResult.errorCode ?: "Unknown error") } + } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.kt b/src/main/kotlin/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.kt index b553406..d0ae97d 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.kt @@ -25,23 +25,23 @@ import com.intellij.openapi.util.text.StringUtil */ class EscapeCharactersInterceptor : TranslatorService.TranslationInterceptor { - private val needEscapeChars = mutableListOf() + private val needEscapeChars = mutableListOf() - init { - needEscapeChars.addAll(listOf('@', '?', '\'', '\"')) - } + init { + needEscapeChars.addAll(listOf('@', '?', '\'', '\"')) + } - override fun process(text: String?): String? { - if (StringUtil.isEmpty(text)) { - return text - } - val result = StringBuilder() - text!!.forEach { ch -> - if (needEscapeChars.contains(ch)) { - result.append('\\') - } - result.append(ch) - } - return result.toString() + override fun process(text: String?): String? { + if (StringUtil.isEmpty(text)) { + return text + } + val result = StringBuilder() + text!!.forEach { ch -> + if (needEscapeChars.contains(ch)) { + result.append('\\') + } + result.append(ch) } + return result.toString() + } } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt b/src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt index 17d12d9..085027b 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt @@ -26,47 +26,51 @@ import com.intellij.openapi.util.text.StringUtil * @author airsaid */ data class Lang( - val id: Int, - val code: String, - val name: String, - val englishName: String, - private val _translationCode: String? = null + val id: Int, + val code: String, + val name: String, + val englishName: String, + val flag: String, + val directoryName: String, + private val _translationCode: String? = null ) : Cloneable { - val translationCode: String - get() = if (!StringUtil.isEmpty(_translationCode)) _translationCode!! else code + val translationCode: String + get() = if (!StringUtil.isEmpty(_translationCode)) _translationCode!! else code - fun setTranslationCode(translationCode: String): Lang { - return this.copy(_translationCode = translationCode) - } + fun setTranslationCode(translationCode: String): Lang { + return this.copy(_translationCode = translationCode) + } - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || javaClass != other.javaClass) return false - val language = other as Lang - return id == language.id - } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val language = other as Lang + return id == language.id + } - override fun hashCode(): Int { - return id.hashCode() - } + override fun hashCode(): Int { + return id.hashCode() + } - public override fun clone(): Lang { - return try { - super.clone() as Lang - } catch (e: CloneNotSupportedException) { - e.printStackTrace() - this.copy() - } + public override fun clone(): Lang { + return try { + super.clone() as Lang + } catch (e: CloneNotSupportedException) { + e.printStackTrace() + this.copy() } + } - override fun toString(): String { - return "Lang{" + - "id=$id, " + - "code='$code', " + - "name='$name', " + - "englishName='$englishName', " + - "translationCode='$_translationCode'" + - '}' - } + override fun toString(): String { + return "Lang{" + + "id=$id, " + + "code='$code', " + + "name='$name', " + + "englishName='$englishName', " + + "flag='$flag', " + + "directoryName='$directoryName', " + + "translationCode='$translationCode'" + + '}' + } } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/lang/LanguageFlags.kt b/src/main/kotlin/com/airsaid/localization/translate/lang/LanguageFlags.kt deleted file mode 100644 index 0ea9b20..0000000 --- a/src/main/kotlin/com/airsaid/localization/translate/lang/LanguageFlags.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.airsaid.localization.translate.lang - -import java.util.Locale - -private val LANGUAGE_FLAG_OVERRIDES: Map = mapOf( - "sq" to "\uD83C\uDDE6\uD83C\uDDF1", // 🇦🇱 Albania - "ar" to "\uD83C\uDDF8\uD83C\uDDE6", // 🇸🇦 Saudi Arabia - "am" to "\uD83C\uDDEA\uD83C\uDDF9", // 🇪🇹 Ethiopia - "az" to "\uD83C\uDDE6\uD83C\uDDFF", // 🇦🇿 Azerbaijan - "ga" to "\uD83C\uDDEE\uD83C\uDDEA", // 🇮🇪 Ireland - "et" to "\uD83C\uDDEA\uD83C\uDDEA", // 🇪🇪 Estonia - "eu" to "\uD83C\uDDEA\uD83C\uDDF8", // 🇪🇸 Spain (Basque Country) - "be" to "\uD83C\uDDE7\uD83C\uDDFE", // 🇧🇾 Belarus - "bg" to "\uD83C\uDDE7\uD83C\uDDEC", // 🇧🇬 Bulgaria - "is" to "\uD83C\uDDEE\uD83C\uDDF8", // 🇮🇸 Iceland - "pl" to "\uD83C\uDDF5\uD83C\uDDF1", // 🇵🇱 Poland - "bs" to "\uD83C\uDDE7\uD83C\uDDE6", // 🇧🇦 Bosnia and Herzegovina - "fa" to "\uD83C\uDDEE\uD83C\uDDF7", // 🇮🇷 Iran - "af" to "\uD83C\uDDFF\uD83C\uDDE6", // 🇿🇦 South Africa - "da" to "\uD83C\uDDE9\uD83C\uDDF0", // 🇩🇰 Denmark - "de" to "\uD83C\uDDE9\uD83C\uDDEA", // 🇩🇪 Germany - "ru" to "\uD83C\uDDF7\uD83C\uDDFA", // 🇷🇺 Russia - "fr" to "\uD83C\uDDEB\uD83C\uDDF7", // 🇫🇷 France - "fil" to "\uD83C\uDDF5\uD83C\uDDED", // 🇵🇭 Philippines - "fi" to "\uD83C\uDDEB\uD83C\uDDEE", // 🇫🇮 Finland - "fy" to "\uD83C\uDDF3\uD83C\uDDF1", // 🇳🇱 Netherlands - "km" to "\uD83C\uDDF0\uD83C\uDDED", // 🇰🇭 Cambodia - "ka" to "\uD83C\uDDEC\uD83C\uDDEA", // 🇬🇪 Georgia - "gu" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Gujarati) - "kk" to "\uD83C\uDDF0\uD83C\uDDFF", // 🇰🇿 Kazakhstan - "ht" to "\uD83C\uDDED\uD83C\uDDF9", // 🇭🇹 Haiti - "ko" to "\uD83C\uDDF0\uD83C\uDDF7", // 🇰🇷 South Korea - "ha" to "\uD83C\uDDF3\uD83C\uDDEC", // 🇳🇬 Nigeria - "nl" to "\uD83C\uDDF3\uD83C\uDDF1", // 🇳🇱 Netherlands - "ky" to "\uD83C\uDDF0\uD83C\uDDEC", // 🇰🇬 Kyrgyzstan - "gl" to "\uD83C\uDDEA\uD83C\uDDF8", // 🇪🇸 Spain (Galicia) - "ca" to "\uD83C\uDDEA\uD83C\uDDF8", // 🇪🇸 Spain (Catalonia) - "cs" to "\uD83C\uDDE8\uD83C\uDDFF", // 🇨🇿 Czech Republic - "kn" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Kannada) - "co" to "\uD83C\uDDEB\uD83C\uDDF7", // 🇫🇷 France (Corsica) - "hr" to "\uD83C\uDDED\uD83C\uDDF7", // 🇭🇷 Croatia - "ku" to "\uD83C\uDDEE\uD83C\uDDEC", // 🇮🇶 Iraq (Kurdish) - "la" to "\uD83C\uDDFB\uD83C\uDDE6", // 🇻🇦 Vatican City - "lv" to "\uD83C\uDDF1\uD83C\uDDFB", // 🇱🇻 Latvia - "lo" to "\uD83C\uDDF1\uD83C\uDDE6", // 🇱🇦 Laos - "lt" to "\uD83C\uDDF1\uD83C\uDDF9", // 🇱🇹 Lithuania - "lb" to "\uD83C\uDDF1\uD83C\uDDEA", // 🇱🇺 Luxembourg - "ro" to "\uD83C\uDDF7\uD83C\uDDF4", // 🇷🇴 Romania - "mg" to "\uD83C\uDDF2\uD83C\uDDEC", // 🇲🇬 Madagascar - "mt" to "\uD83C\uDDF2\uD83C\uDDF9", // 🇲🇹 Malta - "mr" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Marathi) - "ml" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Malayalam) - "ms" to "\uD83C\uDDF2\uD83C\uDDFE", // 🇲🇾 Malaysia - "mk" to "\uD83C\uDDF2\uD83C\uDDF0", // 🇲🇰 North Macedonia - "mi" to "\uD83C\uDDF3\uD83C\uDDFF", // 🇳🇿 New Zealand (Maori) - "mn" to "\uD83C\uDDF2\uD83C\uDDF3", // 🇲🇳 Mongolia - "bn" to "\uD83C\uDDFA\uD83C\uDDEC", // 🇧🇩 Bangladesh - "my" to "\uD83C\uDDF2\uD83C\uDDF2", // 🇲🇲 Myanmar - "hmn" to "\uD83C\uDDE8\uD83C\uDDF3", // 🇨🇳 China (Hmong) - "xh" to "\uD83C\uDDFF\uD83C\uDDE6", // 🇿🇦 South Africa - "zu" to "\uD83C\uDDFF\uD83C\uDDE6", // 🇿🇦 South Africa - "ne" to "\uD83C\uDDF3\uD83C\uDDF5", // 🇳🇵 Nepal - "no" to "\uD83C\uDDF3\uD83C\uDDF4", // 🇳🇴 Norway - "pa" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Punjabi) - "pt" to "\uD83C\uDDF5\uD83C\uDDF9", // 🇵🇹 Portugal - "ps" to "\uD83C\uDDE6\uD83C\uDDEB", // 🇦🇫 Afghanistan - "ny" to "\uD83C\uDDF2\uD83C\uDDFC", // 🇲🇼 Malawi - "ja" to "\uD83C\uDDEF\uD83C\uDDF5", // 🇯🇵 Japan - "sv" to "\uD83C\uDDF8\uD83C\uDDEA", // 🇸🇪 Sweden - "sm" to "\uD83C\uDDFC\uD83C\uDDF8", // 🇼🇸 Samoa - "sr" to "\uD83C\uDDF7\uD83C\uDDF8", // 🇷🇸 Serbia - "st" to "\uD83C\uDDF1\uD83C\uDDF8", // 🇱🇸 Lesotho - "si" to "\uD83C\uDDF1\uD83C\uDDF0", // 🇱🇰 Sri Lanka - "eo" to "\uD83C\uDDFA\uD83C\uDDF3", // 🇺🇳 United Nations flag approximation - "sk" to "\uD83C\uDDF8\uD83C\uDDF0", // 🇸🇰 Slovakia - "sl" to "\uD83C\uDDF8\uD83C\uDDEE", // 🇸🇮 Slovenia - "sw" to "\uD83C\uDDF9\uD83C\uDDF5", // 🇹🇿 Tanzania - "gd" to "\uD83C\uDDEC\uD83C\uDDE7", // 🇬🇧 United Kingdom (Scottish Gaelic) - "ceb" to "\uD83C\uDDF5\uD83C\uDDED", // 🇵🇭 Philippines - "so" to "\uD83C\uDDF8\uD83C\uDDF4", // 🇸🇴 Somalia - "tg" to "\uD83C\uDDF9\uD83C\uDDEF", // 🇹🇯 Tajikistan - "te" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Telugu) - "ta" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Tamil) - "th" to "\uD83C\uDDF9\uD83C\uDDED", // 🇹🇭 Thailand - "tr" to "\uD83C\uDDF9\uD83C\uDDF7", // 🇹🇷 Turkey - "cy" to "\uD83C\uDDEC\uD83C\uDDE7", // 🇬🇧 United Kingdom (Welsh) - "ur" to "\uD83C\uDDF5\uD83C\uDDF0", // 🇵🇰 Pakistan - "uk" to "\uD83C\uDDFA\uD83C\uDDE6", // 🇺🇦 Ukraine - "uz" to "\uD83C\uDDFA\uD83C\uDDFF", // 🇺🇿 Uzbekistan - "es" to "\uD83C\uDDEA\uD83C\uDDF8", // 🇪🇸 Spain - "iw" to "\uD83C\uDDEE\uD83C\uDDF1", // 🇮🇱 Israel - "el" to "\uD83C\uDDEC\uD83C\uDDF7", // 🇬🇷 Greece - "haw" to "\uD83C\uDDFA\uD83C\uDDF8", // 🇺🇸 United States (Hawaii) - "sd" to "\uD83C\uDDF5\uD83C\uDDF0", // 🇵🇰 Pakistan (Sindhi) - "hu" to "\uD83C\uDDED\uD83C\uDDFA", // 🇭🇺 Hungary - "sn" to "\uD83C\uDDFF\uD83C\uDDFC", // 🇿🇼 Zimbabwe - "hy" to "\uD83C\uDDE6\uD83C\uDDF2", // 🇦🇲 Armenia - "ig" to "\uD83C\uDDF3\uD83C\uDDEC", // 🇳🇬 Nigeria - "it" to "\uD83C\uDDEE\uD83C\uDDF9", // 🇮🇹 Italy - "yi" to "\uD83C\uDDEE\uD83C\uDDF1", // 🇮🇱 Israel - "hi" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India - "su" to "\uD83C\uDDEE\uD83C\uDDE9", // 🇮🇩 Indonesia - "jv" to "\uD83C\uDDEE\uD83C\uDDE9", // 🇮🇩 Indonesia - "en" to "\uD83C\uDDFA\uD83C\uDDF8", // 🇺🇸 United States - "yo" to "\uD83C\uDDF3\uD83C\uDDEC", // 🇳🇬 Nigeria - "vi" to "\uD83C\uDDFB\uD83C\uDDF3", // 🇻🇳 Vietnam - "as" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Assamese) - "prs" to "\uD83C\uDDE6\uD83C\uDDEB", // 🇦🇫 Afghanistan (Dari) - "fj" to "\uD83C\uDDEB\uD83C\uDDEF", // 🇫🇯 Fiji - "mww" to "\uD83C\uDDE8\uD83C\uDDF3", // 🇨🇳 China (Hmong Daw) - "iu" to "\uD83C\uDDE8\uD83C\uDDE6", // 🇨🇦 Canada (Inuktitut) - "or" to "\uD83C\uDDEE\uD83C\uDDF3", // 🇮🇳 India (Odia) - "otq" to "\uD83C\uDDF2\uD83C\uDDFD", // 🇲🇽 Mexico (Querétaro Otomi) - "ty" to "\uD83C\uDDF5\uD83C\uDDEB", // 🇵🇫 French Polynesia (Tahitian) - "ti" to "\uD83C\uDDEA\uD83C\uDDF7", // 🇪🇷 Eritrea (Tigrinya) - "to" to "\uD83C\uDDF9\uD83C\uDDF4", // 🇹🇴 Tonga - "yua" to "\uD83C\uDDF2\uD83C\uDDFD" // 🇲🇽 Mexico (Yucatec Maya) -) - -private val REGION_CODE_PATTERN = Regex("(?:-|_)(?:r)?([A-Za-z]{2})$") - -val Lang.flagEmoji: String? - get() { - extractRegion(code)?.let { region -> - regionToFlag(region)?.let { return it } - } - - val translation = translationCode - extractRegion(translation)?.let { region -> - regionToFlag(region)?.let { return it } - } - - val normalizedCode = code.lowercase(Locale.US) - LANGUAGE_FLAG_OVERRIDES[normalizedCode]?.let { return it } - - val normalizedTranslation = translation.lowercase(Locale.US) - LANGUAGE_FLAG_OVERRIDES[normalizedTranslation]?.let { return it } - - return null - } - -private fun extractRegion(value: String?): String? { - if (value.isNullOrBlank()) return null - val match = REGION_CODE_PATTERN.find(value) - return match?.groupValues?.getOrNull(1)?.uppercase(Locale.US) -} - -private fun regionToFlag(region: String): String? { - val code = region.uppercase(Locale.US) - if (code.length != 2 || code.any { it !in 'A'..'Z' }) return null - return buildString { - append(Character.toChars(0x1F1E6 + (code[0].code - 'A'.code))) - append(Character.toChars(0x1F1E6 + (code[1].code - 'A'.code))) - } -} diff --git a/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt b/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt index 52e36d6..341d991 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt @@ -18,163 +18,159 @@ package com.airsaid.localization.translate.lang /** + * Enum class for supported languages with Android values directory mapping. + * * @author airsaid */ -// Some language codes and names cannot pass the compiler check -@Suppress("SpellCheckingInspection", "unused") -object Languages { - val AUTO = Lang(0, "auto", "Auto", "Auto") - val ALBANIAN = Lang(1, "sq", "Shqiptar", "Albanian") - val ARABIC = Lang(2, "ar", "العربية", "Arabic") - val AMHARIC = Lang(3, "am", "አማርኛ", "Amharic") - val AZERBAIJANI = Lang(4, "az", "азәрбајҹан", "Azerbaijani") - val IRISH = Lang(5, "ga", "Gaeilge", "Irish") - val ESTONIAN = Lang(6, "et", "Eesti", "Estonian") - val BASQUE = Lang(7, "eu", "Euskal", "Basque") - val BELARUSIAN = Lang(8, "be", "беларускі", "Belarusian") - val BULGARIAN = Lang(9, "bg", "Български", "Bulgarian") - val ICELANDIC = Lang(10, "is", "Íslenska", "Icelandic") - val POLISH = Lang(11, "pl", "Polski", "Polish") - val BOSNIAN = Lang(12, "bs", "Bosanski", "Bosnian") - val PERSIAN = Lang(13, "fa", "Persian", "Persian") - val AFRIKAANS = Lang(14, "af", "Afrikaans", "Afrikaans") - val DANISH = Lang(15, "da", "Dansk", "Danish") - val GERMAN = Lang(16, "de", "Deutsch", "German") - val RUSSIAN = Lang(17, "ru", "Русский", "Russian") - val FRENCH = Lang(18, "fr", "Français", "French") - val FILIPINO = Lang(19, "fil", "Filipino", "Filipino") - val FINNISH = Lang(20, "fi", "Suomi", "Finnish") - val FRISIAN = Lang(21, "fy", "Frysk", "Frisian") - val KHMER = Lang(22, "km", "ខ្មែរ", "Khmer") - val GEORGIAN = Lang(23, "ka", "ქართული", "Georgian") - val GUJARATI = Lang(24, "gu", "ગુજરાતી", "Gujarati") - val KAZAKH = Lang(25, "kk", "Kazakh", "Kazakh") - val HAITIAN_CREOLE = Lang(26, "ht", "Haitian Creole", "Haitian Creole") - val KOREAN = Lang(27, "ko", "한국어", "Korean") - val HAUSA = Lang(28, "ha", "Hausa", "Hausa") - val DUTCH = Lang(29, "nl", "Nederlands", "Dutch") - val KYRGYZ = Lang(30, "ky", "Кыргыз тили", "Kyrgyz") - val GALICIAN = Lang(31, "gl", "Galego", "Galician") - val CATALAN = Lang(32, "ca", "Català", "Catalan") - val CZECH = Lang(33, "cs", "Čeština", "Czech") - val KANNADA = Lang(34, "kn", "ಕನ್ನಡ", "Kannada") - val CORSICAN = Lang(35, "co", "Corsa", "Corsican") - val CROATIAN = Lang(36, "hr", "Hrvatski", "Croatian") - val KURDISH = Lang(37, "ku", "Kurdî", "Kurdish") - val LATIN = Lang(38, "la", "Latina", "Latin") - val LATVIAN = Lang(39, "lv", "Latviešu", "Latvian") - val LAO = Lang(40, "lo", "ລາວ", "Lao") - val LITHUANIAN = Lang(41, "lt", "Lietuvių", "Lithuanian") - val LUXEMBOURGISH = Lang(42, "lb", "Lëtzebuergesch", "Luxembourgish") - val ROMANIAN = Lang(43, "ro", "Română", "Romanian") - val MALAGASY = Lang(44, "mg", "Malagasy", "Malagasy") - val MALTESE = Lang(45, "mt", "Il-Malti", "Maltese") - val MARATHI = Lang(46, "mr", "मराठी", "Marathi") - val MALAYALAM = Lang(47, "ml", "മലയാളം", "Malayalam") - val MALAY = Lang(48, "ms", "Melayu", "Malay") - val MACEDONIAN = Lang(49, "mk", "Македонски", "Macedonian") - val MAORI = Lang(50, "mi", "Māori", "Maori") - val MONGOLIAN = Lang(51, "mn", "Монгол хэл", "Mongolian") - val BANGLA = Lang(52, "bn", "বাংল", "Bangla") - val BURMESE = Lang(53, "my", "မြန်မာ", "Burmese") - val HMONG = Lang(54, "hmn", "Hmoob", "Hmong") - val XHOSA = Lang(55, "xh", "IsiXhosa", "Xhosa") - val ZULU = Lang(56, "zu", "Zulu", "Zulu") - val NEPALI = Lang(57, "ne", "नेपाली", "Nepali") - val NORWEGIAN = Lang(58, "no", "Norsk", "Norwegian") - val PUNJABI = Lang(59, "pa", "ਪੰਜਾਬੀ", "Punjabi") - val PORTUGUESE = Lang(60, "pt", "Português", "Portuguese") - val PASHTO = Lang(61, "ps", "Pashto", "Pashto") - val CHICHEWA = Lang(62, "ny", "Chichewa", "Chichewa") - val JAPANESE = Lang(63, "ja", "日本語", "Japanese") - val SWEDISH = Lang(64, "sv", "Svenska", "Swedish") - val SAMOAN = Lang(65, "sm", "Samoa", "Samoan") - val SERBIAN = Lang(66, "sr", "Српски", "Serbian") - val SOTHO = Lang(67, "st", "Sesotho", "Sotho") - val SINHALA = Lang(68, "si", "සිංහල", "Sinhala") - val ESPERANTO = Lang(69, "eo", "Esperanta", "Esperanto") - val SLOVAK = Lang(70, "sk", "Slovenčina", "Slovak") - val SLOVENIAN = Lang(71, "sl", "Slovenščina", "Slovenian") - val SWAHILI = Lang(72, "sw", "Kiswahili", "Swahili") - val SCOTTISH_GAELIC = Lang(73, "gd", "Gàidhlig na h-Alba", "Scottish Gaelic") - val CEBUANO = Lang(74, "ceb", "Cebuano", "Cebuano") - val SOMALI = Lang(75, "so", "Somali", "Somali") - val TAJIK = Lang(76, "tg", "Тоҷикӣ", "Tajik") - val TELUGU = Lang(77, "te", "తెలుగు", "Telugu") - val TAMIL = Lang(78, "ta", "தமிழ்", "Tamil") - val THAI = Lang(79, "th", "ไทย", "Thai") - val TURKISH = Lang(80, "tr", "Türkçe", "Turkish") - val WELSH = Lang(81, "cy", "Cymraeg", "Welsh") - val URDU = Lang(82, "ur", "اردو", "Urdu") - val UKRAINIAN = Lang(83, "uk", "Українська", "Ukrainian") - val UZBEK = Lang(84, "uz", "O'zbek", "Uzbek") - val SPANISH = Lang(85, "es", "Español", "Spanish") - val HEBREW = Lang(86, "iw", "עברית", "Hebrew") - val GREEK = Lang(87, "el", "Ελληνικά", "Greek") - val HAWAIIAN = Lang(88, "haw", "Hawaiian", "Hawaiian") - val SINDHI = Lang(89, "sd", "سنڌي", "Sindhi") - val HUNGARIAN = Lang(90, "hu", "Magyar", "Hungarian") - val SHONA = Lang(91, "sn", "Shona", "Shona") - val ARMENIAN = Lang(92, "hy", "Հայերեն", "Armenian") - val IGBO = Lang(93, "ig", "Igbo", "Igbo") - val ITALIAN = Lang(94, "it", "Italiano", "Italian") - val YIDDISH = Lang(95, "yi", "ייִדיש", "Yiddish") - val HINDI = Lang(96, "hi", "हिंदी", "Hindi") - val SUNDANESE = Lang(97, "su", "Sunda", "Sundanese") - val INDONESIAN = Lang(98, "in-rID", "Indonesia", "Indonesian") - val JAVANESE = Lang(99, "jv", "Wong Jawa", "Javanese") - val ENGLISH = Lang(100, "en", "English", "English") - val YORUBA = Lang(101, "yo", "Yorùbá", "Yoruba") - val VIETNAMESE = Lang(102, "vi", "Tiếng Việt", "Vietnamese") - val CHINESE_TRADITIONAL = Lang(103, "zh-rTW", "正體中文", "Chinese Traditional") - val CHINESE_SIMPLIFIED = Lang(104, "zh-rCN", "简体中文", "Chinese Simplified") - val ASSAMESE = Lang(105, "as", "Assamese", "Assamese") - val DARI = Lang(106, "prs", "Dari", "Dari") - val FIJIAN = Lang(107, "fj", "Fijian", "Fijian") - val HMONG_DAW = Lang(108, "mww", "Hmong Daw", "Hmong Daw") - val INUKTITUT = Lang(109, "iu", "ᐃᓄᒃᑎᑐᑦ", "Inuktitut") - val KLINGON_LATIN = Lang(110, "tlh-Latn", "Klingon (Latin)", "Klingon (Latin)") - val KLINGON_PIQAD = Lang(111, "tlh-Piqd", "Klingon (pIqaD)", "Klingon (pIqaD)") - val ODIA = Lang(112, "or", "Odia", "Odia") - val QUERETARO_OTOMI = Lang(113, "otq", "Querétaro Otomi", "Querétaro Otomi") - val TAHITIAN = Lang(114, "ty", "Tahitian", "Tahitian") - val TIGRINYA = Lang(115, "ti", "ትግርኛ", "Tigrinya") - val TONGAN = Lang(116, "to", "lea fakatonga", "Tongan") - val YUCATEC_MAYA = Lang(117, "yua", "Yucatec Maya", "Yucatec Maya") +enum class Languages( + val id: Int, + val code: String, + val displayName: String, + val englishName: String, + val flag: String, + val directoryName: String, +) { + AUTO(0, "auto", "Auto", "Auto", "🌏", ""), + ALBANIAN(1, "sq", "Shqiptar", "Albanian", "🇦🇱", "sq"), + ARABIC(2, "ar", "العربية", "Arabic", "🇸🇦", "ar"), + AMHARIC(3, "am", "አማርኛ", "Amharic", "🇪🇹", "am"), + AZERBAIJANI(4, "az", "азәрбајҹан", "Azerbaijani", "🇦🇿", "az"), + IRISH(5, "ga", "Gaeilge", "Irish", "🇮🇪", "ga"), + ESTONIAN(6, "et", "Eesti", "Estonian", "🇪🇪", "et"), + BASQUE(7, "eu", "Euskal", "Basque", "🇪🇸", "eu"), + BELARUSIAN(8, "be", "беларускі", "Belarusian", "🇧🇾", "be"), + BULGARIAN(9, "bg", "Български", "Bulgarian", "🇧🇬", "bg"), + ICELANDIC(10, "is", "Íslenska", "Icelandic", "🇮🇸", "is"), + POLISH(11, "pl", "Polski", "Polish", "🇵🇱", "pl"), + BOSNIAN(12, "bs", "Bosanski", "Bosnian", "🇧🇦", "bs"), + PERSIAN(13, "fa", "Persian", "Persian", "🇮🇷", "fa"), + AFRIKAANS(14, "af", "Afrikaans", "Afrikaans", "🇿🇦", "af"), + DANISH(15, "da", "Dansk", "Danish", "🇩🇰", "da"), + GERMAN(16, "de", "Deutsch", "German", "🇩🇪", "de"), + RUSSIAN(17, "ru", "Русский", "Russian", "🇷🇺", "ru"), + FRENCH(18, "fr", "Français", "French", "🇫🇷", "fr"), + FILIPINO(19, "fil", "Filipino", "Filipino", "🇵🇭", "fil"), + FINNISH(20, "fi", "Suomi", "Finnish", "🇫🇮", "fi"), + FRISIAN(21, "fy", "Frysk", "Frisian", "🇳🇱", "fy"), + KHMER(22, "km", "ខ្មែរ", "Khmer", "🇰🇭", "km"), + GEORGIAN(23, "ka", "ქართული", "Georgian", "🇬🇪", "ka"), + GUJARATI(24, "gu", "ગુજરાતી", "Gujarati", "🇮🇳", "gu"), + KAZAKH(25, "kk", "Kazakh", "Kazakh", "🇰🇿", "kk"), + HAITIAN_CREOLE(26, "ht", "Haitian Creole", "Haitian Creole", "🇭🇹", "ht"), + KOREAN(27, "ko", "한국어", "Korean", "🇰🇷", "ko"), + HAUSA(28, "ha", "Hausa", "Hausa", "🇳🇬", "ha"), + DUTCH(29, "nl", "Nederlands", "Dutch", "🇳🇱", "nl"), + KYRGYZ(30, "ky", "Кыргыз тили", "Kyrgyz", "🇰🇬", "ky"), + GALICIAN(31, "gl", "Galego", "Galician", "🇪🇸", "gl"), + CATALAN(32, "ca", "Català", "Catalan", "🇪🇸", "ca"), + CZECH(33, "cs", "Čeština", "Czech", "🇨🇿", "cs"), + KANNADA(34, "kn", "ಕನ್ನಡ", "Kannada", "🇮🇳", "kn"), + CORSICAN(35, "co", "Corsa", "Corsican", "🇫🇷", "co"), + CROATIAN(36, "hr", "Hrvatski", "Croatian", "🇭🇷", "hr"), + KURDISH(37, "ku", "Kurdî", "Kurdish", "🇮🇶", "ku"), + LATIN(38, "la", "Latina", "Latin", "🇻🇦", "la"), + LATVIAN(39, "lv", "Latviešu", "Latvian", "🇱🇻", "lv"), + LAO(40, "lo", "ລາວ", "Lao", "🇱🇦", "lo"), + LITHUANIAN(41, "lt", "Lietuvių", "Lithuanian", "🇱🇹", "lt"), + LUXEMBOURGISH(42, "lb", "Lëtzebuergesch", "Luxembourgish", "🇱🇺", "lb"), + ROMANIAN(43, "ro", "Română", "Romanian", "🇷🇴", "ro"), + MALAGASY(44, "mg", "Malagasy", "Malagasy", "🇲🇬", "mg"), + MALTESE(45, "mt", "Il-Malti", "Maltese", "🇲🇹", "mt"), + MARATHI(46, "mr", "मराठी", "Marathi", "🇮🇳", "mr"), + MALAYALAM(47, "ml", "മലയാളം", "Malayalam", "🇮🇳", "ml"), + MALAY(48, "ms", "Melayu", "Malay", "🇲🇾", "ms"), + MACEDONIAN(49, "mk", "Македонски", "Macedonian", "🇲🇰", "mk"), + MAORI(50, "mi", "Māori", "Maori", "🇳🇿", "mi"), + MONGOLIAN(51, "mn", "Монгол хэл", "Mongolian", "🇲🇳", "mn"), + BANGLA(52, "bn", "বাংল", "Bangla", "🇧🇩", "bn"), + BURMESE(53, "my", "မြန်မာ", "Burmese", "🇲🇲", "my"), + HMONG(54, "hmn", "Hmoob", "Hmong", "🇨🇳", "hmn"), + XHOSA(55, "xh", "IsiXhosa", "Xhosa", "🇿🇦", "xh"), + ZULU(56, "zu", "Zulu", "Zulu", "🇿🇦", "zu"), + NEPALI(57, "ne", "नेपाली", "Nepali", "🇳🇵", "ne"), + NORWEGIAN(58, "no", "Norsk", "Norwegian", "🇳🇴", "no"), + PUNJABI(59, "pa", "ਪੰਜਾਬੀ", "Punjabi", "🇮🇳", "pa"), + PORTUGUESE(60, "pt", "Português", "Portuguese", "🇵🇹", "pt"), + PASHTO(61, "ps", "Pashto", "Pashto", "🇦🇫", "ps"), + CHICHEWA(62, "ny", "Chichewa", "Chichewa", "🇲🇼", "ny"), + JAPANESE(63, "ja", "日本語", "Japanese", "🇯🇵", "ja"), + SWEDISH(64, "sv", "Svenska", "Swedish", "🇸🇪", "sv"), + SAMOAN(65, "sm", "Samoa", "Samoan", "🇼🇸", "sm"), + SERBIAN(66, "sr", "Српски", "Serbian", "🇷🇸", "sr"), + SOTHO(67, "st", "Sesotho", "Sotho", "🇱🇸", "st"), + SINHALA(68, "si", "සිංහල", "Sinhala", "🇱🇰", "si"), + ESPERANTO(69, "eo", "Esperanta", "Esperanto", "🇺🇳", "eo"), + SLOVAK(70, "sk", "Slovenčina", "Slovak", "🇸🇰", "sk"), + SLOVENIAN(71, "sl", "Slovenščina", "Slovenian", "🇸🇮", "sl"), + SWAHILI(72, "sw", "Kiswahili", "Swahili", "🇹🇿", "sw"), + SCOTTISH_GAELIC(73, "gd", "Gàidhlig na h-Alba", "Scottish Gaelic", "🇬🇧", "gd"), + CEBUANO(74, "ceb", "Cebuano", "Cebuano", "🇵🇭", "ceb"), + SOMALI(75, "so", "Somali", "Somali", "🇸🇴", "so"), + TAJIK(76, "tg", "Тоҷикӣ", "Tajik", "🇹🇯", "tg"), + TELUGU(77, "te", "తెలుగు", "Telugu", "🇮🇳", "te"), + TAMIL(78, "ta", "தமிழ்", "Tamil", "🇮🇳", "ta"), + THAI(79, "th", "ไทย", "Thai", "🇹🇭", "th"), + TURKISH(80, "tr", "Türkçe", "Turkish", "🇹🇷", "tr"), + WELSH(81, "cy", "Cymraeg", "Welsh", "🇬🇧", "cy"), + URDU(82, "ur", "اردو", "Urdu", "🇵🇰", "ur"), + UKRAINIAN(83, "uk", "Українська", "Ukrainian", "🇺🇦", "uk"), + UZBEK(84, "uz", "O'zbek", "Uzbek", "🇺🇿", "uz"), + SPANISH(85, "es", "Español", "Spanish", "🇪🇸", "es"), + HEBREW(86, "iw", "עברית", "Hebrew", "🇮🇱", "iw"), + GREEK(87, "el", "Ελληνικά", "Greek", "🇬🇷", "el"), + HAWAIIAN(88, "haw", "Hawaiian", "Hawaiian", "🇺🇸", "haw"), + SINDHI(89, "sd", "سنڌي", "Sindhi", "🇵🇰", "sd"), + HUNGARIAN(90, "hu", "Magyar", "Hungarian", "🇭🇺", "hu"), + SHONA(91, "sn", "Shona", "Shona", "🇿🇼", "sn"), + ARMENIAN(92, "hy", "Հայերեն", "Armenian", "🇦🇲", "hy"), + IGBO(93, "ig", "Igbo", "Igbo", "🇳🇬", "ig"), + ITALIAN(94, "it", "Italiano", "Italian", "🇮🇹", "it"), + YIDDISH(95, "yi", "ייִדיש", "Yiddish", "🇮🇱", "yi"), + HINDI(96, "hi", "हिंदी", "Hindi", "🇮🇳", "hi"), + SUNDANESE(97, "su", "Sunda", "Sundanese", "🇮🇩", "su"), + INDONESIAN(98, "id", "Indonesia", "Indonesian", "🇮🇩", "in"), + JAVANESE(99, "jv", "Wong Jawa", "Javanese", "🇮🇩", "jv"), + ENGLISH(100, "en", "English", "English", "🇺🇸", "en"), + YORUBA(101, "yo", "Yorùbá", "Yoruba", "🇳🇬", "yo"), + VIETNAMESE(102, "vi", "Tiếng Việt", "Vietnamese", "🇻🇳", "vi"), + CHINESE_TRADITIONAL(103, "zh-rTW", "正體中文", "Chinese Traditional", "🇨🇳", "zh-rTW"), + CHINESE_SIMPLIFIED(104, "zh-rCN", "简体中文", "Chinese Simplified", "🇨🇳", "zh-rCN"), + ASSAMESE(105, "as", "Assamese", "Assamese", "🇮🇳", "as"), + DARI(106, "prs", "Dari", "Dari", "🇦🇫", "prs"), + FIJIAN(107, "fj", "Fijian", "Fijian", "🇫🇯", "fj"), + HMONG_DAW(108, "mww", "Hmong Daw", "Hmong Daw", "🇨🇳", "mww"), + INUKTITUT(109, "iu", "ᐃᓄᒃᑎᑐᑦ", "Inuktitut", "🇨🇦", "iu"), + ODIA(112, "or", "Odia", "Odia", "🇮🇳", "or"), + QUERETARO_OTOMI(113, "otq", "Querétaro Otomi", "Querétaro Otomi", "🇲🇽", "otq"), + TAHITIAN(114, "ty", "Tahitian", "Tahitian", "🇵🇫", "ty"), + TIGRINYA(115, "ti", "ትግርኛ", "Tigrinya", "🇪🇷", "ti"), + TONGAN(116, "to", "lea fakatonga", "Tongan", "🇹🇴", "to"), + YUCATEC_MAYA(117, "yua", "Yucatec Maya", "Yucatec Maya", "🇲🇽", "yua"); - private val languages: Map = mapOf( - 0 to AUTO, 1 to ALBANIAN, 2 to ARABIC, 3 to AMHARIC, 4 to AZERBAIJANI, - 5 to IRISH, 6 to ESTONIAN, 7 to BASQUE, 8 to BELARUSIAN, 9 to BULGARIAN, - 10 to ICELANDIC, 11 to POLISH, 12 to BOSNIAN, 13 to PERSIAN, 14 to AFRIKAANS, - 15 to DANISH, 16 to GERMAN, 17 to RUSSIAN, 18 to FRENCH, 19 to FILIPINO, - 20 to FINNISH, 21 to FRISIAN, 22 to KHMER, 23 to GEORGIAN, 24 to GUJARATI, - 25 to KAZAKH, 26 to HAITIAN_CREOLE, 27 to KOREAN, 28 to HAUSA, 29 to DUTCH, - 30 to KYRGYZ, 31 to GALICIAN, 32 to CATALAN, 33 to CZECH, 34 to KANNADA, - 35 to CORSICAN, 36 to CROATIAN, 37 to KURDISH, 38 to LATIN, 39 to LATVIAN, - 40 to LAO, 41 to LITHUANIAN, 42 to LUXEMBOURGISH, 43 to ROMANIAN, 44 to MALAGASY, - 45 to MALTESE, 46 to MARATHI, 47 to MALAYALAM, 48 to MALAY, 49 to MACEDONIAN, - 50 to MAORI, 51 to MONGOLIAN, 52 to BANGLA, 53 to BURMESE, 54 to HMONG, - 55 to XHOSA, 56 to ZULU, 57 to NEPALI, 58 to NORWEGIAN, 59 to PUNJABI, - 60 to PORTUGUESE, 61 to PASHTO, 62 to CHICHEWA, 63 to JAPANESE, 64 to SWEDISH, - 65 to SAMOAN, 66 to SERBIAN, 67 to SOTHO, 68 to SINHALA, 69 to ESPERANTO, - 70 to SLOVAK, 71 to SLOVENIAN, 72 to SWAHILI, 73 to SCOTTISH_GAELIC, 74 to CEBUANO, - 75 to SOMALI, 76 to TAJIK, 77 to TELUGU, 78 to TAMIL, 79 to THAI, - 80 to TURKISH, 81 to WELSH, 82 to URDU, 83 to UKRAINIAN, 84 to UZBEK, - 85 to SPANISH, 86 to HEBREW, 87 to GREEK, 88 to HAWAIIAN, 89 to SINDHI, - 90 to HUNGARIAN, 91 to SHONA, 92 to ARMENIAN, 93 to IGBO, 94 to ITALIAN, - 95 to YIDDISH, 96 to HINDI, 97 to SUNDANESE, 98 to INDONESIAN, 99 to JAVANESE, - 100 to ENGLISH, 101 to YORUBA, 102 to VIETNAMESE, 103 to CHINESE_TRADITIONAL, - 104 to CHINESE_SIMPLIFIED, 105 to ASSAMESE, 106 to DARI, 107 to FIJIAN, - 108 to HMONG_DAW, 109 to INUKTITUT, 110 to KLINGON_LATIN, 111 to KLINGON_PIQAD, - 112 to ODIA, 113 to QUERETARO_OTOMI, 114 to TAHITIAN, 115 to TIGRINYA, - 116 to TONGAN, 117 to YUCATEC_MAYA - ) + companion object { + fun languages(): List { + return Languages.entries.map { it.toLang() } + } - fun getLanguages(): List { - return ArrayList(languages.values) + fun allSupportedLanguages(): List { + return Languages.entries.filter { it != AUTO }.map { it.toLang() } + } } +} - fun getAllSupportedLanguages(): List { - return getLanguages().filter { it != AUTO } - } -} \ No newline at end of file +fun Languages.toLang(): Lang { + return Lang( + id = this.id, + code = this.code, + name = this.displayName, + englishName = this.englishName, + flag = this.flag, + directoryName = this.directoryName + ) +} + +val Lang.flagEmoji: String? + get() = flag.takeIf { it.isNotBlank() } + +val Lang.valuesDirectoryQualifier: String? + get() = directoryName.takeIf { it.isNotBlank() } diff --git a/src/main/kotlin/com/airsaid/localization/translate/services/TranslationCacheService.kt b/src/main/kotlin/com/airsaid/localization/translate/services/TranslationCacheService.kt index b7c2ed4..91a971d 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/services/TranslationCacheService.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/services/TranslationCacheService.kt @@ -37,62 +37,62 @@ import java.lang.reflect.Type * @author airsaid */ @State( - name = "com.airsaid.localization.translate.services.TranslationCacheService", - storages = [Storage("androidLocalizeTranslationCaches.xml")] + name = "com.airsaid.localization.translate.services.TranslationCacheService", + storages = [Storage("androidLocalizeTranslationCaches.xml")] ) @Service class TranslationCacheService : PersistentStateComponent, Disposable { - @Transient - private val lruCache = LRUCache(CACHE_MAX_SIZE) + @Transient + private val lruCache = LRUCache(CACHE_MAX_SIZE) - @OptionTag(converter = LruCacheConverter::class) - fun getLruCache(): LRUCache = lruCache + @OptionTag(converter = LruCacheConverter::class) + fun getLruCache(): LRUCache = lruCache - fun put(key: String, value: String) { - lruCache.put(key, value) - } + fun put(key: String, value: String) { + lruCache.put(key, value) + } - fun get(key: String?): String { - val value = lruCache.get(key) - return value ?: "" - } + fun get(key: String?): String { + val value = lruCache.get(key) + return value ?: "" + } - fun setMaxCacheSize(maxCacheSize: Int) { - lruCache.setMaxCapacity(maxCacheSize) - } + fun setMaxCacheSize(maxCacheSize: Int) { + lruCache.setMaxCapacity(maxCacheSize) + } - override fun getState(): TranslationCacheService = this + override fun getState(): TranslationCacheService = this - override fun loadState(state: TranslationCacheService) { - XmlSerializerUtil.copyBean(state, this) - } + override fun loadState(state: TranslationCacheService) { + XmlSerializerUtil.copyBean(state, this) + } - override fun dispose() { - lruCache.clear() - } + override fun dispose() { + lruCache.clear() + } - class LruCacheConverter : Converter>() { - override fun fromString(value: String): LRUCache? { - val type: Type = object : TypeToken>() {}.type - val map: Map = GsonUtil.getInstance().gson.fromJson(value, type) - val lruCache = LRUCache(CACHE_MAX_SIZE) - for ((key, value1) in map) { - lruCache.put(key, value1) - } - return lruCache - } + class LruCacheConverter : Converter>() { + override fun fromString(value: String): LRUCache? { + val type: Type = object : TypeToken>() {}.type + val map: Map = GsonUtil.getInstance().gson.fromJson(value, type) + val lruCache = LRUCache(CACHE_MAX_SIZE) + for ((key, value1) in map) { + lruCache.put(key, value1) + } + return lruCache + } - override fun toString(lruCache: LRUCache): String? { - val values = linkedMapOf() - lruCache.forEach { key, value -> values[key] = value } - return GsonUtil.getInstance().gson.toJson(values) - } + override fun toString(lruCache: LRUCache): String? { + val values = linkedMapOf() + lruCache.forEach { key, value -> values[key] = value } + return GsonUtil.getInstance().gson.toJson(values) } + } - companion object { - private const val CACHE_MAX_SIZE = 500 + companion object { + private const val CACHE_MAX_SIZE = 500 - fun getInstance(): TranslationCacheService = service() - } + fun getInstance(): TranslationCacheService = service() + } } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt b/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt index 238a11d..b86d94e 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt @@ -35,132 +35,133 @@ import kotlin.jvm.Volatile @Service class TranslatorService { - interface TranslationInterceptor { - fun process(text: String?): String? + interface TranslationInterceptor { + fun process(text: String?): String? + } + + private val defaultTranslator: AbstractTranslator + private val cacheService: TranslationCacheService + private val translators: Map + private val translationInterceptors: MutableList + private var isEnableCache = true + private var intervalTime = 0 + + @Volatile + private var selectedTranslator: AbstractTranslator + var maxCacheSize: Int = 1000 + set(value) { + field = value + cacheService.setMaxCacheSize(value) + } + var translationInterval: Int = 0 + set(value) { + field = value + intervalTime = value } - private val defaultTranslator: AbstractTranslator - private val cacheService: TranslationCacheService - private val translators: Map - private val translationInterceptors: MutableList - private var isEnableCache = true - private var intervalTime = 0 - @Volatile - private var selectedTranslator: AbstractTranslator - var maxCacheSize: Int = 1000 - set(value) { - field = value - cacheService.setMaxCacheSize(value) - } - var translationInterval: Int = 0 - set(value) { - field = value - intervalTime = value - } - - init { - val translatorsMap = linkedMapOf() - val serviceLoader = ServiceLoader.load( - AbstractTranslator::class.java, javaClass.classLoader - ) - for (translator in serviceLoader) { - translatorsMap[translator.key] = translator - } - if (translatorsMap.isEmpty()) { - LOG.error("No translators were registered. Translation functionality will be unavailable.") - throw IllegalStateException("No translators registered") - } - translators = translatorsMap - - defaultTranslator = selectDefaultTranslator(translatorsMap) - selectedTranslator = defaultTranslator - - cacheService = TranslationCacheService.getInstance() - - translationInterceptors = mutableListOf() - translationInterceptors.add(EscapeCharactersInterceptor()) + init { + val translatorsMap = linkedMapOf() + val serviceLoader = ServiceLoader.load( + AbstractTranslator::class.java, javaClass.classLoader + ) + for (translator in serviceLoader) { + translatorsMap[translator.key] = translator + } + if (translatorsMap.isEmpty()) { + LOG.error("No translators were registered. Translation functionality will be unavailable.") + throw IllegalStateException("No translators registered") } + translators = translatorsMap + + defaultTranslator = selectDefaultTranslator(translatorsMap) + selectedTranslator = defaultTranslator + + cacheService = TranslationCacheService.getInstance() + + translationInterceptors = mutableListOf() + translationInterceptors.add(EscapeCharactersInterceptor()) + } - fun getDefaultTranslator(): AbstractTranslator = defaultTranslator + fun getDefaultTranslator(): AbstractTranslator = defaultTranslator - fun getTranslators(): Map = translators + fun getTranslators(): Map = translators - fun setSelectedTranslator(selectedTranslator: AbstractTranslator) { - if (this.selectedTranslator != selectedTranslator) { - LOG.info("setTranslator: $selectedTranslator") - this.selectedTranslator = selectedTranslator - } + fun setSelectedTranslator(selectedTranslator: AbstractTranslator) { + if (this.selectedTranslator != selectedTranslator) { + LOG.info("setTranslator: $selectedTranslator") + this.selectedTranslator = selectedTranslator } + } - fun getSelectedTranslator(): AbstractTranslator = selectedTranslator + fun getSelectedTranslator(): AbstractTranslator = selectedTranslator - fun doTranslateByAsync(fromLang: Lang, toLang: Lang, text: String, consumer: Consumer) { - ApplicationManager.getApplication().executeOnPooledThread { - val translatedText = doTranslate(fromLang, toLang, text) - ApplicationManager.getApplication().invokeLater { - consumer.accept(translatedText) - } - } + fun doTranslateByAsync(fromLang: Lang, toLang: Lang, text: String, consumer: Consumer) { + ApplicationManager.getApplication().executeOnPooledThread { + val translatedText = doTranslate(fromLang, toLang, text) + ApplicationManager.getApplication().invokeLater { + consumer.accept(translatedText) + } } + } - fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { - LOG.info("doTranslate fromLang: $fromLang, toLang: $toLang, text: $text") - - if (isEnableCache) { - val cacheResult = cacheService.get(getCacheKey(fromLang, toLang, text)) - if (cacheResult.isNotEmpty()) { - LOG.info("doTranslate cache result: $cacheResult") - return cacheResult - } - } - - // Arabic numbers skip translation - if (StringUtils.isNumeric(text)) { - return text - } - - val translator = selectedTranslator - var result = translator.doTranslate(fromLang, toLang, text) - LOG.info("doTranslate result: $result") - for (interceptor in translationInterceptors) { - result = interceptor.process(result) ?: result - LOG.info("doTranslate interceptor process result: $result") - } - cacheService.put(getCacheKey(fromLang, toLang, text), result) - delay(intervalTime) - return result + fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { + LOG.info("doTranslate fromLang: $fromLang, toLang: $toLang, text: $text") + + if (isEnableCache) { + val cacheResult = cacheService.get(getCacheKey(fromLang, toLang, text)) + if (cacheResult.isNotEmpty()) { + LOG.info("doTranslate cache result: $cacheResult") + return cacheResult + } } - fun setEnableCache(isEnableCache: Boolean) { - this.isEnableCache = isEnableCache + // Arabic numbers skip translation + if (StringUtils.isNumeric(text)) { + return text } - fun isEnableCache(): Boolean = isEnableCache + val translator = selectedTranslator + var result = translator.doTranslate(fromLang, toLang, text) + LOG.info("doTranslate result: $result") + for (interceptor in translationInterceptors) { + result = interceptor.process(result) ?: result + LOG.info("doTranslate interceptor process result: $result") + } + cacheService.put(getCacheKey(fromLang, toLang, text), result) + delay(intervalTime) + return result + } + fun setEnableCache(isEnableCache: Boolean) { + this.isEnableCache = isEnableCache + } - private fun getCacheKey(fromLang: Lang, toLang: Lang, text: String): String { - return "${fromLang.code}_${toLang.code}_$text" - } + fun isEnableCache(): Boolean = isEnableCache + + + private fun getCacheKey(fromLang: Lang, toLang: Lang, text: String): String { + return "${fromLang.code}_${toLang.code}_$text" + } - private fun delay(milliseconds: Int) { - if (milliseconds <= 0) return - try { - LOG.info("doTranslate delay time: ${milliseconds} ms.") - Thread.sleep(milliseconds.toLong()) - } catch (e: InterruptedException) { - e.printStackTrace() - } + private fun delay(milliseconds: Int) { + if (milliseconds <= 0) return + try { + LOG.info("doTranslate delay time: ${milliseconds} ms.") + Thread.sleep(milliseconds.toLong()) + } catch (e: InterruptedException) { + e.printStackTrace() } + } - companion object { - private val LOG = Logger.getInstance(TranslatorService::class.java) + companion object { + private val LOG = Logger.getInstance(TranslatorService::class.java) - fun getInstance(): TranslatorService = service() + fun getInstance(): TranslatorService = service() - internal fun selectDefaultTranslator(translators: Map): AbstractTranslator { - val preferred = translators["Microsoft"] ?: translators.values.first() - LOG.info("Selected ${preferred.key} as default translator.") - return preferred - } + internal fun selectDefaultTranslator(translators: Map): AbstractTranslator { + val preferred = translators["Microsoft"] ?: translators.values.first() + LOG.info("Selected ${preferred.key} as default translator.") + return preferred } + } } diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt index 3498e15..9976736 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -16,47 +16,19 @@ package com.airsaid.localization.ui -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.TooltipArea -import androidx.compose.foundation.background -import androidx.compose.foundation.border +import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Info -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -64,8 +36,8 @@ import androidx.compose.ui.awt.ComposePanel import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.toComposeImageBitmap -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.airsaid.localization.constant.Constants @@ -73,8 +45,8 @@ import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.flagEmoji import com.airsaid.localization.translate.services.TranslatorService -import com.airsaid.localization.utils.LanguageUtil import com.airsaid.localization.ui.components.IdeCheckbox +import com.airsaid.localization.utils.LanguageUtil import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper @@ -88,473 +60,475 @@ import kotlin.math.roundToInt */ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(project, false) { - interface OnClickListener { - fun onClickListener(selectedLanguage: List) - } - - private val translatorService = TranslatorService.getInstance() - private val selectedLanguages = mutableStateListOf() - private val selectAllState = mutableStateOf(false) - private val overwriteExistingState = mutableStateOf(false) - private val openTranslatedFileState = mutableStateOf(false) - - private var onClickListener: OnClickListener? = null - - private lateinit var translator: AbstractTranslator - private lateinit var supportedLanguages: List - - init { - initState() - title = "Select Translated Languages" - init() - } - - fun setOnClickListener(listener: OnClickListener) { - onClickListener = listener - } - - override fun createCenterPanel(): JComponent { - val panel = ComposePanel() - val (preferredSize, minimumSize) = calculateDialogSize() - panel.preferredSize = preferredSize - panel.minimumSize = minimumSize - panel.setContent { - IdeTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - SelectLanguagesContent( - translator = translator, - supportedLanguages = supportedLanguages, - selectedLanguages = selectedLanguages, - selectAllStateChecked = selectAllState.value, - overwriteExistingChecked = overwriteExistingState.value, - openTranslatedFileChecked = openTranslatedFileState.value, - onSelectAllChanged = { handleSelectAll(it) }, - onOverwriteChanged = { checked -> - overwriteExistingState.value = checked - }, - onOpenTranslatedFileChanged = { checked -> - openTranslatedFileState.value = checked - }, - onLanguageToggled = { lang, checked -> - if (checked) { - if (!selectedLanguages.contains(lang)) { - selectedLanguages.add(lang) - } - } else { - selectedLanguages.remove(lang) - } - - val allSelected = selectedLanguages.size == supportedLanguages.size && supportedLanguages.isNotEmpty() - if (selectAllState.value != allSelected) { - selectAllState.value = allSelected - } - - okAction.isEnabled = selectedLanguages.isNotEmpty() - }, - ) + interface OnClickListener { + fun onClickListener(selectedLanguage: List) + } + + private val translatorService = TranslatorService.getInstance() + private val selectedLanguages = mutableStateListOf() + private val selectAllState = mutableStateOf(false) + private val overwriteExistingState = mutableStateOf(false) + private val openTranslatedFileState = mutableStateOf(false) + + private var onClickListener: OnClickListener? = null + + private lateinit var translator: AbstractTranslator + private lateinit var supportedLanguages: List + + init { + initState() + title = "Select Translated Languages" + init() + } + + fun setOnClickListener(listener: OnClickListener) { + onClickListener = listener + } + + override fun createCenterPanel(): JComponent { + val panel = ComposePanel() + val (preferredSize, minimumSize) = calculateDialogSize() + panel.preferredSize = preferredSize + panel.minimumSize = minimumSize + panel.setContent { + IdeTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + SelectLanguagesContent( + translator = translator, + supportedLanguages = supportedLanguages, + selectedLanguages = selectedLanguages, + selectAllStateChecked = selectAllState.value, + overwriteExistingChecked = overwriteExistingState.value, + openTranslatedFileChecked = openTranslatedFileState.value, + onSelectAllChanged = { handleSelectAll(it) }, + onOverwriteChanged = { checked -> + overwriteExistingState.value = checked + }, + onOpenTranslatedFileChanged = { checked -> + openTranslatedFileState.value = checked + }, + onLanguageToggled = { lang, checked -> + if (checked) { + if (!selectedLanguages.contains(lang)) { + selectedLanguages.add(lang) } - } + } else { + selectedLanguages.remove(lang) + } + + val allSelected = selectedLanguages.size == supportedLanguages.size && supportedLanguages.isNotEmpty() + if (selectAllState.value != allSelected) { + selectAllState.value = allSelected + } + + okAction.isEnabled = selectedLanguages.isNotEmpty() + }, + ) } - return panel + } } - - override fun doOKAction() { - project?.let { LanguageUtil.saveSelectedLanguage(it, selectedLanguages) } - properties().setValue(Constants.KEY_IS_SELECT_ALL, selectAllState.value) - properties().setValue(Constants.KEY_IS_OVERWRITE_EXISTING_STRING, overwriteExistingState.value) - properties().setValue(Constants.KEY_IS_OPEN_TRANSLATED_FILE, openTranslatedFileState.value) - onClickListener?.onClickListener(selectedLanguages.toList()) - super.doOKAction() + return panel + } + + override fun doOKAction() { + project?.let { LanguageUtil.saveSelectedLanguage(it, selectedLanguages) } + properties().setValue(Constants.KEY_IS_SELECT_ALL, selectAllState.value) + properties().setValue(Constants.KEY_IS_OVERWRITE_EXISTING_STRING, overwriteExistingState.value) + properties().setValue(Constants.KEY_IS_OPEN_TRANSLATED_FILE, openTranslatedFileState.value) + onClickListener?.onClickListener(selectedLanguages.toList()) + super.doOKAction() + } + + override fun getDimensionServiceKey(): String? { + val key = translator.key + return "#com.airsaid.localization.ui.SelectLanguagesDialog#$key" + } + + private fun initState() { + val properties = properties() + translator = translatorService.getSelectedTranslator() + supportedLanguages = translator.supportedLanguages.sortedBy { it.englishName } + + val savedLanguageIds = LanguageUtil.getSelectedLanguageIds(project) + selectedLanguages.clear() + if (!savedLanguageIds.isNullOrEmpty()) { + selectedLanguages.addAll(supportedLanguages.filter { savedLanguageIds.contains(it.id.toString()) }) } - override fun getDimensionServiceKey(): String? { - val key = translator.key - return "#com.airsaid.localization.ui.SelectLanguagesDialog#$key" + selectAllState.value = properties.getBoolean(Constants.KEY_IS_SELECT_ALL) + if (selectAllState.value) { + selectedLanguages.clear() + selectedLanguages.addAll(supportedLanguages) } - private fun initState() { - val properties = properties() - translator = translatorService.getSelectedTranslator() - supportedLanguages = translator.supportedLanguages.sortedBy { it.englishName } - - val savedLanguageIds = LanguageUtil.getSelectedLanguageIds(project) - selectedLanguages.clear() - if (!savedLanguageIds.isNullOrEmpty()) { - selectedLanguages.addAll(supportedLanguages.filter { savedLanguageIds.contains(it.id.toString()) }) - } - - selectAllState.value = properties.getBoolean(Constants.KEY_IS_SELECT_ALL) - if (selectAllState.value) { - selectedLanguages.clear() - selectedLanguages.addAll(supportedLanguages) - } - - overwriteExistingState.value = properties.getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING) - openTranslatedFileState.value = properties.getBoolean(Constants.KEY_IS_OPEN_TRANSLATED_FILE) + overwriteExistingState.value = properties.getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING) + openTranslatedFileState.value = properties.getBoolean(Constants.KEY_IS_OPEN_TRANSLATED_FILE) - okAction.isEnabled = selectedLanguages.isNotEmpty() - } + okAction.isEnabled = selectedLanguages.isNotEmpty() + } - private fun handleSelectAll(checked: Boolean) { - selectAllState.value = checked - if (checked) { - selectedLanguages.clear() - selectedLanguages.addAll(supportedLanguages) - } else { - selectedLanguages.clear() - } - okAction.isEnabled = selectedLanguages.isNotEmpty() + private fun handleSelectAll(checked: Boolean) { + selectAllState.value = checked + if (checked) { + selectedLanguages.clear() + selectedLanguages.addAll(supportedLanguages) + } else { + selectedLanguages.clear() } - - private fun properties(): PropertiesComponent { - return if (project != null) PropertiesComponent.getInstance(project) else PropertiesComponent.getInstance() + okAction.isEnabled = selectedLanguages.isNotEmpty() + } + + private fun properties(): PropertiesComponent { + return if (project != null) PropertiesComponent.getInstance(project) else PropertiesComponent.getInstance() + } + + private fun calculateDialogSize(): Pair { + val screen = Toolkit.getDefaultToolkit().screenSize + val aspectRatio = 1.45 + val maxWidth = (screen.width * 0.85).roundToInt() + val minWidth = 900 + var width = (screen.width * 0.62).roundToInt().coerceIn(minWidth, maxWidth) + + val maxHeight = (screen.height * 0.8).roundToInt() + val minHeight = 620 + var height = (width / aspectRatio).roundToInt().coerceAtLeast(minHeight) + + if (height > maxHeight) { + height = maxHeight + width = (height * aspectRatio).roundToInt().coerceAtMost(maxWidth) } - private fun calculateDialogSize(): Pair { - val screen = Toolkit.getDefaultToolkit().screenSize - val aspectRatio = 1.45 - val maxWidth = (screen.width * 0.85).roundToInt() - val minWidth = 900 - var width = (screen.width * 0.62).roundToInt().coerceIn(minWidth, maxWidth) - - val maxHeight = (screen.height * 0.8).roundToInt() - val minHeight = 620 - var height = (width / aspectRatio).roundToInt().coerceAtLeast(minHeight) - - if (height > maxHeight) { - height = maxHeight - width = (height * aspectRatio).roundToInt().coerceAtMost(maxWidth) - } - - val preferred = Dimension(width, height) - val minimum = Dimension(minWidth, minHeight) - return preferred to minimum - } + val preferred = Dimension(width, height) + val minimum = Dimension(minWidth, minHeight) + return preferred to minimum + } } @Composable private fun SelectLanguagesContent( - translator: AbstractTranslator, - supportedLanguages: List, - selectedLanguages: SnapshotStateList, - selectAllStateChecked: Boolean, - overwriteExistingChecked: Boolean, - openTranslatedFileChecked: Boolean, - onSelectAllChanged: (Boolean) -> Unit, - onOverwriteChanged: (Boolean) -> Unit, - onOpenTranslatedFileChanged: (Boolean) -> Unit, - onLanguageToggled: (Lang, Boolean) -> Unit, + translator: AbstractTranslator, + supportedLanguages: List, + selectedLanguages: SnapshotStateList, + selectAllStateChecked: Boolean, + overwriteExistingChecked: Boolean, + openTranslatedFileChecked: Boolean, + onSelectAllChanged: (Boolean) -> Unit, + onOverwriteChanged: (Boolean) -> Unit, + onOpenTranslatedFileChanged: (Boolean) -> Unit, + onLanguageToggled: (Lang, Boolean) -> Unit, ) { - var filterText by rememberSaveable { mutableStateOf("") } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(18.dp) - ) { - LanguagesCard( - filterText = filterText, - onFilterChange = { filterText = it }, - allLanguages = supportedLanguages, - selectedLanguages = selectedLanguages, - selectAll = selectAllStateChecked, - overwriteExisting = overwriteExistingChecked, - openTranslatedFile = openTranslatedFileChecked, - onSelectAllChanged = onSelectAllChanged, - onOverwriteChanged = onOverwriteChanged, - onOpenTranslatedFileChanged = onOpenTranslatedFileChanged, - onLanguageToggled = onLanguageToggled, - modifier = Modifier.weight(1f, fill = true), - ) + var filterText by rememberSaveable { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(18.dp) + ) { + LanguagesCard( + filterText = filterText, + onFilterChange = { filterText = it }, + allLanguages = supportedLanguages, + selectedLanguages = selectedLanguages, + selectAll = selectAllStateChecked, + overwriteExisting = overwriteExistingChecked, + openTranslatedFile = openTranslatedFileChecked, + onSelectAllChanged = onSelectAllChanged, + onOverwriteChanged = onOverwriteChanged, + onOpenTranslatedFileChanged = onOpenTranslatedFileChanged, + onLanguageToggled = onLanguageToggled, + modifier = Modifier.weight(1f, fill = true), + ) - TranslatorFooter(translator = translator) - } + TranslatorFooter(translator = translator) + } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun LanguagesCard( - filterText: String, - onFilterChange: (String) -> Unit, - allLanguages: List, - selectedLanguages: SnapshotStateList, - selectAll: Boolean, - overwriteExisting: Boolean, - openTranslatedFile: Boolean, - onSelectAllChanged: (Boolean) -> Unit, - onOverwriteChanged: (Boolean) -> Unit, - onOpenTranslatedFileChanged: (Boolean) -> Unit, - onLanguageToggled: (Lang, Boolean) -> Unit, - modifier: Modifier = Modifier, + filterText: String, + onFilterChange: (String) -> Unit, + allLanguages: List, + selectedLanguages: SnapshotStateList, + selectAll: Boolean, + overwriteExisting: Boolean, + openTranslatedFile: Boolean, + onSelectAllChanged: (Boolean) -> Unit, + onOverwriteChanged: (Boolean) -> Unit, + onOpenTranslatedFileChanged: (Boolean) -> Unit, + onLanguageToggled: (Lang, Boolean) -> Unit, + modifier: Modifier = Modifier, ) { - val filteredLanguages = remember(filterText, allLanguages) { - if (filterText.isBlank()) allLanguages - else allLanguages.filter { - it.englishName.contains(filterText, ignoreCase = true) || - it.code.contains(filterText, ignoreCase = true) - } + val filteredLanguages = remember(filterText, allLanguages) { + if (filterText.isBlank()) allLanguages + else allLanguages.filter { + it.englishName.contains(filterText, ignoreCase = true) || + it.code.contains(filterText, ignoreCase = true) } - - Surface( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 260.dp), - shape = RoundedCornerShape(12.dp), - tonalElevation = 0.dp, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)), - color = MaterialTheme.colorScheme.surface, + } + + Surface( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 260.dp), + shape = RoundedCornerShape(12.dp), + tonalElevation = 0.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(18.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - OptionsSection( - overwriteExisting = overwriteExisting, - onOverwriteChanged = onOverwriteChanged, - openTranslatedFile = openTranslatedFile, - onOpenTranslatedFileChanged = onOpenTranslatedFileChanged, - ) - - OutlinedTextField( - value = filterText, - onValueChange = onFilterChange, - label = { Text("Filter languages") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - LanguagesHeader( - total = allLanguages.size, - selected = selectedLanguages.size, - selectAll = selectAll, - onSelectAllChanged = onSelectAllChanged, - ) - - LanguagesGrid( - languages = filteredLanguages, - selectedLanguages = selectedLanguages, - onLanguageToggled = onLanguageToggled, - ) - } + OptionsSection( + overwriteExisting = overwriteExisting, + onOverwriteChanged = onOverwriteChanged, + openTranslatedFile = openTranslatedFile, + onOpenTranslatedFileChanged = onOpenTranslatedFileChanged, + ) + + OutlinedTextField( + value = filterText, + onValueChange = onFilterChange, + label = { Text("Filter languages") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + LanguagesHeader( + total = allLanguages.size, + selected = selectedLanguages.size, + selectAll = selectAll, + onSelectAllChanged = onSelectAllChanged, + ) + + LanguagesGrid( + languages = filteredLanguages, + selectedLanguages = selectedLanguages, + onLanguageToggled = onLanguageToggled, + ) } + } } @Composable private fun LanguagesHeader( - total: Int, - selected: Int, - selectAll: Boolean, - onSelectAllChanged: (Boolean) -> Unit, + total: Int, + selected: Int, + selectAll: Boolean, + onSelectAllChanged: (Boolean) -> Unit, ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Languages ($selected/$total)", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.weight(1f)) - OptionItem( - text = "Select all", - tooltip = "Select every supported language.", - checked = selectAll, - onCheckedChange = onSelectAllChanged, - ) - } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Languages ($selected/$total)", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + OptionItem( + text = "Select all", + tooltip = "Select every supported language.", + checked = selectAll, + onCheckedChange = onSelectAllChanged, + ) + } } @Composable private fun LanguagesGrid( - languages: List, - selectedLanguages: SnapshotStateList, - onLanguageToggled: (Lang, Boolean) -> Unit, + languages: List, + selectedLanguages: SnapshotStateList, + onLanguageToggled: (Lang, Boolean) -> Unit, ) { - if (languages.isEmpty()) { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Text(text = "No languages match your filter", style = MaterialTheme.typography.bodyMedium) - } - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(4), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxSize() - ) { - items(languages, key = { it.id }) { language -> - LanguageOption( - language = language, - isSelected = language in selectedLanguages, - onToggle = { checked -> onLanguageToggled(language, checked) }, - ) - } - } + if (languages.isEmpty()) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text(text = "No languages match your filter", style = MaterialTheme.typography.bodyMedium) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(4), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + items(languages, key = { it.id }) { language -> + LanguageOption( + language = language, + isSelected = language in selectedLanguages, + onToggle = { checked -> onLanguageToggled(language, checked) }, + ) + } } + } } @OptIn(ExperimentalLayoutApi::class) @Composable private fun OptionsSection( - overwriteExisting: Boolean, - onOverwriteChanged: (Boolean) -> Unit, - openTranslatedFile: Boolean, - onOpenTranslatedFileChanged: (Boolean) -> Unit, + overwriteExisting: Boolean, + onOverwriteChanged: (Boolean) -> Unit, + openTranslatedFile: Boolean, + onOpenTranslatedFileChanged: (Boolean) -> Unit, ) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - OptionItem( - text = "Overwrite existing", - tooltip = "Replace existing strings when a translation already exists.", - checked = overwriteExisting, - onCheckedChange = onOverwriteChanged, - ) - OptionItem( - text = "Open translated file", - tooltip = "Open the generated translation file after the task finishes.", - checked = openTranslatedFile, - onCheckedChange = onOpenTranslatedFileChanged, - ) - } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OptionItem( + text = "Overwrite existing", + tooltip = "Replace existing strings when a translation already exists.", + checked = overwriteExisting, + onCheckedChange = onOverwriteChanged, + ) + OptionItem( + text = "Open translated file", + tooltip = "Open the generated translation file after the task finishes.", + checked = openTranslatedFile, + onCheckedChange = onOpenTranslatedFileChanged, + ) + } } @Composable private fun OptionItem( - text: String, - tooltip: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, + text: String, + tooltip: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.toggleable( - value = checked, - interactionSource = remember { MutableInteractionSource() }, - indication = null, - role = Role.Checkbox, - onValueChange = onCheckedChange, - ) - ) { - IdeCheckbox(checked = checked) - Text(text = text, style = MaterialTheme.typography.bodyMedium) - TooltipIcon(text = tooltip) - } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.toggleable( + value = checked, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Checkbox, + onValueChange = onCheckedChange, + ) + ) { + IdeCheckbox(checked = checked) + Text(text = text, style = MaterialTheme.typography.bodyMedium) + TooltipIcon(text = tooltip) + } } @OptIn(ExperimentalFoundationApi::class) @Composable private fun TooltipIcon(text: String) { - TooltipArea( - tooltip = { - Surface( - shape = RoundedCornerShape(6.dp), - shadowElevation = 4.dp, - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Text( - text = text, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - delayMillis = 300, - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + TooltipArea( + tooltip = { + Surface( + shape = RoundedCornerShape(6.dp), + shadowElevation = 4.dp, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - } + } + }, + delayMillis = 300, + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } @Composable private fun LanguageOption( - language: Lang, - isSelected: Boolean, - onToggle: (Boolean) -> Unit, + language: Lang, + isSelected: Boolean, + onToggle: (Boolean) -> Unit, ) { - val flag = language.flagEmoji - val displayName = remember(language) { "${language.englishName} (${language.code.uppercase()})" } - val backgroundColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant - val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) - - Row( - modifier = Modifier - .defaultMinSize(minHeight = 64.dp) - .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(12.dp)) - .background(backgroundColor, RoundedCornerShape(12.dp)) - .padding(horizontal = 12.dp, vertical = 8.dp) - .toggleable( - value = isSelected, - interactionSource = remember { MutableInteractionSource() }, - indication = null, - role = Role.Checkbox, - onValueChange = onToggle, - ), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IdeCheckbox(checked = isSelected) - if (flag != null) { - Text( - text = flag, - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 20.sp), - ) - } - Text( - text = displayName, - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp), - color = MaterialTheme.colorScheme.onSurface, - ) + val flag = language.flagEmoji + val displayName = remember(language) { "${language.englishName} (${language.code})" } + val backgroundColor = + if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant + val borderColor = + if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) + + Row( + modifier = Modifier + .defaultMinSize(minHeight = 64.dp) + .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(12.dp)) + .background(backgroundColor, RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + .toggleable( + value = isSelected, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Checkbox, + onValueChange = onToggle, + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IdeCheckbox(checked = isSelected) + if (flag != null) { + Text( + text = flag, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 20.sp), + ) } + Text( + text = displayName, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp), + color = MaterialTheme.colorScheme.onSurface, + ) + } } @Composable private fun TranslatorFooter(translator: AbstractTranslator) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - TranslatorIcon(icon = translator.icon) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "${translator.name} Translator", - style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + TranslatorIcon(icon = translator.icon) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${translator.name} Translator", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } @Composable private fun TranslatorIcon(icon: javax.swing.Icon?, modifier: Modifier = Modifier) { - if (icon == null) return - val imageBitmap = remember(icon) { icon.toImageBitmap() } - Image( - painter = remember(imageBitmap) { BitmapPainter(imageBitmap) }, - contentDescription = null, - modifier = modifier.size(20.dp), - ) + if (icon == null) return + val imageBitmap = remember(icon) { icon.toImageBitmap() } + Image( + painter = remember(imageBitmap) { BitmapPainter(imageBitmap) }, + contentDescription = null, + modifier = modifier.size(20.dp), + ) } private fun javax.swing.Icon.toImageBitmap(): ImageBitmap { - val image = java.awt.image.BufferedImage(iconWidth, iconHeight, java.awt.image.BufferedImage.TYPE_INT_ARGB) - val graphics = image.createGraphics() - graphics.background = java.awt.Color(0, 0, 0, 0) - paintIcon(null, graphics, 0, 0) - graphics.dispose() - return image.toComposeImageBitmap() + val image = java.awt.image.BufferedImage(iconWidth, iconHeight, java.awt.image.BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + graphics.background = java.awt.Color(0, 0, 0, 0) + paintIcon(null, graphics, 0, 0) + graphics.dispose() + return image.toComposeImageBitmap() } diff --git a/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt b/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt index e131f2a..cad98e9 100644 --- a/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt +++ b/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt @@ -2,6 +2,7 @@ package com.airsaid.localization.translate import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.translate.lang.Languages +import com.airsaid.localization.translate.lang.toLang import com.intellij.openapi.util.Pair import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpServer @@ -67,7 +68,7 @@ class AbstractTranslatorNetworkTest { } val inputText = "Hello world + test" - val result = translator.doTranslate(Languages.AUTO, Languages.ENGLISH, inputText) + val result = translator.doTranslate(Languages.AUTO.toLang(), Languages.ENGLISH.toLang(), inputText) assertTrue(latch.await(2, TimeUnit.SECONDS)) assertEquals("\"ok\"", result) @@ -78,7 +79,7 @@ class AbstractTranslatorNetworkTest { val expectedBody = listOf( "q" to inputText, - "lang" to Languages.ENGLISH.translationCode, + "lang" to Languages.ENGLISH.toLang().translationCode, ).joinToString("&") { (name, value) -> "${name}=${URLEncoder.encode(value, StandardCharsets.UTF_8)}" } @@ -114,7 +115,7 @@ class AbstractTranslatorNetworkTest { } val inputText = "Hello" - val result = translator.doTranslate(Languages.AUTO, Languages.ENGLISH, inputText) + val result = translator.doTranslate(Languages.AUTO.toLang(), Languages.ENGLISH.toLang(), inputText) assertTrue(latch.await(2, TimeUnit.SECONDS)) assertEquals("{\"translated\":\"ok\"}", result) @@ -122,7 +123,7 @@ class AbstractTranslatorNetworkTest { val request = capturedRequest.get() assertEquals("POST", request.method) assertTrue(request.contentType?.contains("application/json") == true) - val expectedBody = "{\"text\":\"$inputText\",\"target\":\"${Languages.ENGLISH.translationCode}\"}" + val expectedBody = "{\"text\":\"$inputText\",\"target\":\"${Languages.ENGLISH.toLang().translationCode}\"}" assertEquals(expectedBody, request.body) } @@ -136,7 +137,7 @@ class AbstractTranslatorNetworkTest { private open inner class TestTranslator(private val endpoint: String) : AbstractTranslator() { override val key: String = "Test" override val name: String = "Test" - override val supportedLanguages: List = listOf(Languages.ENGLISH) + override val supportedLanguages: List = listOf(Languages.ENGLISH.toLang()) override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { return baseUrl + endpoint From 71e215e6055494709f0a47c85fbd580da44efe86 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 09:48:20 +0800 Subject: [PATCH 33/58] Remove redundant addition of non-translatable XML tags --- src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt index 33b7c72..6862034 100644 --- a/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt +++ b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt @@ -144,7 +144,6 @@ class TranslateTask( if (value is XmlTag) { if (!valueService.isTranslatable(value)) { - translatedValues.add(value) continue } From 275f564bc97339d360c5b603e4fa9516c5656568 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 14:22:42 +0800 Subject: [PATCH 34/58] Refactor supported languages dialog --- .../localization/config/SettingsComponent.kt | 4 +- .../localization/ui/SupportLanguagesDialog.kt | 135 ------------------ .../ui/SupportedLanguagesDialog.kt | 132 +++++++++++++++++ 3 files changed, 134 insertions(+), 137 deletions(-) delete mode 100644 src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt create mode 100644 src/main/kotlin/com/airsaid/localization/ui/SupportedLanguagesDialog.kt diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt index 7778ba5..eaf65b2 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt @@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.ui.IdeTheme -import com.airsaid.localization.ui.SupportLanguagesDialog +import com.airsaid.localization.ui.SupportedLanguagesDialog import com.airsaid.localization.ui.components.IdeSwitch import com.airsaid.localization.ui.components.IdeTextField import com.intellij.openapi.diagnostic.Logger @@ -83,7 +83,7 @@ class SettingsComponent { maxCacheSizeState = maxCacheSizeState, translationIntervalState = translationIntervalState, onTranslatorSelected = { translator -> applySelectedTranslator(translator) }, - onShowSupportedLanguages = { translator -> SupportLanguagesDialog(translator).show() }, + onShowSupportedLanguages = { translator -> SupportedLanguagesDialog(translator).show() }, onConfigureTranslator = { translator -> TranslatorConfigurationManager.showConfigurationDialog(translator) } diff --git a/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt deleted file mode 100644 index 7d33d4e..0000000 --- a/src/main/kotlin/com/airsaid/localization/ui/SupportLanguagesDialog.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.airsaid.localization.ui - -import androidx.compose.foundation.VerticalScrollbar -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.ui.Alignment -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.ComposePanel -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.airsaid.localization.translate.AbstractTranslator -import com.airsaid.localization.translate.lang.Lang -import com.airsaid.localization.translate.lang.flagEmoji -import com.intellij.openapi.ui.DialogWrapper -import javax.swing.Action -import javax.swing.JComponent - -class SupportLanguagesDialog(private val translator: AbstractTranslator) : DialogWrapper(true) { - - private val supportedLanguages: List = translator.supportedLanguages.sortedBy { it.englishName } - - init { - title = "${translator.name} Translator Supported Languages" - init() - } - - override fun createCenterPanel(): JComponent { - val panel = ComposePanel() - panel.preferredSize = java.awt.Dimension(460, 420) - panel.setContent { - IdeTheme { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.background, - ) { - SupportLanguagesContent(languages = supportedLanguages) - } - } - } - return panel - } - - override fun getDimensionServiceKey(): String? { - val key = translator.key - return "#com.airsaid.localization.ui.SupportLanguagesDialog#$key" - } - - override fun createActions(): Array = emptyArray() -} - -@Composable -private fun SupportLanguagesContent(languages: List) { - val listState = rememberLazyListState() - - Column( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.background) - .padding(horizontal = 20.dp, vertical = 24.dp) - ) { - Text( - text = "Supported languages (${languages.size})", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onBackground - ) - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - ) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(languages, key = { it.id }) { language -> - Column(modifier = Modifier.fillMaxWidth()) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val flag = language.flagEmoji - if (flag != null) { - Text( - text = flag, - style = MaterialTheme.typography.headlineSmall.copy(fontSize = 24.sp) - ) - } - Text(text = language.englishName, style = MaterialTheme.typography.bodyMedium) - } - Text( - text = language.code.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - VerticalScrollbar( - modifier = Modifier.align(Alignment.CenterEnd), - adapter = rememberScrollbarAdapter(listState) - ) - } - } -} diff --git a/src/main/kotlin/com/airsaid/localization/ui/SupportedLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SupportedLanguagesDialog.kt new file mode 100644 index 0000000..4a52f51 --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/ui/SupportedLanguagesDialog.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2021 Airsaid. https://github.com/airsaid + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.airsaid.localization.ui + +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.airsaid.localization.translate.AbstractTranslator +import com.airsaid.localization.translate.lang.Lang +import java.awt.Dimension +import javax.swing.Action + +/** + * A dialog to show the supported languages of the [translator]. + * + * @author airsaid + */ +class SupportedLanguagesDialog(private val translator: AbstractTranslator) : ComposeDialog() { + + private val supportedLanguages = translator.supportedLanguages.sortedBy { it.code } + + init { + title = "${translator.name} Translator Supported Languages" + } + + override fun preferredSize() = Dimension(460, 420) + + @Composable + override fun Content() { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.background, + ) { + SupportLanguagesContent(languages = supportedLanguages) + } + } + + override fun getDimensionServiceKey(): String { + return "#com.airsaid.localization.ui.SupportLanguagesDialog#${translator.key}" + } +} + +@Composable +private fun SupportLanguagesContent(languages: List) { + val listState = rememberLazyListState() + + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 20.dp, vertical = 24.dp) + ) { + Text( + text = "Supported languages (${languages.size})", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(languages, key = { it.code }) { language -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val flag = language.flag + if (flag.isNotEmpty()) { + Text( + text = flag, + style = MaterialTheme.typography.headlineLarge + ) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = language.name, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "${language.englishName} (${language.code})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd), + adapter = rememberScrollbarAdapter(listState) + ) + } + } +} From a57eb8e70814f502f0c4b20517e7ca8eb4bfb171 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 14:24:16 +0800 Subject: [PATCH 35/58] Refactor language model to remove language IDs --- .../translate/impl/deepl/DeepLTranslator.kt | 4 - .../impl/google/AbsGoogleTranslator.kt | 12 +- .../translate/impl/google/GoogleTranslator.kt | 2 +- .../localization/translate/lang/Lang.kt | 8 +- .../localization/translate/lang/Languages.kt | 259 +++++++++--------- 5 files changed, 144 insertions(+), 141 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt index b0f8c6e..8ab5b49 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt @@ -66,7 +66,6 @@ open class DeepLTranslator : AbstractTranslator() { add(Languages.GREEK.toLang()) add( Languages.ENGLISH.toLang().copy( - id = 118, code = "en-gb", name = "English (British)", englishName = "English (British)", @@ -75,7 +74,6 @@ open class DeepLTranslator : AbstractTranslator() { ) add( Languages.ENGLISH.toLang().copy( - id = 119, code = "en-us", name = "English (American)", englishName = "English (American)", @@ -98,7 +96,6 @@ open class DeepLTranslator : AbstractTranslator() { add(Languages.POLISH.toLang()) add( Languages.PORTUGUESE.toLang().copy( - id = 120, code = "pt-br", name = "Portuguese (Brazilian)", englishName = "Portuguese (Brazilian)", @@ -107,7 +104,6 @@ open class DeepLTranslator : AbstractTranslator() { ) add( Languages.PORTUGUESE.toLang().copy( - id = 121, code = "pt-pt", name = "Portuguese (European)", englishName = "Portuguese (European)", diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt index d2f077e..a64e1fe 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt @@ -36,12 +36,12 @@ abstract class AbsGoogleTranslator : AbstractTranslator() { override val supportedLanguages: List by lazy { Languages.allSupportedLanguages() .map { lang -> - when (lang.id) { - Languages.CHINESE_SIMPLIFIED.id -> lang.setTranslationCode("zh-CN") - Languages.CHINESE_TRADITIONAL.id -> lang.setTranslationCode("zh-TW") - Languages.FILIPINO.id -> lang.setTranslationCode("tl") - Languages.INDONESIAN.id -> lang.setTranslationCode("id") - Languages.JAVANESE.id -> lang.setTranslationCode("jw") + when (lang.code) { + Languages.CHINESE_SIMPLIFIED.code -> lang.setTranslationCode("zh-CN") + Languages.CHINESE_TRADITIONAL.code -> lang.setTranslationCode("zh-TW") + Languages.FILIPINO.code -> lang.setTranslationCode("tl") + Languages.INDONESIAN.code -> lang.setTranslationCode("id") + Languages.JAVANESE.code -> lang.setTranslationCode("jw") else -> lang } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt index 14cdfc4..2eeee24 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt @@ -24,7 +24,7 @@ class GoogleTranslator : AbsGoogleTranslator() { override val icon: Icon = PluginIcons.GOOGLE_ICON override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { - val source = if (fromLang.id == Languages.AUTO.id) "auto" else fromLang.translationCode + val source = if (fromLang.code.equals(Languages.AUTO.code, ignoreCase = true)) "auto" else fromLang.translationCode val builder = UrlBuilder(googleApiUrl(TRANSLATE_PATH)) .addQueryParameter("client", "gtx") .addQueryParameter("sl", source) diff --git a/src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt b/src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt index 085027b..927ed6c 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt @@ -26,7 +26,6 @@ import com.intellij.openapi.util.text.StringUtil * @author airsaid */ data class Lang( - val id: Int, val code: String, val name: String, val englishName: String, @@ -46,11 +45,11 @@ data class Lang( if (this === other) return true if (other == null || javaClass != other.javaClass) return false val language = other as Lang - return id == language.id + return code == language.code } override fun hashCode(): Int { - return id.hashCode() + return code.hashCode() } public override fun clone(): Lang { @@ -64,7 +63,6 @@ data class Lang( override fun toString(): String { return "Lang{" + - "id=$id, " + "code='$code', " + "name='$name', " + "englishName='$englishName', " + @@ -73,4 +71,4 @@ data class Lang( "translationCode='$translationCode'" + '}' } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt b/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt index 341d991..ab53e5f 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt @@ -23,129 +23,128 @@ package com.airsaid.localization.translate.lang * @author airsaid */ enum class Languages( - val id: Int, val code: String, val displayName: String, val englishName: String, val flag: String, val directoryName: String, ) { - AUTO(0, "auto", "Auto", "Auto", "🌏", ""), - ALBANIAN(1, "sq", "Shqiptar", "Albanian", "🇦🇱", "sq"), - ARABIC(2, "ar", "العربية", "Arabic", "🇸🇦", "ar"), - AMHARIC(3, "am", "አማርኛ", "Amharic", "🇪🇹", "am"), - AZERBAIJANI(4, "az", "азәрбајҹан", "Azerbaijani", "🇦🇿", "az"), - IRISH(5, "ga", "Gaeilge", "Irish", "🇮🇪", "ga"), - ESTONIAN(6, "et", "Eesti", "Estonian", "🇪🇪", "et"), - BASQUE(7, "eu", "Euskal", "Basque", "🇪🇸", "eu"), - BELARUSIAN(8, "be", "беларускі", "Belarusian", "🇧🇾", "be"), - BULGARIAN(9, "bg", "Български", "Bulgarian", "🇧🇬", "bg"), - ICELANDIC(10, "is", "Íslenska", "Icelandic", "🇮🇸", "is"), - POLISH(11, "pl", "Polski", "Polish", "🇵🇱", "pl"), - BOSNIAN(12, "bs", "Bosanski", "Bosnian", "🇧🇦", "bs"), - PERSIAN(13, "fa", "Persian", "Persian", "🇮🇷", "fa"), - AFRIKAANS(14, "af", "Afrikaans", "Afrikaans", "🇿🇦", "af"), - DANISH(15, "da", "Dansk", "Danish", "🇩🇰", "da"), - GERMAN(16, "de", "Deutsch", "German", "🇩🇪", "de"), - RUSSIAN(17, "ru", "Русский", "Russian", "🇷🇺", "ru"), - FRENCH(18, "fr", "Français", "French", "🇫🇷", "fr"), - FILIPINO(19, "fil", "Filipino", "Filipino", "🇵🇭", "fil"), - FINNISH(20, "fi", "Suomi", "Finnish", "🇫🇮", "fi"), - FRISIAN(21, "fy", "Frysk", "Frisian", "🇳🇱", "fy"), - KHMER(22, "km", "ខ្មែរ", "Khmer", "🇰🇭", "km"), - GEORGIAN(23, "ka", "ქართული", "Georgian", "🇬🇪", "ka"), - GUJARATI(24, "gu", "ગુજરાતી", "Gujarati", "🇮🇳", "gu"), - KAZAKH(25, "kk", "Kazakh", "Kazakh", "🇰🇿", "kk"), - HAITIAN_CREOLE(26, "ht", "Haitian Creole", "Haitian Creole", "🇭🇹", "ht"), - KOREAN(27, "ko", "한국어", "Korean", "🇰🇷", "ko"), - HAUSA(28, "ha", "Hausa", "Hausa", "🇳🇬", "ha"), - DUTCH(29, "nl", "Nederlands", "Dutch", "🇳🇱", "nl"), - KYRGYZ(30, "ky", "Кыргыз тили", "Kyrgyz", "🇰🇬", "ky"), - GALICIAN(31, "gl", "Galego", "Galician", "🇪🇸", "gl"), - CATALAN(32, "ca", "Català", "Catalan", "🇪🇸", "ca"), - CZECH(33, "cs", "Čeština", "Czech", "🇨🇿", "cs"), - KANNADA(34, "kn", "ಕನ್ನಡ", "Kannada", "🇮🇳", "kn"), - CORSICAN(35, "co", "Corsa", "Corsican", "🇫🇷", "co"), - CROATIAN(36, "hr", "Hrvatski", "Croatian", "🇭🇷", "hr"), - KURDISH(37, "ku", "Kurdî", "Kurdish", "🇮🇶", "ku"), - LATIN(38, "la", "Latina", "Latin", "🇻🇦", "la"), - LATVIAN(39, "lv", "Latviešu", "Latvian", "🇱🇻", "lv"), - LAO(40, "lo", "ລາວ", "Lao", "🇱🇦", "lo"), - LITHUANIAN(41, "lt", "Lietuvių", "Lithuanian", "🇱🇹", "lt"), - LUXEMBOURGISH(42, "lb", "Lëtzebuergesch", "Luxembourgish", "🇱🇺", "lb"), - ROMANIAN(43, "ro", "Română", "Romanian", "🇷🇴", "ro"), - MALAGASY(44, "mg", "Malagasy", "Malagasy", "🇲🇬", "mg"), - MALTESE(45, "mt", "Il-Malti", "Maltese", "🇲🇹", "mt"), - MARATHI(46, "mr", "मराठी", "Marathi", "🇮🇳", "mr"), - MALAYALAM(47, "ml", "മലയാളം", "Malayalam", "🇮🇳", "ml"), - MALAY(48, "ms", "Melayu", "Malay", "🇲🇾", "ms"), - MACEDONIAN(49, "mk", "Македонски", "Macedonian", "🇲🇰", "mk"), - MAORI(50, "mi", "Māori", "Maori", "🇳🇿", "mi"), - MONGOLIAN(51, "mn", "Монгол хэл", "Mongolian", "🇲🇳", "mn"), - BANGLA(52, "bn", "বাংল", "Bangla", "🇧🇩", "bn"), - BURMESE(53, "my", "မြန်မာ", "Burmese", "🇲🇲", "my"), - HMONG(54, "hmn", "Hmoob", "Hmong", "🇨🇳", "hmn"), - XHOSA(55, "xh", "IsiXhosa", "Xhosa", "🇿🇦", "xh"), - ZULU(56, "zu", "Zulu", "Zulu", "🇿🇦", "zu"), - NEPALI(57, "ne", "नेपाली", "Nepali", "🇳🇵", "ne"), - NORWEGIAN(58, "no", "Norsk", "Norwegian", "🇳🇴", "no"), - PUNJABI(59, "pa", "ਪੰਜਾਬੀ", "Punjabi", "🇮🇳", "pa"), - PORTUGUESE(60, "pt", "Português", "Portuguese", "🇵🇹", "pt"), - PASHTO(61, "ps", "Pashto", "Pashto", "🇦🇫", "ps"), - CHICHEWA(62, "ny", "Chichewa", "Chichewa", "🇲🇼", "ny"), - JAPANESE(63, "ja", "日本語", "Japanese", "🇯🇵", "ja"), - SWEDISH(64, "sv", "Svenska", "Swedish", "🇸🇪", "sv"), - SAMOAN(65, "sm", "Samoa", "Samoan", "🇼🇸", "sm"), - SERBIAN(66, "sr", "Српски", "Serbian", "🇷🇸", "sr"), - SOTHO(67, "st", "Sesotho", "Sotho", "🇱🇸", "st"), - SINHALA(68, "si", "සිංහල", "Sinhala", "🇱🇰", "si"), - ESPERANTO(69, "eo", "Esperanta", "Esperanto", "🇺🇳", "eo"), - SLOVAK(70, "sk", "Slovenčina", "Slovak", "🇸🇰", "sk"), - SLOVENIAN(71, "sl", "Slovenščina", "Slovenian", "🇸🇮", "sl"), - SWAHILI(72, "sw", "Kiswahili", "Swahili", "🇹🇿", "sw"), - SCOTTISH_GAELIC(73, "gd", "Gàidhlig na h-Alba", "Scottish Gaelic", "🇬🇧", "gd"), - CEBUANO(74, "ceb", "Cebuano", "Cebuano", "🇵🇭", "ceb"), - SOMALI(75, "so", "Somali", "Somali", "🇸🇴", "so"), - TAJIK(76, "tg", "Тоҷикӣ", "Tajik", "🇹🇯", "tg"), - TELUGU(77, "te", "తెలుగు", "Telugu", "🇮🇳", "te"), - TAMIL(78, "ta", "தமிழ்", "Tamil", "🇮🇳", "ta"), - THAI(79, "th", "ไทย", "Thai", "🇹🇭", "th"), - TURKISH(80, "tr", "Türkçe", "Turkish", "🇹🇷", "tr"), - WELSH(81, "cy", "Cymraeg", "Welsh", "🇬🇧", "cy"), - URDU(82, "ur", "اردو", "Urdu", "🇵🇰", "ur"), - UKRAINIAN(83, "uk", "Українська", "Ukrainian", "🇺🇦", "uk"), - UZBEK(84, "uz", "O'zbek", "Uzbek", "🇺🇿", "uz"), - SPANISH(85, "es", "Español", "Spanish", "🇪🇸", "es"), - HEBREW(86, "iw", "עברית", "Hebrew", "🇮🇱", "iw"), - GREEK(87, "el", "Ελληνικά", "Greek", "🇬🇷", "el"), - HAWAIIAN(88, "haw", "Hawaiian", "Hawaiian", "🇺🇸", "haw"), - SINDHI(89, "sd", "سنڌي", "Sindhi", "🇵🇰", "sd"), - HUNGARIAN(90, "hu", "Magyar", "Hungarian", "🇭🇺", "hu"), - SHONA(91, "sn", "Shona", "Shona", "🇿🇼", "sn"), - ARMENIAN(92, "hy", "Հայերեն", "Armenian", "🇦🇲", "hy"), - IGBO(93, "ig", "Igbo", "Igbo", "🇳🇬", "ig"), - ITALIAN(94, "it", "Italiano", "Italian", "🇮🇹", "it"), - YIDDISH(95, "yi", "ייִדיש", "Yiddish", "🇮🇱", "yi"), - HINDI(96, "hi", "हिंदी", "Hindi", "🇮🇳", "hi"), - SUNDANESE(97, "su", "Sunda", "Sundanese", "🇮🇩", "su"), - INDONESIAN(98, "id", "Indonesia", "Indonesian", "🇮🇩", "in"), - JAVANESE(99, "jv", "Wong Jawa", "Javanese", "🇮🇩", "jv"), - ENGLISH(100, "en", "English", "English", "🇺🇸", "en"), - YORUBA(101, "yo", "Yorùbá", "Yoruba", "🇳🇬", "yo"), - VIETNAMESE(102, "vi", "Tiếng Việt", "Vietnamese", "🇻🇳", "vi"), - CHINESE_TRADITIONAL(103, "zh-rTW", "正體中文", "Chinese Traditional", "🇨🇳", "zh-rTW"), - CHINESE_SIMPLIFIED(104, "zh-rCN", "简体中文", "Chinese Simplified", "🇨🇳", "zh-rCN"), - ASSAMESE(105, "as", "Assamese", "Assamese", "🇮🇳", "as"), - DARI(106, "prs", "Dari", "Dari", "🇦🇫", "prs"), - FIJIAN(107, "fj", "Fijian", "Fijian", "🇫🇯", "fj"), - HMONG_DAW(108, "mww", "Hmong Daw", "Hmong Daw", "🇨🇳", "mww"), - INUKTITUT(109, "iu", "ᐃᓄᒃᑎᑐᑦ", "Inuktitut", "🇨🇦", "iu"), - ODIA(112, "or", "Odia", "Odia", "🇮🇳", "or"), - QUERETARO_OTOMI(113, "otq", "Querétaro Otomi", "Querétaro Otomi", "🇲🇽", "otq"), - TAHITIAN(114, "ty", "Tahitian", "Tahitian", "🇵🇫", "ty"), - TIGRINYA(115, "ti", "ትግርኛ", "Tigrinya", "🇪🇷", "ti"), - TONGAN(116, "to", "lea fakatonga", "Tongan", "🇹🇴", "to"), - YUCATEC_MAYA(117, "yua", "Yucatec Maya", "Yucatec Maya", "🇲🇽", "yua"); + AUTO("auto", "Auto", "Auto", "🌏", ""), + ALBANIAN("sq", "Shqiptar", "Albanian", "🇦🇱", "sq"), + ARABIC("ar", "العربية", "Arabic", "🇸🇦", "ar"), + AMHARIC("am", "አማርኛ", "Amharic", "🇪🇹", "am"), + AZERBAIJANI("az", "азәрбајҹан", "Azerbaijani", "🇦🇿", "az"), + IRISH("ga", "Gaeilge", "Irish", "🇮🇪", "ga"), + ESTONIAN("et", "Eesti", "Estonian", "🇪🇪", "et"), + BASQUE("eu", "Euskal", "Basque", "🇪🇸", "eu"), + BELARUSIAN("be", "беларускі", "Belarusian", "🇧🇾", "be"), + BULGARIAN("bg", "Български", "Bulgarian", "🇧🇬", "bg"), + ICELANDIC("is", "Íslenska", "Icelandic", "🇮🇸", "is"), + POLISH("pl", "Polski", "Polish", "🇵🇱", "pl"), + BOSNIAN("bs", "Bosanski", "Bosnian", "🇧🇦", "bs"), + PERSIAN("fa", "Persian", "Persian", "🇮🇷", "fa"), + AFRIKAANS("af", "Afrikaans", "Afrikaans", "🇿🇦", "af"), + DANISH("da", "Dansk", "Danish", "🇩🇰", "da"), + GERMAN("de", "Deutsch", "German", "🇩🇪", "de"), + RUSSIAN("ru", "Русский", "Russian", "🇷🇺", "ru"), + FRENCH("fr", "Français", "French", "🇫🇷", "fr"), + FILIPINO("fil", "Filipino", "Filipino", "🇵🇭", "fil"), + FINNISH("fi", "Suomi", "Finnish", "🇫🇮", "fi"), + FRISIAN("fy", "Frysk", "Frisian", "🇳🇱", "fy"), + KHMER("km", "ខ្មែរ", "Khmer", "🇰🇭", "km"), + GEORGIAN("ka", "ქართული", "Georgian", "🇬🇪", "ka"), + GUJARATI("gu", "ગુજરાતી", "Gujarati", "🇮🇳", "gu"), + KAZAKH("kk", "Kazakh", "Kazakh", "🇰🇿", "kk"), + HAITIAN_CREOLE("ht", "Haitian Creole", "Haitian Creole", "🇭🇹", "ht"), + KOREAN("ko", "한국어", "Korean", "🇰🇷", "ko"), + HAUSA("ha", "Hausa", "Hausa", "🇳🇬", "ha"), + DUTCH("nl", "Nederlands", "Dutch", "🇳🇱", "nl"), + KYRGYZ("ky", "Кыргыз тили", "Kyrgyz", "🇰🇬", "ky"), + GALICIAN("gl", "Galego", "Galician", "🇪🇸", "gl"), + CATALAN("ca", "Català", "Catalan", "🇪🇸", "ca"), + CZECH("cs", "Čeština", "Czech", "🇨🇿", "cs"), + KANNADA("kn", "ಕನ್ನಡ", "Kannada", "🇮🇳", "kn"), + CORSICAN("co", "Corsa", "Corsican", "🇫🇷", "co"), + CROATIAN("hr", "Hrvatski", "Croatian", "🇭🇷", "hr"), + KURDISH("ku", "Kurdî", "Kurdish", "🇮🇶", "ku"), + LATIN("la", "Latina", "Latin", "🇻🇦", "la"), + LATVIAN("lv", "Latviešu", "Latvian", "🇱🇻", "lv"), + LAO("lo", "ລາວ", "Lao", "🇱🇦", "lo"), + LITHUANIAN("lt", "Lietuvių", "Lithuanian", "🇱🇹", "lt"), + LUXEMBOURGISH("lb", "Lëtzebuergesch", "Luxembourgish", "🇱🇺", "lb"), + ROMANIAN("ro", "Română", "Romanian", "🇷🇴", "ro"), + MALAGASY("mg", "Malagasy", "Malagasy", "🇲🇬", "mg"), + MALTESE("mt", "Il-Malti", "Maltese", "🇲🇹", "mt"), + MARATHI("mr", "मराठी", "Marathi", "🇮🇳", "mr"), + MALAYALAM("ml", "മലയാളം", "Malayalam", "🇮🇳", "ml"), + MALAY("ms", "Melayu", "Malay", "🇲🇾", "ms"), + MACEDONIAN("mk", "Македонски", "Macedonian", "🇲🇰", "mk"), + MAORI("mi", "Māori", "Maori", "🇳🇿", "mi"), + MONGOLIAN("mn", "Монгол хэл", "Mongolian", "🇲🇳", "mn"), + BANGLA("bn", "বাংল", "Bangla", "🇧🇩", "bn"), + BURMESE("my", "မြန်မာ", "Burmese", "🇲🇲", "my"), + HMONG("hmn", "Hmoob", "Hmong", "🇨🇳", "hmn"), + XHOSA("xh", "IsiXhosa", "Xhosa", "🇿🇦", "xh"), + ZULU("zu", "Zulu", "Zulu", "🇿🇦", "zu"), + NEPALI("ne", "नेपाली", "Nepali", "🇳🇵", "ne"), + NORWEGIAN("no", "Norsk", "Norwegian", "🇳🇴", "no"), + PUNJABI("pa", "ਪੰਜਾਬੀ", "Punjabi", "🇮🇳", "pa"), + PORTUGUESE("pt", "Português", "Portuguese", "🇵🇹", "pt"), + PASHTO("ps", "Pashto", "Pashto", "🇦🇫", "ps"), + CHICHEWA("ny", "Chichewa", "Chichewa", "🇲🇼", "ny"), + JAPANESE("ja", "日本語", "Japanese", "🇯🇵", "ja"), + SWEDISH("sv", "Svenska", "Swedish", "🇸🇪", "sv"), + SAMOAN("sm", "Samoa", "Samoan", "🇼🇸", "sm"), + SERBIAN("sr", "Српски", "Serbian", "🇷🇸", "sr"), + SOTHO("st", "Sesotho", "Sotho", "🇱🇸", "st"), + SINHALA("si", "සිංහල", "Sinhala", "🇱🇰", "si"), + ESPERANTO("eo", "Esperanta", "Esperanto", "🇺🇳", "eo"), + SLOVAK("sk", "Slovenčina", "Slovak", "🇸🇰", "sk"), + SLOVENIAN("sl", "Slovenščina", "Slovenian", "🇸🇮", "sl"), + SWAHILI("sw", "Kiswahili", "Swahili", "🇹🇿", "sw"), + SCOTTISH_GAELIC("gd", "Gàidhlig na h-Alba", "Scottish Gaelic", "🇬🇧", "gd"), + CEBUANO("ceb", "Cebuano", "Cebuano", "🇵🇭", "ceb"), + SOMALI("so", "Somali", "Somali", "🇸🇴", "so"), + TAJIK("tg", "Тоҷикӣ", "Tajik", "🇹🇯", "tg"), + TELUGU("te", "తెలుగు", "Telugu", "🇮🇳", "te"), + TAMIL("ta", "தமிழ்", "Tamil", "🇮🇳", "ta"), + THAI("th", "ไทย", "Thai", "🇹🇭", "th"), + TURKISH("tr", "Türkçe", "Turkish", "🇹🇷", "tr"), + WELSH("cy", "Cymraeg", "Welsh", "🇬🇧", "cy"), + URDU("ur", "اردو", "Urdu", "🇵🇰", "ur"), + UKRAINIAN("uk", "Українська", "Ukrainian", "🇺🇦", "uk"), + UZBEK("uz", "O'zbek", "Uzbek", "🇺🇿", "uz"), + SPANISH("es", "Español", "Spanish", "🇪🇸", "es"), + HEBREW("iw", "עברית", "Hebrew", "🇮🇱", "iw"), + GREEK("el", "Ελληνικά", "Greek", "🇬🇷", "el"), + HAWAIIAN("haw", "Hawaiian", "Hawaiian", "🇺🇸", "haw"), + SINDHI("sd", "سنڌي", "Sindhi", "🇵🇰", "sd"), + HUNGARIAN("hu", "Magyar", "Hungarian", "🇭🇺", "hu"), + SHONA("sn", "Shona", "Shona", "🇿🇼", "sn"), + ARMENIAN("hy", "Հայերեն", "Armenian", "🇦🇲", "hy"), + IGBO("ig", "Igbo", "Igbo", "🇳🇬", "ig"), + ITALIAN("it", "Italiano", "Italian", "🇮🇹", "it"), + YIDDISH("yi", "ייִדיש", "Yiddish", "🇮🇱", "yi"), + HINDI("hi", "हिंदी", "Hindi", "🇮🇳", "hi"), + SUNDANESE("su", "Sunda", "Sundanese", "🇮🇩", "su"), + INDONESIAN("id", "Indonesia", "Indonesian", "🇮🇩", "in"), + JAVANESE("jv", "Wong Jawa", "Javanese", "🇮🇩", "jv"), + ENGLISH("en", "English", "English", "🇺🇸", "en"), + YORUBA("yo", "Yorùbá", "Yoruba", "🇳🇬", "yo"), + VIETNAMESE("vi", "Tiếng Việt", "Vietnamese", "🇻🇳", "vi"), + CHINESE_TRADITIONAL("zh-rTW", "正體中文", "Chinese Traditional", "🇨🇳", "zh-rTW"), + CHINESE_SIMPLIFIED("zh-rCN", "简体中文", "Chinese Simplified", "🇨🇳", "zh-rCN"), + ASSAMESE("as", "Assamese", "Assamese", "🇮🇳", "as"), + DARI("prs", "Dari", "Dari", "🇦🇫", "prs"), + FIJIAN("fj", "Fijian", "Fijian", "🇫🇯", "fj"), + HMONG_DAW("mww", "Hmong Daw", "Hmong Daw", "🇨🇳", "mww"), + INUKTITUT("iu", "ᐃᓄᒃᑎᑐᑦ", "Inuktitut", "🇨🇦", "iu"), + ODIA("or", "Odia", "Odia", "🇮🇳", "or"), + QUERETARO_OTOMI("otq", "Querétaro Otomi", "Querétaro Otomi", "🇲🇽", "otq"), + TAHITIAN("ty", "Tahitian", "Tahitian", "🇵🇫", "ty"), + TIGRINYA("ti", "ትግርኛ", "Tigrinya", "🇪🇷", "ti"), + TONGAN("to", "lea fakatonga", "Tongan", "🇹🇴", "to"), + YUCATEC_MAYA("yua", "Yucatec Maya", "Yucatec Maya", "🇲🇽", "yua"); companion object { fun languages(): List { @@ -155,22 +154,32 @@ enum class Languages( fun allSupportedLanguages(): List { return Languages.entries.filter { it != AUTO }.map { it.toLang() } } + + private val DEFAULT_FAVORITES = listOf( + ENGLISH, + CHINESE_SIMPLIFIED, + CHINESE_TRADITIONAL, + SPANISH, + FRENCH, + GERMAN, + JAPANESE, + KOREAN, + PORTUGUESE, + HINDI + ) + + fun defaultFavoriteCodes(): List = DEFAULT_FAVORITES.map { it.code } + + fun defaultFavoriteLanguages(): List = DEFAULT_FAVORITES.map { it.toLang() } } } fun Languages.toLang(): Lang { return Lang( - id = this.id, code = this.code, name = this.displayName, englishName = this.englishName, flag = this.flag, directoryName = this.directoryName ) -} - -val Lang.flagEmoji: String? - get() = flag.takeIf { it.isNotBlank() } - -val Lang.valuesDirectoryQualifier: String? - get() = directoryName.takeIf { it.isNotBlank() } +} \ No newline at end of file From 53b4d1f3c055f4da9684548d8c739b3c6224ea88 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 16:40:14 +0800 Subject: [PATCH 36/58] Add favorite languages support to language selection dialog --- .../localization/constant/Constants.kt | 14 +- .../translate/AbstractTranslator.kt | 5 +- .../localization/translate/lang/Languages.kt | 2 +- .../airsaid/localization/ui/ComposeDialog.kt | 4 +- .../localization/ui/SelectLanguagesDialog.kt | 481 ++++++++++++------ .../localization/ui/components/SwingIcon.kt | 38 ++ .../localization/utils/LanguageUtil.kt | 88 ++-- 7 files changed, 432 insertions(+), 200 deletions(-) create mode 100644 src/main/kotlin/com/airsaid/localization/ui/components/SwingIcon.kt diff --git a/src/main/kotlin/com/airsaid/localization/constant/Constants.kt b/src/main/kotlin/com/airsaid/localization/constant/Constants.kt index bf72d2b..fabd170 100644 --- a/src/main/kotlin/com/airsaid/localization/constant/Constants.kt +++ b/src/main/kotlin/com/airsaid/localization/constant/Constants.kt @@ -23,10 +23,10 @@ package com.airsaid.localization.constant * @author airsaid */ object Constants { - const val PLUGIN_NAME = "AndroidLocalize" - const val PLUGIN_ID = "com.github.airsaid.androidlocalize" - const val KEY_SELECTED_LANGUAGES = "$PLUGIN_ID.selected_languages" - const val KEY_IS_OVERWRITE_EXISTING_STRING = "$PLUGIN_ID.is_overwrite_existing_string" - const val KEY_IS_SELECT_ALL = "$PLUGIN_ID.is_select_all" - const val KEY_IS_OPEN_TRANSLATED_FILE = "$PLUGIN_ID.is_open_translated_file" -} \ No newline at end of file + const val PLUGIN_NAME = "AndroidLocalize" + const val PLUGIN_ID = "com.github.airsaid.androidlocalize" + const val KEY_SELECTED_LANGUAGES = "$PLUGIN_ID.selected_languages.v2" + const val KEY_FAVORITE_LANGUAGES = "$PLUGIN_ID.favorite_languages" + const val KEY_IS_OVERWRITE_EXISTING_STRING = "$PLUGIN_ID.is_overwrite_existing_string" + const val KEY_IS_OPEN_TRANSLATED_FILE = "$PLUGIN_ID.is_open_translated_file" +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt index d93a5de..3368250 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/AbstractTranslator.kt @@ -38,8 +38,9 @@ abstract class AbstractTranslator : Translator, TranslatorConfigurable { override val name: String get() = key - override val supportedLanguages: List - get() = Languages.allSupportedLanguages() + override val supportedLanguages: List by lazy { + Languages.allSupportedLanguages() + } companion object { protected val LOG = Logger.getInstance(AbstractTranslator::class.java) diff --git a/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt b/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt index ab53e5f..dddc604 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/lang/Languages.kt @@ -132,7 +132,7 @@ enum class Languages( ENGLISH("en", "English", "English", "🇺🇸", "en"), YORUBA("yo", "Yorùbá", "Yoruba", "🇳🇬", "yo"), VIETNAMESE("vi", "Tiếng Việt", "Vietnamese", "🇻🇳", "vi"), - CHINESE_TRADITIONAL("zh-rTW", "正體中文", "Chinese Traditional", "🇨🇳", "zh-rTW"), + CHINESE_TRADITIONAL("zh-rTW", "繁體中文", "Chinese Traditional", "🇨🇳", "zh-rTW"), CHINESE_SIMPLIFIED("zh-rCN", "简体中文", "Chinese Simplified", "🇨🇳", "zh-rCN"), ASSAMESE("as", "Assamese", "Assamese", "🇮🇳", "as"), DARI("prs", "Dari", "Dari", "🇦🇫", "prs"), diff --git a/src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt index 57f931d..2712f90 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt @@ -28,6 +28,8 @@ abstract class ComposeDialog( override fun createCenterPanel(): JComponent = composePanel + protected open fun preferredSize(): Dimension? = null + @Composable protected abstract fun Content() @@ -38,8 +40,6 @@ abstract class ComposeDialog( } } - protected open fun preferredSize(): Dimension? = null - override fun doOKAction() { onClickOKCallback?.invoke() super.doOKAction() diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt index 9976736..428ea9d 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -22,167 +22,197 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Star import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.ComposePanel -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.airsaid.localization.constant.Constants import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.lang.Lang -import com.airsaid.localization.translate.lang.flagEmoji +import com.airsaid.localization.translate.lang.Languages import com.airsaid.localization.translate.services.TranslatorService import com.airsaid.localization.ui.components.IdeCheckbox +import com.airsaid.localization.ui.components.SwingIcon import com.airsaid.localization.utils.LanguageUtil import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.DialogWrapper import java.awt.Dimension import java.awt.Toolkit -import javax.swing.JComponent import kotlin.math.roundToInt /** * Compose-driven dialog used to pick the languages that should be generated. + * + * @author airsaid */ -class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(project, false) { +class SelectLanguagesDialog(private val project: Project) : ComposeDialog(project, false) { + /** + * Callback for when the OK button is clicked. + */ interface OnClickListener { + /** + * Called when the OK button is clicked. + * + * @param selectedLanguage The list of selected languages. + */ fun onClickListener(selectedLanguage: List) } private val translatorService = TranslatorService.getInstance() + private val translator = translatorService.getSelectedTranslator() + private val supportedLanguages = translator.supportedLanguages.sortedBy { it.code } + private val defaultFavoriteCodes = Languages.defaultFavoriteCodes() + + private val favoriteLanguages = mutableStateListOf() private val selectedLanguages = mutableStateListOf() - private val selectAllState = mutableStateOf(false) private val overwriteExistingState = mutableStateOf(false) private val openTranslatedFileState = mutableStateOf(false) - private var onClickListener: OnClickListener? = null + private var stateInitialized by mutableStateOf(false) - private lateinit var translator: AbstractTranslator - private lateinit var supportedLanguages: List + private var onClickListener: OnClickListener? = null init { - initState() title = "Select Translated Languages" - init() } + /** + * Sets the listener to be invoked when the OK button is clicked. + * + * @param listener The listener to be invoked. + */ fun setOnClickListener(listener: OnClickListener) { onClickListener = listener } - override fun createCenterPanel(): JComponent { - val panel = ComposePanel() - val (preferredSize, minimumSize) = calculateDialogSize() - panel.preferredSize = preferredSize - panel.minimumSize = minimumSize - panel.setContent { - IdeTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - SelectLanguagesContent( - translator = translator, - supportedLanguages = supportedLanguages, - selectedLanguages = selectedLanguages, - selectAllStateChecked = selectAllState.value, - overwriteExistingChecked = overwriteExistingState.value, - openTranslatedFileChecked = openTranslatedFileState.value, - onSelectAllChanged = { handleSelectAll(it) }, - onOverwriteChanged = { checked -> - overwriteExistingState.value = checked - }, - onOpenTranslatedFileChanged = { checked -> - openTranslatedFileState.value = checked - }, - onLanguageToggled = { lang, checked -> - if (checked) { - if (!selectedLanguages.contains(lang)) { - selectedLanguages.add(lang) - } - } else { - selectedLanguages.remove(lang) - } - - val allSelected = selectedLanguages.size == supportedLanguages.size && supportedLanguages.isNotEmpty() - if (selectAllState.value != allSelected) { - selectAllState.value = allSelected - } - - okAction.isEnabled = selectedLanguages.isNotEmpty() - }, - ) - } + @Composable + override fun Content() { + LaunchedEffect(Unit) { + if (!stateInitialized) { + loadState() } } - return panel + + if (!stateInitialized) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + val languages by remember { + derivedStateOf { translator.supportedLanguages.filterNot { favoriteLanguages.contains(it) } } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + SelectLanguagesContent( + translator = translator, + languages = languages, + favoriteLanguages = favoriteLanguages, + selectedLanguages = selectedLanguages, + overwriteExistingChecked = overwriteExistingState.value, + openTranslatedFileChecked = openTranslatedFileState.value, + onSelectAllChanged = { selectAll(languages, it) }, + onFavoriteSelectAllChanged = { selectAll(favoriteLanguages, it) }, + onOverwriteChanged = { checked -> overwriteExistingState.value = checked }, + onOpenTranslatedFileChanged = { checked -> openTranslatedFileState.value = checked }, + onLanguageToggled = { lang, checked -> selectLanguage(lang, checked) }, + onFavoriteToggle = { lang, isFavorite -> setFavoriteLanguage(lang, isFavorite) }, + ) + } + + OnClickOK { + LanguageUtil.saveSelectedLanguages(project, selectedLanguages) + properties().setValue(Constants.KEY_IS_OVERWRITE_EXISTING_STRING, overwriteExistingState.value) + properties().setValue(Constants.KEY_IS_OPEN_TRANSLATED_FILE, openTranslatedFileState.value) + onClickListener?.onClickListener(selectedLanguages.toList()) + } + + okAction.isEnabled = selectedLanguages.isNotEmpty() } - override fun doOKAction() { - project?.let { LanguageUtil.saveSelectedLanguage(it, selectedLanguages) } - properties().setValue(Constants.KEY_IS_SELECT_ALL, selectAllState.value) - properties().setValue(Constants.KEY_IS_OVERWRITE_EXISTING_STRING, overwriteExistingState.value) - properties().setValue(Constants.KEY_IS_OPEN_TRANSLATED_FILE, openTranslatedFileState.value) - onClickListener?.onClickListener(selectedLanguages.toList()) - super.doOKAction() + override fun preferredSize(): Dimension { + val (preferred, minimum) = calculateDialogSize() + window?.minimumSize = minimum + return preferred } - override fun getDimensionServiceKey(): String? { - val key = translator.key + override fun getDimensionServiceKey(): String { + val key = translatorService.getSelectedTranslator().key return "#com.airsaid.localization.ui.SelectLanguagesDialog#$key" } - private fun initState() { + private fun loadState() { val properties = properties() - translator = translatorService.getSelectedTranslator() - supportedLanguages = translator.supportedLanguages.sortedBy { it.englishName } - val savedLanguageIds = LanguageUtil.getSelectedLanguageIds(project) - selectedLanguages.clear() - if (!savedLanguageIds.isNullOrEmpty()) { - selectedLanguages.addAll(supportedLanguages.filter { savedLanguageIds.contains(it.id.toString()) }) + favoriteLanguages.clear() + val favoriteLanguageCodes = LanguageUtil.getFavoriteLanguageIds(project) + if (favoriteLanguageCodes.isNotEmpty()) { + favoriteLanguages.addAll(supportedLanguages.filter { favoriteLanguageCodes.contains(it.code) }) + } + if (favoriteLanguages.isEmpty() && defaultFavoriteCodes.isNotEmpty()) { + favoriteLanguages.addAll(supportedLanguages.filter { defaultFavoriteCodes.contains(it.code) }) } - selectAllState.value = properties.getBoolean(Constants.KEY_IS_SELECT_ALL) - if (selectAllState.value) { - selectedLanguages.clear() - selectedLanguages.addAll(supportedLanguages) + selectedLanguages.clear() + val selectedLanguageCodes = LanguageUtil.getSelectedLanguageIds(project) + if (selectedLanguageCodes.isNotEmpty()) { + selectedLanguages.addAll(supportedLanguages.filter { selectedLanguageCodes.contains(it.code) }) } overwriteExistingState.value = properties.getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING) openTranslatedFileState.value = properties.getBoolean(Constants.KEY_IS_OPEN_TRANSLATED_FILE) - okAction.isEnabled = selectedLanguages.isNotEmpty() + stateInitialized = true } - private fun handleSelectAll(checked: Boolean) { - selectAllState.value = checked + private fun selectAll(languages: List, checked: Boolean) { + languages.forEach { lang -> + selectLanguage(lang, checked) + } + } + + private fun selectLanguage(lang: Lang, checked: Boolean) { if (checked) { - selectedLanguages.clear() - selectedLanguages.addAll(supportedLanguages) + if (!selectedLanguages.contains(lang)) { + selectedLanguages.add(lang) + } } else { - selectedLanguages.clear() + selectedLanguages.remove(lang) } - okAction.isEnabled = selectedLanguages.isNotEmpty() + } + + private fun setFavoriteLanguage(lang: Lang, isFavorite: Boolean) { + if (isFavorite) { + if (!favoriteLanguages.contains(lang)) { + favoriteLanguages.add(lang) + } + } else { + if (favoriteLanguages.contains(lang)) { + favoriteLanguages.remove(lang) + } + } + LanguageUtil.saveFavoriteLanguages(project, favoriteLanguages) } private fun properties(): PropertiesComponent { - return if (project != null) PropertiesComponent.getInstance(project) else PropertiesComponent.getInstance() + return PropertiesComponent.getInstance(project) } private fun calculateDialogSize(): Pair { @@ -210,18 +240,27 @@ class SelectLanguagesDialog(private val project: Project?) : DialogWrapper(proje @Composable private fun SelectLanguagesContent( translator: AbstractTranslator, - supportedLanguages: List, + languages: List, + favoriteLanguages: SnapshotStateList, selectedLanguages: SnapshotStateList, - selectAllStateChecked: Boolean, overwriteExistingChecked: Boolean, openTranslatedFileChecked: Boolean, onSelectAllChanged: (Boolean) -> Unit, + onFavoriteSelectAllChanged: (Boolean) -> Unit, onOverwriteChanged: (Boolean) -> Unit, onOpenTranslatedFileChanged: (Boolean) -> Unit, onLanguageToggled: (Lang, Boolean) -> Unit, + onFavoriteToggle: (Lang, Boolean) -> Unit, ) { var filterText by rememberSaveable { mutableStateOf("") } + val favoriteSelectAllChecked by remember(selectedLanguages) { + derivedStateOf { favoriteLanguages.isNotEmpty() && favoriteLanguages.all { selectedLanguages.contains(it) } } + } + val languagesSelectAllChecked by remember(selectedLanguages) { + derivedStateOf { languages.isNotEmpty() && languages.all { selectedLanguages.contains(it) } } + } + Column( modifier = Modifier .fillMaxSize() @@ -231,15 +270,19 @@ private fun SelectLanguagesContent( LanguagesCard( filterText = filterText, onFilterChange = { filterText = it }, - allLanguages = supportedLanguages, + languages = languages, + favoriteLanguages = favoriteLanguages, selectedLanguages = selectedLanguages, - selectAll = selectAllStateChecked, + selectAll = languagesSelectAllChecked, + favoriteSelectAll = favoriteSelectAllChecked, overwriteExisting = overwriteExistingChecked, openTranslatedFile = openTranslatedFileChecked, onSelectAllChanged = onSelectAllChanged, + onFavoriteSelectAllChanged = onFavoriteSelectAllChanged, onOverwriteChanged = onOverwriteChanged, onOpenTranslatedFileChanged = onOpenTranslatedFileChanged, onLanguageToggled = onLanguageToggled, + onFavoriteToggle = onFavoriteToggle, modifier = Modifier.weight(1f, fill = true), ) @@ -252,25 +295,49 @@ private fun SelectLanguagesContent( private fun LanguagesCard( filterText: String, onFilterChange: (String) -> Unit, - allLanguages: List, + languages: List, + favoriteLanguages: SnapshotStateList, selectedLanguages: SnapshotStateList, selectAll: Boolean, + favoriteSelectAll: Boolean, overwriteExisting: Boolean, openTranslatedFile: Boolean, onSelectAllChanged: (Boolean) -> Unit, + onFavoriteSelectAllChanged: (Boolean) -> Unit, onOverwriteChanged: (Boolean) -> Unit, onOpenTranslatedFileChanged: (Boolean) -> Unit, onLanguageToggled: (Lang, Boolean) -> Unit, + onFavoriteToggle: (Lang, Boolean) -> Unit, modifier: Modifier = Modifier, ) { - val filteredLanguages = remember(filterText, allLanguages) { - if (filterText.isBlank()) allLanguages - else allLanguages.filter { + val filteredLanguages = remember(filterText, languages) { + if (filterText.isBlank()) { + languages + } else { + languages.filter { + it.code.contains(filterText, ignoreCase = true) || + it.englishName.contains(filterText, ignoreCase = true) || + it.name.contains(filterText, ignoreCase = true) + } + } + } + + val filteredFavoriteLanguages = if (filterText.isBlank()) { + favoriteLanguages.toList() + } else { + favoriteLanguages.filter { it.englishName.contains(filterText, ignoreCase = true) || it.code.contains(filterText, ignoreCase = true) } } + val favoriteSelectedCount by remember { + derivedStateOf { favoriteLanguages.count { selectedLanguages.contains(it) } } + } + val selectedLanguagesCount by remember { + derivedStateOf { selectedLanguages.count { !favoriteLanguages.contains(it) } } + } + Surface( modifier = modifier .fillMaxWidth() @@ -282,7 +349,7 @@ private fun LanguagesCard( ) { Column( modifier = Modifier - .fillMaxWidth() + .fillMaxSize() .padding(18.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { @@ -301,45 +368,118 @@ private fun LanguagesCard( modifier = Modifier.fillMaxWidth(), ) - LanguagesHeader( - total = allLanguages.size, - selected = selectedLanguages.size, - selectAll = selectAll, - onSelectAllChanged = onSelectAllChanged, + FavoriteLanguagesSection( + favoriteLanguages = favoriteLanguages, + filteredFavoriteLanguages = filteredFavoriteLanguages, + selectedLanguages = selectedLanguages, + selectedCount = favoriteSelectedCount, + selectAll = favoriteSelectAll, + onSelectAllChanged = onFavoriteSelectAllChanged, + onLanguageToggled = onLanguageToggled, + onFavoriteToggle = onFavoriteToggle, ) + LanguagesHeader( + title = "Languages", + total = languages.size, + selected = selectedLanguagesCount, + ) { + OptionItem( + text = "Select all", + tooltip = "Select every supported language.", + checked = selectAll, + onCheckedChange = onSelectAllChanged, + ) + } + LanguagesGrid( languages = filteredLanguages, selectedLanguages = selectedLanguages, + favoriteLanguages = favoriteLanguages, onLanguageToggled = onLanguageToggled, + onFavoriteToggle = onFavoriteToggle, + emptyMessage = "No languages match your filter", + modifier = Modifier.weight(1f, fill = true), ) } } } +@Composable +private fun FavoriteLanguagesSection( + favoriteLanguages: SnapshotStateList, + filteredFavoriteLanguages: List, + selectedLanguages: SnapshotStateList, + selectedCount: Int, + selectAll: Boolean, + onSelectAllChanged: (Boolean) -> Unit, + onLanguageToggled: (Lang, Boolean) -> Unit, + onFavoriteToggle: (Lang, Boolean) -> Unit, +) { + val hasFavorites = favoriteLanguages.isNotEmpty() + val languagesToDisplay = if (hasFavorites) filteredFavoriteLanguages else emptyList() + val emptyMessage = when { + !hasFavorites -> "No favorite languages yet. \nClick the star beside a language below to add it." + languagesToDisplay.isEmpty() -> "No favorite languages match your filter" + else -> null + } + val resolvedEmptyMessage = emptyMessage ?: "No favorite languages match your filter" + val gridModifier = if (languagesToDisplay.isEmpty()) { + Modifier.padding(vertical = 8.dp) + } else { + Modifier.heightIn(max = 216.dp) + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + LanguagesHeader( + title = "Favorite Languages", + total = favoriteLanguages.size, + selected = selectedCount, + ) { + OptionItem( + text = "Select all", + tooltip = "Select every favorite language.", + checked = selectAll, + onCheckedChange = onSelectAllChanged, + ) + } + + LanguagesGrid( + languages = languagesToDisplay, + selectedLanguages = selectedLanguages, + favoriteLanguages = favoriteLanguages, + onLanguageToggled = onLanguageToggled, + onFavoriteToggle = onFavoriteToggle, + emptyMessage = resolvedEmptyMessage, + modifier = gridModifier, + emptyAlignment = Alignment.CenterStart, + ) + } +} + @Composable private fun LanguagesHeader( + title: String, total: Int, selected: Int, - selectAll: Boolean, - onSelectAllChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, + trailingContent: (@Composable RowScope.() -> Unit)? = null, ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Languages ($selected/$total)", + text = "$title ($selected/$total)", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(modifier = Modifier.weight(1f)) - OptionItem( - text = "Select all", - tooltip = "Select every supported language.", - checked = selectAll, - onCheckedChange = onSelectAllChanged, - ) + if (trailingContent != null) { + Spacer(modifier = Modifier.weight(1f)) + trailingContent() + } } } @@ -347,26 +487,43 @@ private fun LanguagesHeader( private fun LanguagesGrid( languages: List, selectedLanguages: SnapshotStateList, + favoriteLanguages: SnapshotStateList, onLanguageToggled: (Lang, Boolean) -> Unit, + onFavoriteToggle: (Lang, Boolean) -> Unit, + emptyMessage: String, + modifier: Modifier = Modifier, + emptyAlignment: Alignment = Alignment.Center, ) { + val containerModifier = modifier.fillMaxWidth() if (languages.isEmpty()) { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Text(text = "No languages match your filter", style = MaterialTheme.typography.bodyMedium) + Box(modifier = containerModifier, contentAlignment = emptyAlignment) { + Text( + text = emptyMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } else { - LazyVerticalGrid( - columns = GridCells.Fixed(4), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxSize() - ) { - items(languages, key = { it.id }) { language -> - LanguageOption( - language = language, - isSelected = language in selectedLanguages, - onToggle = { checked -> onLanguageToggled(language, checked) }, - ) + val languagesGridState = rememberLazyGridState() + Row(modifier = containerModifier, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + LazyVerticalGrid( + state = languagesGridState, + columns = GridCells.Fixed(4), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f, fill = true) + ) { + items(languages, key = { it.code }) { language -> + LanguageOption( + language = language, + isSelected = language in selectedLanguages, + isFavorite = language in favoriteLanguages, + onToggle = { checked -> onLanguageToggled(language, checked) }, + onFavoriteToggle = { checked -> onFavoriteToggle(language, checked) }, + ) + } } + VerticalScrollbar(rememberScrollbarAdapter(languagesGridState)) } } } @@ -456,17 +613,18 @@ private fun TooltipIcon(text: String) { private fun LanguageOption( language: Lang, isSelected: Boolean, + isFavorite: Boolean, onToggle: (Boolean) -> Unit, + onFavoriteToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, ) { - val flag = language.flagEmoji - val displayName = remember(language) { "${language.englishName} (${language.code})" } val backgroundColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) Row( - modifier = Modifier + modifier = modifier .defaultMinSize(minHeight = 64.dp) .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(12.dp)) .background(backgroundColor, RoundedCornerShape(12.dp)) @@ -482,17 +640,42 @@ private fun LanguageOption( verticalAlignment = Alignment.CenterVertically, ) { IdeCheckbox(checked = isSelected) - if (flag != null) { + Text( + text = language.flag, + style = MaterialTheme.typography.headlineMedium, + ) + Column( + modifier = Modifier.weight(1f, fill = true), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = language.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) Text( - text = flag, - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 20.sp), + text = "${language.englishName} (${language.code})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + IconToggleButton( + checked = isFavorite, + onCheckedChange = onFavoriteToggle, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites", + tint = if (isFavorite) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.45f + ), ) } - Text( - text = displayName, - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp), - color = MaterialTheme.colorScheme.onSurface, - ) } } @@ -503,7 +686,7 @@ private fun TranslatorFooter(translator: AbstractTranslator) { horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { - TranslatorIcon(icon = translator.icon) + SwingIcon(icon = translator.icon) Spacer(modifier = Modifier.width(8.dp)) Text( text = "${translator.name} Translator", @@ -511,24 +694,4 @@ private fun TranslatorFooter(translator: AbstractTranslator) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) } -} - -@Composable -private fun TranslatorIcon(icon: javax.swing.Icon?, modifier: Modifier = Modifier) { - if (icon == null) return - val imageBitmap = remember(icon) { icon.toImageBitmap() } - Image( - painter = remember(imageBitmap) { BitmapPainter(imageBitmap) }, - contentDescription = null, - modifier = modifier.size(20.dp), - ) -} - -private fun javax.swing.Icon.toImageBitmap(): ImageBitmap { - val image = java.awt.image.BufferedImage(iconWidth, iconHeight, java.awt.image.BufferedImage.TYPE_INT_ARGB) - val graphics = image.createGraphics() - graphics.background = java.awt.Color(0, 0, 0, 0) - paintIcon(null, graphics, 0, 0) - graphics.dispose() - return image.toComposeImageBitmap() -} +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/ui/components/SwingIcon.kt b/src/main/kotlin/com/airsaid/localization/ui/components/SwingIcon.kt new file mode 100644 index 0000000..eead50e --- /dev/null +++ b/src/main/kotlin/com/airsaid/localization/ui/components/SwingIcon.kt @@ -0,0 +1,38 @@ +package com.airsaid.localization.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.unit.dp +import java.awt.image.BufferedImage +import javax.swing.Icon + +/** + * A icon component to show a swing [Icon] in Jetpack Compose UI. + * + * @author airsaid + */ +@Composable +fun SwingIcon(icon: Icon?, modifier: Modifier = Modifier) { + if (icon == null) return + val imageBitmap = remember(icon) { icon.toImageBitmap() } + Image( + painter = remember(imageBitmap) { BitmapPainter(imageBitmap) }, + contentDescription = null, + modifier = modifier.size(20.dp), + ) +} + +private fun Icon.toImageBitmap(): ImageBitmap { + val image = BufferedImage(iconWidth, iconHeight, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + graphics.background = java.awt.Color(0, 0, 0, 0) + paintIcon(null, graphics, 0, 0) + graphics.dispose() + return image.toComposeImageBitmap() +} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/utils/LanguageUtil.kt b/src/main/kotlin/com/airsaid/localization/utils/LanguageUtil.kt index 4db7b50..e181c5f 100644 --- a/src/main/kotlin/com/airsaid/localization/utils/LanguageUtil.kt +++ b/src/main/kotlin/com/airsaid/localization/utils/LanguageUtil.kt @@ -19,6 +19,7 @@ package com.airsaid.localization.utils import com.airsaid.localization.constant.Constants import com.airsaid.localization.translate.lang.Lang +import com.airsaid.localization.translate.lang.Languages import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.project.Project import org.apache.http.util.TextUtils @@ -30,37 +31,66 @@ import org.apache.http.util.TextUtils */ object LanguageUtil { - private const val SEPARATOR_SELECTED_LANGUAGES_CODE = "," + private const val SEPARATOR_SELECTED_LANGUAGES_CODE = "," - /** - * Save the language data selected in the current project. - * - * @param project current project. - * @param languages selected language. - */ - fun saveSelectedLanguage(project: Project, languages: List) { - PropertiesComponent.getInstance(project) - .setValue(Constants.KEY_SELECTED_LANGUAGES, getLanguageIdString(languages)) - } + /** + * Persist the selected language codes in the current project. + * + * @param project current project. + * @param languages selected language. + */ + fun saveSelectedLanguages(project: Project, languages: List) { + PropertiesComponent.getInstance(project) + .setValue(Constants.KEY_SELECTED_LANGUAGES, getLanguageCodeString(languages)) + } - /** - * Get saved language code data in the current project. - * - * @param project current project. - * @return null if not saved, otherwise return the saved language id data. - */ - fun getSelectedLanguageIds(project: Project?): List? { - val codeString = PropertiesComponent.getInstance(project!!) - .getValue(Constants.KEY_SELECTED_LANGUAGES) + /** + * Fetch the persisted selected language codes for the given project. + * + * @param project current project. + * @return the selected language codes, or null if not set before. + */ + fun getSelectedLanguageIds(project: Project): List { + val codeString = PropertiesComponent.getInstance(project) + .getValue(Constants.KEY_SELECTED_LANGUAGES) - if (TextUtils.isEmpty(codeString)) { - return null - } + return parseStoredLanguageCodes(codeString) + } - return codeString!!.split(SEPARATOR_SELECTED_LANGUAGES_CODE) - } + /** + * Persist the favorite language codes in the current project. + * + * @param project current project. + * @param languages favorite language. + */ + fun saveFavoriteLanguages(project: Project, languages: List) { + PropertiesComponent.getInstance(project) + .setValue(Constants.KEY_FAVORITE_LANGUAGES, getLanguageCodeString(languages)) + } - private fun getLanguageIdString(language: List): String { - return language.joinToString(SEPARATOR_SELECTED_LANGUAGES_CODE) { it.id.toString() } - } -} \ No newline at end of file + /** + * Fetch the persisted favorite language codes for the given project. + * + * @param project current project. + * @return the favorite language codes, or null if not set before. + */ + fun getFavoriteLanguageIds(project: Project): List { + val codeString = PropertiesComponent.getInstance(project) + .getValue(Constants.KEY_FAVORITE_LANGUAGES) + + return parseStoredLanguageCodes(codeString) + } + + private fun getLanguageCodeString(language: List): String { + return language.joinToString(SEPARATOR_SELECTED_LANGUAGES_CODE) { it.code } + } + + private fun parseStoredLanguageCodes(value: String?): List { + if (value.isNullOrEmpty()) return emptyList() + + return value.split(SEPARATOR_SELECTED_LANGUAGES_CODE) + .map { it.trim() } + .filter { it.isNotEmpty() && Languages.entries.any { lang -> lang.code == it } } + .distinct() + } +} From b8959c92813f4b53448262e66b0dda3de930dea0 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 17:22:53 +0800 Subject: [PATCH 37/58] Center align empty message in LanguagesGrid --- .../com/airsaid/localization/ui/SelectLanguagesDialog.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt index 428ea9d..98ef7ad 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.airsaid.localization.constant.Constants @@ -454,7 +455,6 @@ private fun FavoriteLanguagesSection( onFavoriteToggle = onFavoriteToggle, emptyMessage = resolvedEmptyMessage, modifier = gridModifier, - emptyAlignment = Alignment.CenterStart, ) } } @@ -501,6 +501,7 @@ private fun LanguagesGrid( text = emptyMessage, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) } } else { From 06e80c8d99df395effddd083c87c246ceaefe140 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 17:39:46 +0800 Subject: [PATCH 38/58] Refactor property assignment in translation task --- src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt index 6862034..88dd096 100644 --- a/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt +++ b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt @@ -205,7 +205,7 @@ class TranslateTask( try { val translatedText = translatorService.doTranslate(Languages.AUTO.toLang(), toLanguage, text) ApplicationManager.getApplication().runReadAction { - child.setValue(translatedText) + child.value = translatedText } } catch (e: TranslationException) { LOG.warn(e) From 28ad537e416def5f89ccb69b5b83402dd46bb97e Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 17:55:28 +0800 Subject: [PATCH 39/58] Add settings button to SelectLanguagesDialog footer --- .../localization/ui/SelectLanguagesDialog.kt | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt index 98ef7ad..0e3815b 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Star import androidx.compose.material3.* import androidx.compose.runtime.* @@ -39,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.airsaid.localization.config.SettingsConfigurable import com.airsaid.localization.constant.Constants import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.lang.Lang @@ -48,6 +50,7 @@ import com.airsaid.localization.ui.components.IdeCheckbox import com.airsaid.localization.ui.components.SwingIcon import com.airsaid.localization.utils.LanguageUtil import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project import java.awt.Dimension import java.awt.Toolkit @@ -135,6 +138,7 @@ class SelectLanguagesDialog(private val project: Project) : ComposeDialog(projec onOpenTranslatedFileChanged = { checked -> openTranslatedFileState.value = checked }, onLanguageToggled = { lang, checked -> selectLanguage(lang, checked) }, onFavoriteToggle = { lang, isFavorite -> setFavoriteLanguage(lang, isFavorite) }, + onOpenSettings = { openPluginSettings() }, ) } @@ -236,6 +240,10 @@ class SelectLanguagesDialog(private val project: Project) : ComposeDialog(projec val minimum = Dimension(minWidth, minHeight) return preferred to minimum } + + private fun openPluginSettings() { + ShowSettingsUtil.getInstance().showSettingsDialog(project, SettingsConfigurable::class.java) + } } @Composable @@ -252,6 +260,7 @@ private fun SelectLanguagesContent( onOpenTranslatedFileChanged: (Boolean) -> Unit, onLanguageToggled: (Lang, Boolean) -> Unit, onFavoriteToggle: (Lang, Boolean) -> Unit, + onOpenSettings: () -> Unit, ) { var filterText by rememberSaveable { mutableStateOf("") } @@ -287,7 +296,7 @@ private fun SelectLanguagesContent( modifier = Modifier.weight(1f, fill = true), ) - TranslatorFooter(translator = translator) + TranslatorFooter(translator = translator, onOpenSettings = onOpenSettings) } } @@ -680,8 +689,9 @@ private fun LanguageOption( } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun TranslatorFooter(translator: AbstractTranslator) { +private fun TranslatorFooter(translator: AbstractTranslator, onOpenSettings: () -> Unit) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, @@ -694,5 +704,30 @@ private fun TranslatorFooter(translator: AbstractTranslator) { style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), color = MaterialTheme.colorScheme.onSurfaceVariant, ) + TooltipArea( + tooltip = { + Surface( + shape = RoundedCornerShape(6.dp), + shadowElevation = 4.dp, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Text( + text = "Open plugin settings", + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + delayMillis = 300, + ) { + IconButton(onClick = onOpenSettings) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = "Open plugin settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } -} \ No newline at end of file +} From 326210bcceee1524fb5f6fdc4bb97b4b8a238ff2 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 18:01:07 +0800 Subject: [PATCH 40/58] Reduce minimum translation interval to 50ms --- .../kotlin/com/airsaid/localization/config/SettingsState.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt index 8021f75..8ebe5c7 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsState.kt @@ -152,7 +152,7 @@ class SettingsState : PersistentStateComponent { state.translationInterval *= 1000 } if (state.translationInterval <= 0) { - state.translationInterval = 500 + state.translationInterval = 50 } } From 8f27fd783691c0aa0e6b6cf0a9cad560c7b4d92e Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 18:24:01 +0800 Subject: [PATCH 41/58] Add donation section to settings UI --- .../localization/config/SettingsComponent.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt index eaf65b2..4a6ff58 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt @@ -43,6 +43,7 @@ import com.airsaid.localization.ui.IdeTheme import com.airsaid.localization.ui.SupportedLanguagesDialog import com.airsaid.localization.ui.components.IdeSwitch import com.airsaid.localization.ui.components.IdeTextField +import com.intellij.ide.BrowserUtil import com.intellij.openapi.diagnostic.Logger import com.intellij.util.ui.UIUtil import java.awt.Dimension @@ -153,6 +154,8 @@ private val TranslatorDropdownWidth = 280.dp private val CompactFieldHeight = 36.dp private val FormContentSpacing = 8.dp private const val MAX_REQUEST_INTERVAL_MS = 60_000 +private const val DONATION_URL = + "https://github.com/Airsaid/AndroidLocalizePlugin/blob/master/README.md#support-and-donations" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -286,6 +289,11 @@ private fun SettingsContent( } ) } + + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f)) + + SectionHeader(title = "Donation") + DonationSection() } } @@ -341,6 +349,24 @@ private fun SettingsFormRow( } } +@Composable +private fun DonationSection() { + Column { + Text( + text = "If this plugin has helped simplify your localization workflow,\nconsider supporting its ongoing development so it can continue to improve.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + TextButton( + onClick = { BrowserUtil.browse(DONATION_URL) }, + modifier = Modifier.align(Alignment.Start) + ) { + Text("Buy me a coffee ☕") + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TranslatorDropdown( From de8bc08a3e2863578d31a45e275d028bfa6346d8 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 18:42:41 +0800 Subject: [PATCH 42/58] Add and update KDoc comments for classes and methods --- .../localization/config/SettingsComponent.kt | 2 ++ .../config/TranslatorConfigurationManager.kt | 5 ++++ .../config/TranslatorCredentialsDialog.kt | 8 ++++++ .../TranslatorCredentialDescriptor.kt | 5 ++++ .../deepl/DeepLTranslatorCredentialsDialog.kt | 8 ++++++ .../impl/deepl/DeepLTranslatorSettings.kt | 5 ++++ .../translate/impl/google/GoogleHttp.kt | 10 ++++++++ .../translate/impl/google/GoogleToken.kt | 15 +++++++++++ .../impl/google/GoogleTranslationResponse.kt | 5 ++++ .../translate/impl/google/GoogleTranslator.kt | 8 ++++++ .../impl/google/GoogleTranslatorSettings.kt | 5 ++++ .../google/GoogleTranslatorSettingsDialog.kt | 6 +++++ .../microsoft/MicrosoftEdgeAuthService.kt | 2 ++ .../impl/microsoft/MicrosoftExceptions.kt | 5 ++++ .../translate/impl/openai/OpenAIModels.kt | 20 +++++++++++++++ .../translate/impl/openai/OpenAIResponse.kt | 7 +++++- .../translate/impl/openai/OpenAITranslator.kt | 8 ++++++ .../impl/openai/OpenAITranslatorSettings.kt | 8 ++++++ .../openai/OpenAITranslatorSettingsDialog.kt | 6 +++++ .../translate/util/HttpRequestFactory.kt | 2 ++ .../airsaid/localization/ui/ComposeDialog.kt | 10 +++++++- .../localization/ui/IdeComposeTheme.kt | 5 ++++ .../ui/components/FormControls.kt | 25 +++++++++++++++++++ .../AbstractTranslatorNetworkTest.kt | 10 ++++++++ .../services/TranslatorServiceTest.kt | 10 ++++++++ 25 files changed, 198 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt index 4a6ff58..963fcd9 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsComponent.kt @@ -56,6 +56,8 @@ import androidx.compose.foundation.layout.Arrangement as LayoutArrangement /** * Compose implementation of the settings panel exposed through the IDE Settings. + * + * @author airsaid */ class SettingsComponent { companion object { diff --git a/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt b/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt index 7e5b556..fbb08ae 100644 --- a/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt +++ b/src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt @@ -6,6 +6,11 @@ import com.airsaid.localization.translate.impl.google.GoogleTranslatorSettingsDi import com.airsaid.localization.translate.impl.openai.OpenAITranslatorSettingsDialog import com.intellij.openapi.diagnostic.Logger +/** + * Routes translator configuration requests to the appropriate UI dialog. + * + * @author airsaid + */ object TranslatorConfigurationManager { private val LOG = Logger.getInstance(TranslatorConfigurationManager::class.java) diff --git a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt index 05aba96..8e98375 100644 --- a/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt @@ -16,6 +16,11 @@ import com.airsaid.localization.ui.ComposeDialog import com.airsaid.localization.ui.components.IdeTextField import com.intellij.ide.BrowserUtil +/** + * Base Compose dialog for collecting and persisting translator credentials. + * + * @author airsaid + */ open class TranslatorCredentialsDialog( protected val translator: AbstractTranslator, protected val settingsState: SettingsState, @@ -83,6 +88,9 @@ open class TranslatorCredentialsDialog( } } + /** + * Persists the sanitized credential values before closing the dialog. + */ override fun doOKAction() { super.doOKAction() credentialValuesState.forEach { (id, value) -> diff --git a/src/main/kotlin/com/airsaid/localization/translate/TranslatorCredentialDescriptor.kt b/src/main/kotlin/com/airsaid/localization/translate/TranslatorCredentialDescriptor.kt index 58a28c0..b591d44 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/TranslatorCredentialDescriptor.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/TranslatorCredentialDescriptor.kt @@ -1,5 +1,10 @@ package com.airsaid.localization.translate +/** + * Descriptor of a credential field required by a translator. + * + * @author airsaid + */ data class TranslatorCredentialDescriptor( val id: String, val label: String, diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorCredentialsDialog.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorCredentialsDialog.kt index 3c879da..1ea637c 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorCredentialsDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorCredentialsDialog.kt @@ -9,6 +9,11 @@ import com.airsaid.localization.config.TranslatorCredentialsDialog import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.ui.components.IdeCheckBox +/** + * Credentials dialog that exposes DeepL-specific configuration switches. + * + * @author airsaid + */ class DeepLTranslatorCredentialsDialog( translator: AbstractTranslator, settingsState: SettingsState, @@ -29,6 +34,9 @@ class DeepLTranslatorCredentialsDialog( ) } + /** + * Persists the DeepL Pro preference alongside credential values. + */ override fun doOKAction() { super.doOKAction() deeplSettings.usePro = useDeepLPro diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorSettings.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorSettings.kt index f54a2f3..392368a 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorSettings.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorSettings.kt @@ -6,6 +6,11 @@ import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage import com.intellij.openapi.components.service +/** + * Persistent storage for DeepL translator runtime preferences. + * + * @author airsaid + */ @Service @State(name = "com.airsaid.localization.DeepLTranslatorSettings", storages = [Storage("deeplTranslatorSettings.xml")]) class DeepLTranslatorSettings : PersistentStateComponent { diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt index 1c312f8..ad283f8 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt @@ -4,6 +4,11 @@ import com.intellij.util.io.RequestBuilder private const val GOOGLE_REFERER = "https://translate.google.com/" +/** + * Builds the Google translate HTTP endpoint, respecting custom server overrides. + * + * @author airsaid + */ internal fun googleApiUrl(path: String): String { val settings = GoogleTranslatorSettings.getInstance() val base = if (settings.useCustomServer) settings.serverUrl else GoogleTranslatorSettings.DEFAULT_SERVER_URL @@ -12,6 +17,11 @@ internal fun googleApiUrl(path: String): String { return "$normalizedBase/$normalizedPath" } +/** + * Applies the HTTP headers expected by the Google translate service. + * + * @author airsaid + */ internal fun RequestBuilder.withGoogleHeaders(): RequestBuilder = apply { tuner { connection -> connection.setRequestProperty("Referer", GOOGLE_REFERER) diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt index 7832d78..30f89e1 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt @@ -19,12 +19,22 @@ private val LOG: Logger = Logger.getInstance("GoogleToken") private var cachedToken: Token? = null private val tokenLock = Any() +/** + * Clears any cached TKK tokens so subsequent calls fetch fresh credentials. + * + * @author airsaid + */ internal fun resetGoogleTokenCache() { synchronized(tokenLock) { cachedToken = null } } +/** + * Returns the current TKK token pair, fetching or regenerating when required. + * + * @author airsaid + */ @RequiresBackgroundThread internal fun currentTkk(): Pair { synchronized(tokenLock) { @@ -65,6 +75,11 @@ private fun generateLocal(hour: Long): Token { return Token(hour, value, hour) } +/** + * Computes the Google translate token for the given payload. + * + * @author airsaid + */ internal fun String.tk(): String { val (d, e) = currentTkk() val bytes = mutableListOf() diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResponse.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResponse.kt index d625327..7559e51 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResponse.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResponse.kt @@ -2,6 +2,11 @@ package com.airsaid.localization.translate.impl.google import com.google.gson.annotations.SerializedName +/** + * Response model capturing fields returned by the Google translate endpoint. + * + * @author airsaid + */ internal data class GoogleTranslationResponse( @SerializedName("sentences") val sentences: List?, @SerializedName("src") val sourceLanguage: String?, diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt index 2eeee24..c19cd22 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt @@ -14,6 +14,11 @@ import com.intellij.util.io.RequestBuilder import icons.PluginIcons import javax.swing.Icon +/** + * Translator implementation that proxies requests through the Google translate web endpoint. + * + * @author airsaid + */ @AutoService(AbstractTranslator::class) class GoogleTranslator : AbsGoogleTranslator() { @@ -46,6 +51,9 @@ class GoogleTranslator : AbsGoogleTranslator() { requestBuilder.withGoogleHeaders() } + /** + * Parses the JSON payload and surfaces API errors as `TranslationException`. + */ override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { val response = GsonUtil.getInstance().gson.fromJson(resultText, GoogleTranslationResponse::class.java) response.error?.message?.let { message -> diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettings.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettings.kt index f50dca3..42959d8 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettings.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettings.kt @@ -6,6 +6,11 @@ import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage import com.intellij.openapi.components.service +/** + * Persisted configuration controlling how Google translator requests are routed. + * + * @author airsaid + */ @Service @State(name = "com.airsaid.localization.GoogleTranslatorSettings", storages = [Storage("googleTranslatorSettings.xml")]) class GoogleTranslatorSettings : PersistentStateComponent { diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt index c4e5c60..94ba794 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt @@ -15,6 +15,11 @@ import com.airsaid.localization.ui.components.IdeCheckBox import com.airsaid.localization.ui.components.IdeTextField import java.awt.Dimension +/** + * Compose dialog allowing users to toggle the Google translator backend address. + * + * @author airsaid + */ class GoogleTranslatorSettingsDialog : ComposeDialog() { private val settings = GoogleTranslatorSettings.getInstance() @@ -78,6 +83,7 @@ class GoogleTranslatorSettingsDialog : ComposeDialog() { } OnClickOK { + // Persist the selected endpoint and toggle when the user accepts the dialog. settings.useCustomServer = useCustomServer if (useCustomServer) { settings.serverUrl = serverUrl.ifBlank { GoogleTranslatorSettings.DEFAULT_SERVER_URL } diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt index 74b100a..5b66cfa 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt @@ -17,6 +17,8 @@ import java.util.concurrent.atomic.AtomicReference * Fetches and caches Microsoft Translator access tokens using the same public * endpoint leveraged by the Microsoft Edge browser. This removes the need for * user-provided subscription keys. + * + * @author airsaid */ @Service class MicrosoftEdgeAuthService { diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftExceptions.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftExceptions.kt index ddd4500..6e8d17c 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftExceptions.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftExceptions.kt @@ -2,6 +2,11 @@ package com.airsaid.localization.translate.impl.microsoft import java.io.IOException +/** + * Exception thrown when Microsoft authentication fails. + * + * @author airsaid + */ class MicrosoftAuthenticationException( message: String? = null, cause: Throwable? = null diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIModels.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIModels.kt index 75a704c..851853d 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIModels.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIModels.kt @@ -2,21 +2,41 @@ package com.airsaid.localization.translate.impl.openai import com.google.gson.annotations.SerializedName +/** + * Request payload sent to the OpenAI chat completions endpoint. + * + * @author airsaid + */ data class OpenAIRequest( var model: String, var messages: List ) +/** + * Single message exchanged with the OpenAI chat API. + * + * @author airsaid + */ data class OpenAIMessage( var role: String, var content: String ) +/** + * Response wrapper returned when listing available OpenAI models. + * + * @author airsaid + */ data class OpenAIModelsResponse( @SerializedName("data") val data: List = emptyList() ) +/** + * Model descriptor returned by the OpenAI catalog API. + * + * @author airsaid + */ data class OpenAIModel( @SerializedName("id") val id: String = "" diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt index b353143..f74d3a1 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt @@ -19,6 +19,11 @@ package com.airsaid.localization.translate.impl.openai import com.google.gson.annotations.SerializedName +/** + * Response envelope returned by the OpenAI chat completions API. + * + * @author airsaid + */ data class OpenAIResponse( var choices: List?, var created: Int?, @@ -56,4 +61,4 @@ data class OpenAIResponse( @SerializedName("total_tokens") var totalTokens: Int? ) -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt index 3236145..0905915 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt @@ -27,6 +27,11 @@ import com.intellij.openapi.diagnostic.Logger import com.intellij.util.io.RequestBuilder import icons.PluginIcons +/** + * Translator backed by the OpenAI chat completions API. + * + * @author airsaid + */ @AutoService(AbstractTranslator::class) class OpenAITranslator : AbstractTranslator() { companion object { @@ -50,6 +55,9 @@ class OpenAITranslator : AbstractTranslator() { override val requestContentType: String get() = "application/json" + /** + * Verifies credentials before delegating to the base network workflow. + */ @Throws(TranslationException::class) override fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { if (credentialValue("appKey").isBlank()) { diff --git a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt index 410f5f1..f5a05ad 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt @@ -3,6 +3,11 @@ package com.airsaid.localization.translate.impl.openai import com.intellij.openapi.components.* import java.net.URI +/** + * Persisted configuration controlling OpenAI translator models and API host. + * + * @author airsaid + */ @Service @State( name = "com.airsaid.localization.OpenAITranslatorSettings", @@ -102,6 +107,9 @@ class OpenAITranslatorSettings : PersistentStateComponent Unit) { val isDark = UIUtil.isUnderDarcula() diff --git a/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt b/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt index ea74d98..ac6ef8a 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/components/FormControls.kt @@ -28,6 +28,11 @@ import androidx.compose.ui.unit.dp private val CompactFieldHeight = 36.dp private val CompactDropdownHeight = 32.dp +/** + * IntelliJ-styled text field wrapper that supports secure input and Compose slots. + * + * @author airsaid + */ @OptIn(ExperimentalMaterial3Api::class) @Composable @Suppress("LongParameterList") @@ -116,6 +121,11 @@ fun IdeTextField( } } +/** + * Dropdown field that mirrors IntelliJ look and feel while showing a loading indicator. + * + * @author airsaid + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun IdeDropdownField( @@ -214,6 +224,11 @@ fun IdeDropdownField( } } +/** + * Check box row with optional title and subtitle consistent with IDE visuals. + * + * @author airsaid + */ @Composable fun IdeCheckBox( checked: Boolean, @@ -258,6 +273,11 @@ fun IdeCheckBox( } } +/** + * Compact checkbox indicator used within other form controls. + * + * @author airsaid + */ @Composable fun IdeCheckbox( checked: Boolean, @@ -295,6 +315,11 @@ fun IdeCheckbox( } } +/** + * Toggle switch preconfigured to match IntelliJ color tokens. + * + * @author airsaid + */ @Composable fun IdeSwitch( checked: Boolean, diff --git a/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt b/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt index cad98e9..d14f7da 100644 --- a/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt +++ b/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt @@ -21,6 +21,11 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference +/** + * Integration-style tests exercising the HTTP workflow shared by translators. + * + * @author airsaid + */ class AbstractTranslatorNetworkTest { private lateinit var server: HttpServer @@ -134,6 +139,11 @@ class AbstractTranslatorNetworkTest { } } + /** + * Minimal translator harness pointing to the test server. + * + * @author airsaid + */ private open inner class TestTranslator(private val endpoint: String) : AbstractTranslator() { override val key: String = "Test" override val name: String = "Test" diff --git a/src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt b/src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt index 498c64e..5eddbcb 100644 --- a/src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt +++ b/src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt @@ -5,6 +5,11 @@ import com.airsaid.localization.translate.lang.Lang import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +/** + * Unit tests covering translator selection logic. + * + * @author airsaid + */ class TranslatorServiceTest { @Test @@ -21,6 +26,11 @@ class TranslatorServiceTest { assertEquals(first, defaultTranslator) } + /** + * Minimal translator stub used to drive selection scenarios. + * + * @author airsaid + */ private class StubTranslator(override val key: String) : AbstractTranslator() { override val name: String = key override val supportedLanguages: List = emptyList() From b8423cf0511e267d00499f2f6dd267a8ddf5ae8e Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 19:12:33 +0800 Subject: [PATCH 43/58] Format codebase and remove unused utility classes --- build.gradle.kts | 235 +++++++++--------- settings.gradle.kts | 2 +- .../localization/action/TranslateAction.kt | 122 +++++---- .../config/SettingsConfigurable.kt | 161 ++++++------ .../translate/TranslationResult.kt | 12 +- .../localization/translate/util/AgentUtil.kt | 50 ---- .../localization/translate/util/GsonUtil.kt | 16 +- .../localization/translate/util/LRUCache.kt | 154 ++++++------ .../localization/translate/util/MD5.kt | 50 ++-- .../localization/translate/util/UrlBuilder.kt | 50 ++-- .../airsaid/localization/ui/FixedLinkLabel.kt | 54 ---- .../localization/ui/IdeComposeTheme.kt | 122 ++++----- .../localization/utils/NotificationUtil.kt | 30 +-- .../localization/utils/SecureStorage.kt | 24 +- .../airsaid/localization/utils/TextUtil.kt | 20 +- src/main/kotlin/icons/PluginIcons.kt | 38 +-- .../AbstractTranslatorNetworkTest.kt | 184 -------------- .../translate/util/LRUCacheTest.kt | 58 ++--- .../translate/util/UrlBuilderTest.kt | 70 +++--- .../localization/utils/TextUtilTest.kt | 45 ++-- 20 files changed, 604 insertions(+), 893 deletions(-) delete mode 100644 src/main/kotlin/com/airsaid/localization/translate/util/AgentUtil.kt delete mode 100644 src/main/kotlin/com/airsaid/localization/ui/FixedLinkLabel.kt delete mode 100644 src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index a7f421f..80a18c5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,167 +1,166 @@ -import org.gradle.process.CommandLineArgumentProvider import org.jetbrains.changelog.Changelog import org.jetbrains.changelog.markdownToHTML import org.jetbrains.intellij.platform.gradle.TestFrameworkType plugins { - id("java") - alias(libs.plugins.kotlin) - alias(libs.plugins.kotlinKapt) - alias(libs.plugins.composeCompiler) - alias(libs.plugins.intelliJPlatform) - alias(libs.plugins.changelog) - alias(libs.plugins.qodana) - alias(libs.plugins.kover) - alias(libs.plugins.compose) + id("java") + alias(libs.plugins.kotlin) + alias(libs.plugins.kotlinKapt) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.intelliJPlatform) + alias(libs.plugins.changelog) + alias(libs.plugins.qodana) + alias(libs.plugins.kover) + alias(libs.plugins.compose) } group = providers.gradleProperty("pluginGroup").get() version = providers.gradleProperty("pluginVersion").get() kotlin { - jvmToolchain(21) + jvmToolchain(21) } repositories { - mavenCentral() - google() - maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + mavenCentral() + google() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") - intellijPlatform { - defaultRepositories() - } + intellijPlatform { + defaultRepositories() + } } dependencies { - implementation(libs.gson) - implementation(libs.alimt) - implementation(compose.desktop.currentOs) - implementation(compose.material3) - implementation(compose.foundation) - - compileOnly(libs.autoServiceAnnotations) - kapt(libs.autoService) - - testImplementation(libs.junitJupiterApi) - testRuntimeOnly(libs.junitJupiterEngine) - testRuntimeOnly(libs.junitPlatformLauncher) - testRuntimeOnly(libs.junit4) - - intellijPlatform { - create( - providers.gradleProperty("platformType"), - providers.gradleProperty("platformVersion"), - ) - - bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',').filter(String::isNotBlank) }) - bundledModules(providers.gradleProperty("platformBundledModules").map { it.split(',').filter(String::isNotBlank) }) - plugins(providers.gradleProperty("platformPlugins").map { it.split(',').filter(String::isNotBlank) }) - - testFramework(TestFrameworkType.JUnit5) - } + implementation(libs.gson) + implementation(libs.alimt) + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.foundation) + + compileOnly(libs.autoServiceAnnotations) + kapt(libs.autoService) + + testImplementation(libs.junitJupiterApi) + testRuntimeOnly(libs.junitJupiterEngine) + testRuntimeOnly(libs.junitPlatformLauncher) + testRuntimeOnly(libs.junit4) + + intellijPlatform { + create( + providers.gradleProperty("platformType"), + providers.gradleProperty("platformVersion"), + ) + + bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',').filter(String::isNotBlank) }) + bundledModules(providers.gradleProperty("platformBundledModules").map { it.split(',').filter(String::isNotBlank) }) + plugins(providers.gradleProperty("platformPlugins").map { it.split(',').filter(String::isNotBlank) }) + + testFramework(TestFrameworkType.JUnit5) + } } intellijPlatform { - pluginConfiguration { - name = providers.gradleProperty("pluginName") - version = providers.gradleProperty("pluginVersion") - - description = providers.fileContents(layout.projectDirectory.file("README.md")).asText.map { - val start = "" - val end = "" - - with(it.lines()) { - if (!containsAll(listOf(start, end))) { - throw GradleException("Plugin description section not found in README.md:\n$start ... $end") - } - subList(indexOf(start) + 1, indexOf(end)).joinToString("\n").let(::markdownToHTML) - } - } + pluginConfiguration { + name = providers.gradleProperty("pluginName") + version = providers.gradleProperty("pluginVersion") - val changelog = project.changelog - changeNotes = providers.gradleProperty("pluginVersion").map { pluginVersion -> - with(changelog) { - renderItem( - (getOrNull(pluginVersion) ?: getUnreleased()) - .withHeader(false) - .withEmptySections(false), - Changelog.OutputType.HTML, - ) - } - } + description = providers.fileContents(layout.projectDirectory.file("README.md")).asText.map { + val start = "" + val end = "" - ideaVersion { - sinceBuild = providers.gradleProperty("pluginSinceBuild") - untilBuild = providers.gradleProperty("pluginUntilBuild").map { it.ifBlank { null } } + with(it.lines()) { + if (!containsAll(listOf(start, end))) { + throw GradleException("Plugin description section not found in README.md:\n$start ... $end") } + subList(indexOf(start) + 1, indexOf(end)).joinToString("\n").let(::markdownToHTML) + } } - signing { - certificateChain = providers.environmentVariable("CERTIFICATE_CHAIN") - privateKey = providers.environmentVariable("PRIVATE_KEY") - password = providers.environmentVariable("PRIVATE_KEY_PASSWORD") + val changelog = project.changelog + changeNotes = providers.gradleProperty("pluginVersion").map { pluginVersion -> + with(changelog) { + renderItem( + (getOrNull(pluginVersion) ?: getUnreleased()) + .withHeader(false) + .withEmptySections(false), + Changelog.OutputType.HTML, + ) + } } - publishing { - token = providers.environmentVariable("PUBLISH_TOKEN") - channels = providers.gradleProperty("pluginVersion").map { - listOf(it.substringAfter('-', "").substringBefore('.').ifEmpty { "default" }) - } + ideaVersion { + sinceBuild = providers.gradleProperty("pluginSinceBuild") + untilBuild = providers.gradleProperty("pluginUntilBuild").map { it.ifBlank { null } } + } + } + + signing { + certificateChain = providers.environmentVariable("CERTIFICATE_CHAIN") + privateKey = providers.environmentVariable("PRIVATE_KEY") + password = providers.environmentVariable("PRIVATE_KEY_PASSWORD") + } + + publishing { + token = providers.environmentVariable("PUBLISH_TOKEN") + channels = providers.gradleProperty("pluginVersion").map { + listOf(it.substringAfter('-', "").substringBefore('.').ifEmpty { "default" }) } + } - pluginVerification { - ides { - recommended() - } + pluginVerification { + ides { + recommended() } + } } changelog { - groups.empty() - repositoryUrl = providers.gradleProperty("pluginRepositoryUrl") + groups.empty() + repositoryUrl = providers.gradleProperty("pluginRepositoryUrl") } kover { - reports { - total { - xml { - onCheck = true - } - } + reports { + total { + xml { + onCheck = true + } } + } } intellijPlatformTesting { - runIde { - register("runIdeForUiTests") { - task { - jvmArgumentProviders += CommandLineArgumentProvider { - listOf( - "-Drobot-server.port=8082", - "-Dide.mac.message.dialogs.as.sheets=false", - "-Djb.privacy.policy.text=", - "-Djb.consents.confirmation.enabled=false", - ) - } - } - - plugins { - robotServerPlugin() - } + runIde { + register("runIdeForUiTests") { + task { + jvmArgumentProviders += CommandLineArgumentProvider { + listOf( + "-Drobot-server.port=8082", + "-Dide.mac.message.dialogs.as.sheets=false", + "-Djb.privacy.policy.text=", + "-Djb.consents.confirmation.enabled=false", + ) } + } + + plugins { + robotServerPlugin() + } } + } } tasks { - wrapper { - gradleVersion = providers.gradleProperty("gradleVersion").get() - } + wrapper { + gradleVersion = providers.gradleProperty("gradleVersion").get() + } - test { - useJUnitPlatform() - } + test { + useJUnitPlatform() + } - publishPlugin { - dependsOn(patchChangelog) - } + publishPlugin { + dependsOn(patchChangelog) + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 128aa32..99b1c0c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,5 @@ rootProject.name = "AndroidLocalizePlugin" plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } diff --git a/src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt b/src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt index 4c7cb75..60bbdcb 100644 --- a/src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt +++ b/src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt @@ -23,17 +23,13 @@ import com.airsaid.localization.task.TranslateTask import com.airsaid.localization.translate.lang.Lang import com.airsaid.localization.ui.SelectLanguagesDialog import com.airsaid.localization.utils.NotificationUtil -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.CommonDataKeys -import com.intellij.openapi.actionSystem.LangDataKeys -import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.* import com.intellij.openapi.application.ReadAction import com.intellij.openapi.project.Project import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile -import com.intellij.psi.xml.XmlTag import com.intellij.psi.PsiManager +import com.intellij.psi.xml.XmlTag /** * Translate android string value to other languages that can be used to localize your Android APP. @@ -42,82 +38,80 @@ import com.intellij.psi.PsiManager */ class TranslateAction : AnAction() { - override fun actionPerformed(e: AnActionEvent) { - val project = e.getRequiredData(CommonDataKeys.PROJECT) - val valueFile = e.getRequiredData(CommonDataKeys.PSI_FILE) - val valueService = AndroidValuesService.getInstance() + override fun actionPerformed(e: AnActionEvent) { + val project = e.getData(CommonDataKeys.PROJECT) ?: return + val valueFile = e.getData(CommonDataKeys.PSI_FILE) ?: return + val valueService = AndroidValuesService.getInstance() - SettingsState.getInstance().initSetting() + SettingsState.getInstance().initSetting() - valueService.loadValuesByAsync(valueFile) { loadedValues -> - if (!isTranslatable(loadedValues, valueService)) { - NotificationUtil.notifyInfo(project, "The ${valueFile.name} has no text to translate.") - return@loadValuesByAsync - } - showSelectLanguageDialog(project, loadedValues, valueFile) - } + valueService.loadValuesByAsync(valueFile) { loadedValues -> + if (!isTranslatable(loadedValues, valueService)) { + NotificationUtil.notifyInfo(project, "The ${valueFile.name} has no text to translate.") + return@loadValuesByAsync + } + showSelectLanguageDialog(project, loadedValues, valueFile) } + } - override fun update(e: AnActionEvent) { - val project = e.project - if (project == null) { - e.presentation.isEnabledAndVisible = false - return - } + override fun update(e: AnActionEvent) { + val project = e.project + if (project == null) { + e.presentation.isEnabledAndVisible = false + return + } - val psiFile = resolvePsiFile(e) - val isValueFile = AndroidValuesService.getInstance().isValueFile(psiFile) + val psiFile = resolvePsiFile(e) + val isValueFile = AndroidValuesService.getInstance().isValueFile(psiFile) - e.presentation.isEnabledAndVisible = isValueFile - } + e.presentation.isEnabledAndVisible = isValueFile + } - override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT - private fun resolvePsiFile(event: AnActionEvent): PsiFile? { - event.getData(CommonDataKeys.PSI_FILE)?.let { return it } + private fun resolvePsiFile(event: AnActionEvent): PsiFile? { + event.getData(CommonDataKeys.PSI_FILE)?.let { return it } - val element = event.getData(LangDataKeys.PSI_ELEMENT) - if (element != null) { - element.containingFile?.let { return it } - } + val element = event.getData(LangDataKeys.PSI_ELEMENT) + element?.containingFile?.let { return it } - val project = event.project ?: return null - val virtualFile = event.getData(CommonDataKeys.VIRTUAL_FILE) ?: return null - if (virtualFile.isDirectory) { - return null - } + val project = event.project ?: return null + val virtualFile = event.getData(CommonDataKeys.VIRTUAL_FILE) ?: return null + if (virtualFile.isDirectory) { + return null + } - return ReadAction.compute { - PsiManager.getInstance(project).findFile(virtualFile) - } + return ReadAction.compute { + PsiManager.getInstance(project).findFile(virtualFile) } + } } private fun isTranslatable(values: List, valueService: AndroidValuesService): Boolean { - for (psiElement in values) { - if (psiElement is XmlTag && valueService.isTranslatable(psiElement)) { - return true - } + for (psiElement in values) { + if (psiElement is XmlTag && valueService.isTranslatable(psiElement)) { + return true } - return false + } + return false } private fun showSelectLanguageDialog(project: Project, values: List, valueFile: PsiFile) { - val dialog = SelectLanguagesDialog(project) - dialog.setOnClickListener(object : SelectLanguagesDialog.OnClickListener { - override fun onClickListener(selectedLanguage: List) { - val translationTask = TranslateTask(project, "Translating...", selectedLanguage, values, valueFile) - translationTask.setOnTranslateListener(object : TranslateTask.OnTranslateListener { - override fun onTranslateSuccess() { - NotificationUtil.notifyInfo(project, "Translation completed!") - } - - override fun onTranslateError(e: Throwable) { - NotificationUtil.notifyError(project, "Translation failure: ${e.localizedMessage}") - } - }) - translationTask.queue() + val dialog = SelectLanguagesDialog(project) + dialog.setOnClickListener(object : SelectLanguagesDialog.OnClickListener { + override fun onClickListener(selectedLanguage: List) { + val translationTask = TranslateTask(project, "Translating...", selectedLanguage, values, valueFile) + translationTask.setOnTranslateListener(object : TranslateTask.OnTranslateListener { + override fun onTranslateSuccess() { + NotificationUtil.notifyInfo(project, "Translation completed!") } - }) - dialog.show() + + override fun onTranslateError(e: Throwable) { + NotificationUtil.notifyError(project, "Translation failure: ${e.localizedMessage}") + } + }) + translationTask.queue() + } + }) + dialog.show() } diff --git a/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt b/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt index ba863d9..04758aa 100644 --- a/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt +++ b/src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt @@ -18,7 +18,6 @@ package com.airsaid.localization.config import com.airsaid.localization.constant.Constants -import com.airsaid.localization.translate.AbstractTranslator import com.airsaid.localization.translate.services.TranslatorService import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.options.Configurable @@ -29,99 +28,99 @@ import javax.swing.JComponent * @author airsaid */ class SettingsConfigurable : Configurable { - companion object { - private val LOG = Logger.getInstance(SettingsConfigurable::class.java) + companion object { + private val LOG = Logger.getInstance(SettingsConfigurable::class.java) + } + + private var settingsComponent: SettingsComponent? = null + + override fun getDisplayName(): String { + return Constants.PLUGIN_NAME + } + + override fun getPreferredFocusedComponent(): JComponent? { + return settingsComponent?.preferredFocusedComponent + } + + override fun createComponent(): JComponent? { + settingsComponent = SettingsComponent() + initComponents() + return settingsComponent?.content + } + + private fun initComponents() { + val settingsState = SettingsState.getInstance() + val translators = TranslatorService.getInstance().getTranslators() + val selected = settingsState.selectedTranslator + settingsComponent?.let { component -> + component.setTranslators(translators) + component.setSelectedTranslator(translators[selected.key]!!) + component.setEnableCache(settingsState.isEnableCache) + component.setMaxCacheSize(settingsState.maxCacheSize) + component.setTranslationInterval(settingsState.translationInterval) } + } - private var settingsComponent: SettingsComponent? = null + override fun isModified(): Boolean { + val settingsState = SettingsState.getInstance() + val selectedTranslator = settingsComponent?.selectedTranslator ?: return false - override fun getDisplayName(): String { - return Constants.PLUGIN_NAME - } - - override fun getPreferredFocusedComponent(): JComponent? { - return settingsComponent?.preferredFocusedComponent - } - - override fun createComponent(): JComponent? { - settingsComponent = SettingsComponent() - initComponents() - return settingsComponent?.content - } - - private fun initComponents() { - val settingsState = SettingsState.getInstance() - val translators = TranslatorService.getInstance().getTranslators() - val selected = settingsState.selectedTranslator - settingsComponent?.let { component -> - component.setTranslators(translators) - component.setSelectedTranslator(translators[selected.key]!!) - component.setEnableCache(settingsState.isEnableCache) - component.setMaxCacheSize(settingsState.maxCacheSize) - component.setTranslationInterval(settingsState.translationInterval) - } - } + var isChanged = settingsState.selectedTranslator != selectedTranslator - override fun isModified(): Boolean { - val settingsState = SettingsState.getInstance() - val selectedTranslator = settingsComponent?.selectedTranslator ?: return false + isChanged = isChanged || settingsState.isEnableCache != (settingsComponent?.isEnableCache ?: false) + isChanged = isChanged || settingsState.maxCacheSize != (settingsComponent?.maxCacheSize ?: 0) + isChanged = isChanged || settingsState.translationInterval != (settingsComponent?.translationInterval ?: 0) - var isChanged = settingsState.selectedTranslator != selectedTranslator + LOG.info("isModified: $isChanged") + return isChanged + } - isChanged = isChanged || settingsState.isEnableCache != (settingsComponent?.isEnableCache ?: false) - isChanged = isChanged || settingsState.maxCacheSize != (settingsComponent?.maxCacheSize ?: 0) - isChanged = isChanged || settingsState.translationInterval != (settingsComponent?.translationInterval ?: 0) + @Throws(ConfigurationException::class) + override fun apply() { + val settingsState = SettingsState.getInstance() + val selectedTranslator = settingsComponent?.selectedTranslator + ?: throw ConfigurationException("No translator selected") - LOG.info("isModified: $isChanged") - return isChanged - } - - @Throws(ConfigurationException::class) - override fun apply() { - val settingsState = SettingsState.getInstance() - val selectedTranslator = settingsComponent?.selectedTranslator - ?: throw ConfigurationException("No translator selected") + LOG.info("apply selectedTranslator: ${selectedTranslator.name}") - LOG.info("apply selectedTranslator: ${selectedTranslator.name}") + // Verify credential requirements + settingsState.selectedTranslator = selectedTranslator - // Verify credential requirements - settingsState.selectedTranslator = selectedTranslator - - selectedTranslator.credentialDefinitions.forEach { descriptor -> - if (descriptor.required) { - val storedValue = settingsState.getCredential(selectedTranslator.key, descriptor) - if (storedValue.isBlank()) { - throw ConfigurationException("${descriptor.label} not configured") - } - } + selectedTranslator.credentialDefinitions.forEach { descriptor -> + if (descriptor.required) { + val storedValue = settingsState.getCredential(selectedTranslator.key, descriptor) + if (storedValue.isBlank()) { + throw ConfigurationException("${descriptor.label} not configured") } + } + } - settingsComponent?.let { component -> - settingsState.isEnableCache = component.isEnableCache - settingsState.maxCacheSize = component.maxCacheSize - settingsState.translationInterval = component.translationInterval + settingsComponent?.let { component -> + settingsState.isEnableCache = component.isEnableCache + settingsState.maxCacheSize = component.maxCacheSize + settingsState.translationInterval = component.translationInterval - val translatorService = TranslatorService.getInstance() - translatorService.setSelectedTranslator(selectedTranslator) - translatorService.setEnableCache(component.isEnableCache) - translatorService.maxCacheSize = component.maxCacheSize - translatorService.translationInterval = component.translationInterval - } + val translatorService = TranslatorService.getInstance() + translatorService.setSelectedTranslator(selectedTranslator) + translatorService.setEnableCache(component.isEnableCache) + translatorService.maxCacheSize = component.maxCacheSize + translatorService.translationInterval = component.translationInterval } - - override fun reset() { - LOG.info("reset") - val settingsState = SettingsState.getInstance() - val selectedTranslator = settingsState.selectedTranslator - settingsComponent?.let { component -> - component.setSelectedTranslator(selectedTranslator) - component.setEnableCache(settingsState.isEnableCache) - component.setMaxCacheSize(settingsState.maxCacheSize) - component.setTranslationInterval(settingsState.translationInterval) - } + } + + override fun reset() { + LOG.info("reset") + val settingsState = SettingsState.getInstance() + val selectedTranslator = settingsState.selectedTranslator + settingsComponent?.let { component -> + component.setSelectedTranslator(selectedTranslator) + component.setEnableCache(settingsState.isEnableCache) + component.setMaxCacheSize(settingsState.maxCacheSize) + component.setTranslationInterval(settingsState.translationInterval) } + } - override fun disposeUIResources() { - settingsComponent = null - } + override fun disposeUIResources() { + settingsComponent = null + } } diff --git a/src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt b/src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt index 60f04ec..56602d9 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt @@ -25,10 +25,10 @@ package com.airsaid.localization.translate */ interface TranslationResult { - /** - * Get a translation result of the specified text. - * - * @return translation result text. - */ - val translationResult: String + /** + * Get a translation result of the specified text. + * + * @return translation result text. + */ + val translationResult: String } diff --git a/src/main/kotlin/com/airsaid/localization/translate/util/AgentUtil.kt b/src/main/kotlin/com/airsaid/localization/translate/util/AgentUtil.kt deleted file mode 100644 index 8ef18c6..0000000 --- a/src/main/kotlin/com/airsaid/localization/translate/util/AgentUtil.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.translate.util - -import com.intellij.openapi.util.SystemInfo - -/** - * @author airsaid - */ -object AgentUtil { - - private const val CHROME_VERSION = "98.0.4758.102" - private const val EDGE_VERSION = "98.0.1108.62" - - fun getUserAgent(): String { - val arch = System.getProperty("os.arch") - val is64Bit = arch?.contains("64") == true - val systemInformation = when { - SystemInfo.isWindows -> { - if (is64Bit) "Windows NT ${SystemInfo.OS_VERSION}; Win64; x64" else "Windows NT ${SystemInfo.OS_VERSION}" - } - SystemInfo.isMac -> { - val parts = SystemInfo.OS_VERSION.split(".").toMutableList() - if (parts.size < 3) { - parts.add("0") - } - "Macintosh; Intel Mac OS X ${parts.joinToString("_")}" - } - else -> { - if (is64Bit) "X11; Linux x86_64" else "X11; Linux x86" - } - } - return "Mozilla/5.0 ($systemInformation) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/$CHROME_VERSION Safari/537.36 Edg/$EDGE_VERSION" - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/util/GsonUtil.kt b/src/main/kotlin/com/airsaid/localization/translate/util/GsonUtil.kt index 62cb857..1d48b27 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/util/GsonUtil.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/util/GsonUtil.kt @@ -25,14 +25,14 @@ import com.google.gson.GsonBuilder */ class GsonUtil private constructor() { - val gson: Gson = GsonBuilder().create() + val gson: Gson = GsonBuilder().create() - companion object { - @JvmStatic - fun getInstance(): GsonUtil = GsonUtilHolder.instance - } + companion object { + @JvmStatic + fun getInstance(): GsonUtil = GsonUtilHolder.instance + } - private object GsonUtilHolder { - val instance = GsonUtil() - } + private object GsonUtilHolder { + val instance = GsonUtil() + } } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/util/LRUCache.kt b/src/main/kotlin/com/airsaid/localization/translate/util/LRUCache.kt index 735cba4..79f87c9 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/util/LRUCache.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/util/LRUCache.kt @@ -24,94 +24,94 @@ import java.util.function.BiConsumer */ class LRUCache(initialCapacity: Int) { - private val caches: MutableMap> - private var head: Node? = null - private var tail: Node? = null - private var maxCapacity: Int - - init { - maxCapacity = initialCapacity - if (initialCapacity <= 0) { - throw IllegalArgumentException("Illegal Capacity: $initialCapacity") - } - caches = linkedMapOf() + private val caches: MutableMap> + private var head: Node? = null + private var tail: Node? = null + private var maxCapacity: Int + + init { + maxCapacity = initialCapacity + if (initialCapacity <= 0) { + throw IllegalArgumentException("Illegal Capacity: $initialCapacity") } + caches = linkedMapOf() + } - fun put(key: K, value: V) { - while (isFull()) { - removeTailNode() - } - val newNode = Node(key, value) - caches[key] = newNode - moveToHeadNode(newNode) + fun put(key: K, value: V) { + while (isFull()) { + removeTailNode() } - - fun get(key: K?): V? { - if (caches.containsKey(key)) { - val newHead = caches[key]!! - moveToHeadNode(newHead) - return newHead.value - } - return null - } - - fun size(): Int = caches.size - - fun isFull(): Boolean = size() > 0 && size() >= maxCapacity - - fun isEmpty(): Boolean = size() <= 0 - - fun forEach(consumer: BiConsumer) { - for ((key, value) in caches) { - consumer.accept(key, value.value) - } + val newNode = Node(key, value) + caches[key] = newNode + moveToHeadNode(newNode) + } + + fun get(key: K?): V? { + if (caches.containsKey(key)) { + val newHead = caches[key]!! + moveToHeadNode(newHead) + return newHead.value } + return null + } - fun forEach(action: (K, V) -> Unit) { - for ((key, value) in caches) { - action(key, value.value) - } - } + fun size(): Int = caches.size - fun clear() { - caches.clear() - head = null - tail = null - } + fun isFull(): Boolean = size() > 0 && size() >= maxCapacity - private fun moveToHeadNode(node: Node) { - if (head == null) { - head = node - tail = node - return - } + fun isEmpty(): Boolean = size() <= 0 - node.next = head - head?.prev = node - head = node + fun forEach(consumer: BiConsumer) { + for ((key, value) in caches) { + consumer.accept(key, value.value) } + } - private fun removeTailNode() { - val currentTail = tail ?: return - - caches.remove(currentTail.key) - val prev = currentTail.prev - prev?.next = null - currentTail.prev = null - tail = prev + fun forEach(action: (K, V) -> Unit) { + for ((key, value) in caches) { + action(key, value.value) } - - fun setMaxCapacity(maxCapacity: Int) { - this.maxCapacity = maxCapacity + } + + fun clear() { + caches.clear() + head = null + tail = null + } + + private fun moveToHeadNode(node: Node) { + if (head == null) { + head = node + tail = node + return } - fun getMaxCapacity(): Int = maxCapacity - - private class Node( - val key: K, - val value: V - ) { - var prev: Node? = null - var next: Node? = null - } + node.next = head + head?.prev = node + head = node + } + + private fun removeTailNode() { + val currentTail = tail ?: return + + caches.remove(currentTail.key) + val prev = currentTail.prev + prev?.next = null + currentTail.prev = null + tail = prev + } + + fun setMaxCapacity(maxCapacity: Int) { + this.maxCapacity = maxCapacity + } + + fun getMaxCapacity(): Int = maxCapacity + + private class Node( + val key: K, + val value: V + ) { + var prev: Node? = null + var next: Node? = null + } } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/util/MD5.kt b/src/main/kotlin/com/airsaid/localization/translate/util/MD5.kt index 04f3afe..5715023 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/util/MD5.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/util/MD5.kt @@ -26,34 +26,34 @@ import java.security.NoSuchAlgorithmException */ object MD5 { - private val hexDigits = charArrayOf( - '0', '1', '2', '3', '4', '5', '6', '7', - '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' - ) + private val hexDigits = charArrayOf( + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' + ) - fun md5(input: String?): String? { - if (input == null) { - return null - } + fun md5(input: String?): String? { + if (input == null) { + return null + } - return try { - val messageDigest = MessageDigest.getInstance("MD5") - val inputByteArray = input.toByteArray(StandardCharsets.UTF_8) - messageDigest.update(inputByteArray) - val resultByteArray = messageDigest.digest() - byteArrayToHex(resultByteArray) - } catch (e: NoSuchAlgorithmException) { - null - } + return try { + val messageDigest = MessageDigest.getInstance("MD5") + val inputByteArray = input.toByteArray(StandardCharsets.UTF_8) + messageDigest.update(inputByteArray) + val resultByteArray = messageDigest.digest() + byteArrayToHex(resultByteArray) + } catch (e: NoSuchAlgorithmException) { + null } + } - private fun byteArrayToHex(byteArray: ByteArray): String { - val resultCharArray = CharArray(byteArray.size * 2) - var index = 0 - for (b in byteArray) { - resultCharArray[index++] = hexDigits[b.toInt() ushr 4 and 0xf] - resultCharArray[index++] = hexDigits[b.toInt() and 0xf] - } - return String(resultCharArray) + private fun byteArrayToHex(byteArray: ByteArray): String { + val resultCharArray = CharArray(byteArray.size * 2) + var index = 0 + for (b in byteArray) { + resultCharArray[index++] = hexDigits[b.toInt() ushr 4 and 0xf] + resultCharArray[index++] = hexDigits[b.toInt() and 0xf] } + return String(resultCharArray) + } } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/translate/util/UrlBuilder.kt b/src/main/kotlin/com/airsaid/localization/translate/util/UrlBuilder.kt index 4962d6c..33c89ff 100644 --- a/src/main/kotlin/com/airsaid/localization/translate/util/UrlBuilder.kt +++ b/src/main/kotlin/com/airsaid/localization/translate/util/UrlBuilder.kt @@ -22,33 +22,33 @@ package com.airsaid.localization.translate.util */ class UrlBuilder(private val baseUrl: String) { - private val queryParameters: MutableList> = mutableListOf() + private val queryParameters: MutableList> = mutableListOf() - fun addQueryParameter(key: String, value: String): UrlBuilder { - queryParameters.add(Pair(key, value)) - return this - } + fun addQueryParameter(key: String, value: String): UrlBuilder { + queryParameters.add(Pair(key, value)) + return this + } - fun addQueryParameters(key: String, vararg values: String): UrlBuilder { - queryParameters.addAll(values.map { value -> Pair(key, value) }) - return this - } + fun addQueryParameters(key: String, vararg values: String): UrlBuilder { + queryParameters.addAll(values.map { value -> Pair(key, value) }) + return this + } - fun build(): String { - val result = StringBuilder(baseUrl) - for (i in queryParameters.indices) { - if (i == 0) { - result.append("?") - } else { - result.append("&") - } - val param = queryParameters[i] - val key = param.first - val value = param.second - result.append(key) - .append("=") - .append(value) - } - return result.toString() + fun build(): String { + val result = StringBuilder(baseUrl) + for (i in queryParameters.indices) { + if (i == 0) { + result.append("?") + } else { + result.append("&") + } + val param = queryParameters[i] + val key = param.first + val value = param.second + result.append(key) + .append("=") + .append(value) } + return result.toString() + } } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/ui/FixedLinkLabel.kt b/src/main/kotlin/com/airsaid/localization/ui/FixedLinkLabel.kt deleted file mode 100644 index be29529..0000000 --- a/src/main/kotlin/com/airsaid/localization/ui/FixedLinkLabel.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2021 Airsaid. https://github.com/airsaid - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package com.airsaid.localization.ui - -import com.intellij.icons.AllIcons -import com.intellij.ui.components.labels.LinkLabel -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent - -/** - * Fixed the problem that sometimes click does not respond. - * - * @author airsaid - */ -class FixedLinkLabel : LinkLabel("", AllIcons.Ide.Link) { - - private var isDoClick = false - - init { - addMouseListener(object : MouseAdapter() { - override fun mouseReleased(e: MouseEvent) { - if (isEnabled && isInClickableArea(e.point)) { - doClick() - } - } - - override fun mouseExited(e: MouseEvent) { - isDoClick = false - } - }) - } - - override fun doClick() { - if (!isDoClick) { - isDoClick = true - super.doClick() - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt b/src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt index 5f20863..65dcfbb 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt @@ -22,76 +22,76 @@ import javax.swing.UIManager */ @Composable fun IdeTheme(content: @Composable () -> Unit) { - val isDark = UIUtil.isUnderDarcula() + val isDark = UIUtil.isUnderDarcula() - val panelBackground = UIUtil.getPanelBackground().toComposeColor() - val surfaceColor = getUiColor("Panel.background", UIUtil.getPanelBackground()).toComposeColor() - val surfaceVariant = getUiColor("EditorPane.background", UIUtil.getPanelBackground()).toComposeColor() - val foreground = UIUtil.getLabelForeground().toComposeColor() - val secondaryForeground = UIUtil.getInactiveTextColor().toComposeColor() - val outline = getUiColor("Component.borderColor", JBColor(0xD7DCE2, 0x3C3F41)).toComposeColor() - val accent = JBColor(0x3574F0, 0x7EA7FF).toComposeColor() + val panelBackground = UIUtil.getPanelBackground().toComposeColor() + val surfaceColor = getUiColor("Panel.background", UIUtil.getPanelBackground()).toComposeColor() + val surfaceVariant = getUiColor("EditorPane.background", UIUtil.getPanelBackground()).toComposeColor() + val foreground = UIUtil.getLabelForeground().toComposeColor() + val secondaryForeground = UIUtil.getInactiveTextColor().toComposeColor() + val outline = getUiColor("Component.borderColor", JBColor(0xD7DCE2, 0x3C3F41)).toComposeColor() + val accent = JBColor(0x3574F0, 0x7EA7FF).toComposeColor() - val colorScheme = if (isDark) { - darkColorScheme( - primary = accent, - onPrimary = Color.White, - secondary = accent, - onSecondary = Color.White, - background = panelBackground, - onBackground = foreground, - surface = panelBackground, - onSurface = foreground, - surfaceVariant = surfaceVariant, - onSurfaceVariant = secondaryForeground, - outline = outline, - ) - } else { - lightColorScheme( - primary = accent, - onPrimary = Color.White, - secondary = accent, - onSecondary = Color.White, - background = panelBackground, - onBackground = foreground, - surface = panelBackground, - onSurface = foreground, - surfaceVariant = surfaceVariant, - onSurfaceVariant = secondaryForeground, - outline = outline, - ) - } + val colorScheme = if (isDark) { + darkColorScheme( + primary = accent, + onPrimary = Color.White, + secondary = accent, + onSecondary = Color.White, + background = panelBackground, + onBackground = foreground, + surface = surfaceColor, + onSurface = foreground, + surfaceVariant = surfaceVariant, + onSurfaceVariant = secondaryForeground, + outline = outline, + ) + } else { + lightColorScheme( + primary = accent, + onPrimary = Color.White, + secondary = accent, + onSecondary = Color.White, + background = panelBackground, + onBackground = foreground, + surface = surfaceColor, + onSurface = foreground, + surfaceVariant = surfaceVariant, + onSurfaceVariant = secondaryForeground, + outline = outline, + ) + } - val typography = buildIdeTypography() + val typography = buildIdeTypography() - MaterialTheme( - colorScheme = colorScheme, - typography = typography, - content = content, - ) + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + content = content, + ) } private fun buildIdeTypography(): Typography { - val baseFont = UIManager.getFont("Label.font") ?: java.awt.Font("SansSerif", java.awt.Font.PLAIN, 13) - val baseSize = baseFont.size2D + val baseFont = UIManager.getFont("Label.font") ?: java.awt.Font("SansSerif", java.awt.Font.PLAIN, 13) + val baseSize = baseFont.size2D - fun style(multiplier: Float, weight: FontWeight = FontWeight.Normal) = TextStyle( - fontFamily = FontFamily.SansSerif, - fontWeight = weight, - fontSize = (baseSize * multiplier).sp, - ) + fun style(multiplier: Float, weight: FontWeight = FontWeight.Normal) = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = weight, + fontSize = (baseSize * multiplier).sp, + ) - return Typography( - bodyLarge = style(1.05f), - bodyMedium = style(1f), - bodySmall = style(0.9f), - titleLarge = style(1.3f, FontWeight.SemiBold), - titleMedium = style(1.15f, FontWeight.SemiBold), - titleSmall = style(1f, FontWeight.Medium), - headlineSmall = style(1.4f, FontWeight.SemiBold), - labelMedium = style(0.9f, FontWeight.Medium), - labelSmall = style(0.85f, FontWeight.Medium), - ) + return Typography( + bodyLarge = style(1.05f), + bodyMedium = style(1f), + bodySmall = style(0.9f), + titleLarge = style(1.3f, FontWeight.SemiBold), + titleMedium = style(1.15f, FontWeight.SemiBold), + titleSmall = style(1f, FontWeight.Medium), + headlineSmall = style(1.4f, FontWeight.SemiBold), + labelMedium = style(0.9f, FontWeight.Medium), + labelSmall = style(0.85f, FontWeight.Medium), + ) } private fun getUiColor(key: String, fallback: AwtColor): AwtColor = UIManager.getColor(key) ?: fallback diff --git a/src/main/kotlin/com/airsaid/localization/utils/NotificationUtil.kt b/src/main/kotlin/com/airsaid/localization/utils/NotificationUtil.kt index 1f83b99..41203c3 100644 --- a/src/main/kotlin/com/airsaid/localization/utils/NotificationUtil.kt +++ b/src/main/kotlin/com/airsaid/localization/utils/NotificationUtil.kt @@ -27,23 +27,23 @@ import com.intellij.openapi.project.Project */ object NotificationUtil { - private const val NOTIFICATION_GROUP_ID = "Android Localize Plugin" + private const val NOTIFICATION_GROUP_ID = "Android Localize Plugin" - private val NOTIFICATION_GROUP: NotificationGroup = - NotificationGroupManager.getInstance().getNotificationGroup(NOTIFICATION_GROUP_ID) + private val NOTIFICATION_GROUP: NotificationGroup = + NotificationGroupManager.getInstance().getNotificationGroup(NOTIFICATION_GROUP_ID) - fun notifyInfo(project: Project?, content: String) { - NOTIFICATION_GROUP.createNotification(content, NotificationType.INFORMATION) - .notify(project) - } + fun notifyInfo(project: Project?, content: String) { + NOTIFICATION_GROUP.createNotification(content, NotificationType.INFORMATION) + .notify(project) + } - fun notifyWarning(project: Project?, content: String) { - NOTIFICATION_GROUP.createNotification(content, NotificationType.WARNING) - .notify(project) - } + fun notifyWarning(project: Project?, content: String) { + NOTIFICATION_GROUP.createNotification(content, NotificationType.WARNING) + .notify(project) + } - fun notifyError(project: Project?, content: String) { - NOTIFICATION_GROUP.createNotification(content, NotificationType.ERROR) - .notify(project) - } + fun notifyError(project: Project?, content: String) { + NOTIFICATION_GROUP.createNotification(content, NotificationType.ERROR) + .notify(project) + } } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/utils/SecureStorage.kt b/src/main/kotlin/com/airsaid/localization/utils/SecureStorage.kt index 2f2c719..ab2692e 100644 --- a/src/main/kotlin/com/airsaid/localization/utils/SecureStorage.kt +++ b/src/main/kotlin/com/airsaid/localization/utils/SecureStorage.kt @@ -28,18 +28,18 @@ import com.intellij.ide.passwordSafe.PasswordSafe */ class SecureStorage(private val key: String) { - fun save(text: String) { - val credentialAttributes = createCredentialAttributes() - val credentials = Credentials(key, text) - PasswordSafe.instance.set(credentialAttributes, credentials) - } + fun save(text: String) { + val credentialAttributes = createCredentialAttributes() + val credentials = Credentials(key, text) + PasswordSafe.instance.set(credentialAttributes, credentials) + } - fun read(): String { - val password = PasswordSafe.instance.getPassword(createCredentialAttributes()) - return password ?: "" - } + fun read(): String { + val password = PasswordSafe.instance.getPassword(createCredentialAttributes()) + return password ?: "" + } - private fun createCredentialAttributes(): CredentialAttributes { - return CredentialAttributes(generateServiceName(Constants.PLUGIN_NAME, key)) - } + private fun createCredentialAttributes(): CredentialAttributes { + return CredentialAttributes(generateServiceName(Constants.PLUGIN_NAME, key)) + } } \ No newline at end of file diff --git a/src/main/kotlin/com/airsaid/localization/utils/TextUtil.kt b/src/main/kotlin/com/airsaid/localization/utils/TextUtil.kt index cf5b852..e94da84 100644 --- a/src/main/kotlin/com/airsaid/localization/utils/TextUtil.kt +++ b/src/main/kotlin/com/airsaid/localization/utils/TextUtil.kt @@ -24,15 +24,15 @@ import com.intellij.openapi.util.text.StringUtil */ object TextUtil { - fun isEmptyOrSpacesLineBreak(s: CharSequence?): Boolean { - if (StringUtil.isEmpty(s)) { - return true - } - for (i in s!!.indices) { - if (s[i] != ' ' && s[i] != '\r' && s[i] != '\n') { - return false - } - } - return true + fun isEmptyOrSpacesLineBreak(s: CharSequence?): Boolean { + if (StringUtil.isEmpty(s)) { + return true } + for (i in s!!.indices) { + if (s[i] != ' ' && s[i] != '\r' && s[i] != '\n') { + return false + } + } + return true + } } \ No newline at end of file diff --git a/src/main/kotlin/icons/PluginIcons.kt b/src/main/kotlin/icons/PluginIcons.kt index f55c2a9..c5bf29a 100644 --- a/src/main/kotlin/icons/PluginIcons.kt +++ b/src/main/kotlin/icons/PluginIcons.kt @@ -23,31 +23,31 @@ import javax.swing.Icon * @author airsaid */ object PluginIcons { - @JvmField - val TRANSLATE_ACTION_ICON: Icon = load("/icons/icon_translate.svg") + @JvmField + val TRANSLATE_ACTION_ICON: Icon = load("/icons/icon_translate.svg") - @JvmField - val GOOGLE_ICON: Icon = load("/icons/icon_google.svg") + @JvmField + val GOOGLE_ICON: Icon = load("/icons/icon_google.svg") - @JvmField - val BAIDU_ICON: Icon = load("/icons/icon_baidu.svg") + @JvmField + val BAIDU_ICON: Icon = load("/icons/icon_baidu.svg") - @JvmField - val YOUDAO_ICON: Icon = load("/icons/icon_youdao.svg") + @JvmField + val YOUDAO_ICON: Icon = load("/icons/icon_youdao.svg") - @JvmField - val MICROSOFT_ICON: Icon = load("/icons/icon_microsoft.svg") + @JvmField + val MICROSOFT_ICON: Icon = load("/icons/icon_microsoft.svg") - @JvmField - val ALI_ICON: Icon = load("/icons/icon_ali.svg") + @JvmField + val ALI_ICON: Icon = load("/icons/icon_ali.svg") - @JvmField - val DEEP_L_ICON: Icon = load("/icons/icon_deepl.svg") + @JvmField + val DEEP_L_ICON: Icon = load("/icons/icon_deepl.svg") - @JvmField - val OPENAI_ICON: Icon = load("/icons/icon_openai.svg") + @JvmField + val OPENAI_ICON: Icon = load("/icons/icon_openai.svg") - private fun load(path: String): Icon { - return IconLoader.getIcon(path, PluginIcons::class.java) - } + private fun load(path: String): Icon { + return IconLoader.getIcon(path, PluginIcons::class.java) + } } \ No newline at end of file diff --git a/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt b/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt deleted file mode 100644 index d14f7da..0000000 --- a/src/test/kotlin/com/airsaid/localization/translate/AbstractTranslatorNetworkTest.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.airsaid.localization.translate - -import com.airsaid.localization.translate.lang.Lang -import com.airsaid.localization.translate.lang.Languages -import com.airsaid.localization.translate.lang.toLang -import com.intellij.openapi.util.Pair -import com.sun.net.httpserver.HttpExchange -import com.sun.net.httpserver.HttpServer -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.net.URLEncoder -import java.nio.charset.StandardCharsets -import java.util.concurrent.CountDownLatch -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicReference - -/** - * Integration-style tests exercising the HTTP workflow shared by translators. - * - * @author airsaid - */ -class AbstractTranslatorNetworkTest { - - private lateinit var server: HttpServer - private lateinit var executor: ExecutorService - private lateinit var baseUrl: String - - @BeforeEach - fun setUp() { - executor = Executors.newSingleThreadExecutor() - val port = findFreePort() - server = HttpServer.create(InetSocketAddress("127.0.0.1", port), 0) - server.executor = executor - baseUrl = "http://127.0.0.1:$port" - server.start() - } - - @AfterEach - fun tearDown() { - server.stop(0) - executor.shutdownNow() - } - - @Test - fun `doTranslate posts form encoded payload`() { - val capturedRequest = AtomicReference() - val latch = CountDownLatch(1) - - server.createContext("/translate") { exchange -> - capturedRequest.set(exchange.captureRequest()) - exchange.respondWith(200, "\"ok\"") - latch.countDown() - } - - val translator = object : TestTranslator("/translate") { - override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { - return listOf( - Pair.create("q", text), - Pair.create("lang", toLang.translationCode) - ) - } - - override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { - return resultText - } - } - - val inputText = "Hello world + test" - val result = translator.doTranslate(Languages.AUTO.toLang(), Languages.ENGLISH.toLang(), inputText) - - assertTrue(latch.await(2, TimeUnit.SECONDS)) - assertEquals("\"ok\"", result) - - val request = capturedRequest.get() - assertEquals("POST", request.method) - assertTrue(request.contentType?.contains("application/x-www-form-urlencoded") == true) - - val expectedBody = listOf( - "q" to inputText, - "lang" to Languages.ENGLISH.toLang().translationCode, - ).joinToString("&") { (name, value) -> - "${name}=${URLEncoder.encode(value, StandardCharsets.UTF_8)}" - } - assertEquals(expectedBody, request.body) - } - - @Test - fun `doTranslate posts raw json body when content type overrides`() { - val capturedRequest = AtomicReference() - val latch = CountDownLatch(1) - - server.createContext("/json") { exchange -> - capturedRequest.set(exchange.captureRequest()) - exchange.respondWith(200, "{\"translated\":\"ok\"}") - latch.countDown() - } - - val translator = object : TestTranslator("/json") { - override val requestContentType: String - get() = "application/json" - - override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { - return emptyList() - } - - override fun getRequestBody(fromLang: Lang, toLang: Lang, text: String): String { - return "{\"text\":\"$text\",\"target\":\"${toLang.translationCode}\"}" - } - - override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { - return resultText - } - } - - val inputText = "Hello" - val result = translator.doTranslate(Languages.AUTO.toLang(), Languages.ENGLISH.toLang(), inputText) - - assertTrue(latch.await(2, TimeUnit.SECONDS)) - assertEquals("{\"translated\":\"ok\"}", result) - - val request = capturedRequest.get() - assertEquals("POST", request.method) - assertTrue(request.contentType?.contains("application/json") == true) - val expectedBody = "{\"text\":\"$inputText\",\"target\":\"${Languages.ENGLISH.toLang().translationCode}\"}" - assertEquals(expectedBody, request.body) - } - - private fun findFreePort(): Int { - ServerSocket(0).use { socket -> - socket.reuseAddress = true - return socket.localPort - } - } - - /** - * Minimal translator harness pointing to the test server. - * - * @author airsaid - */ - private open inner class TestTranslator(private val endpoint: String) : AbstractTranslator() { - override val key: String = "Test" - override val name: String = "Test" - override val supportedLanguages: List = listOf(Languages.ENGLISH.toLang()) - - override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { - return baseUrl + endpoint - } - - override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { - throw UnsupportedOperationException("TestTranslator should override parsingResult") - } - } - - private data class CapturedRequest( - val method: String, - val body: String, - val contentType: String?, - ) - - private fun HttpExchange.captureRequest(): CapturedRequest { - val bodyText = requestBody.bufferedReader(StandardCharsets.UTF_8).use { it.readText() } - val contentType = requestHeaders.getFirstIgnoreCase("Content-Type") - return CapturedRequest(requestMethod, bodyText, contentType) - } - - private fun HttpExchange.respondWith(status: Int, payload: String) { - val bytes = payload.toByteArray(StandardCharsets.UTF_8) - sendResponseHeaders(status, bytes.size.toLong()) - responseBody.use { out -> - out.write(bytes) - } - } - - private fun com.sun.net.httpserver.Headers.getFirstIgnoreCase(name: String): String? { - return this.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value?.firstOrNull() - } -} diff --git a/src/test/kotlin/com/airsaid/localization/translate/util/LRUCacheTest.kt b/src/test/kotlin/com/airsaid/localization/translate/util/LRUCacheTest.kt index f426af5..7c9afd3 100644 --- a/src/test/kotlin/com/airsaid/localization/translate/util/LRUCacheTest.kt +++ b/src/test/kotlin/com/airsaid/localization/translate/util/LRUCacheTest.kt @@ -8,35 +8,35 @@ import org.junit.jupiter.api.Test */ class LRUCacheTest { - @Test - fun testEmpty() { - val lruCache = LRUCache(10) - assertTrue(lruCache.isEmpty()) - assertFalse(lruCache.isFull()) - assertNull(lruCache.get("key")) - } + @Test + fun testEmpty() { + val lruCache = LRUCache(10) + assertTrue(lruCache.isEmpty()) + assertFalse(lruCache.isFull()) + assertNull(lruCache.get("key")) + } - @Test - fun testFull() { - val lruCache = LRUCache(1) - lruCache.put("key", "value") - assertFalse(lruCache.isEmpty()) - assertTrue(lruCache.isFull()) - assertNotNull(lruCache.get("key")) - } + @Test + fun testFull() { + val lruCache = LRUCache(1) + lruCache.put("key", "value") + assertFalse(lruCache.isEmpty()) + assertTrue(lruCache.isFull()) + assertNotNull(lruCache.get("key")) + } - @Test - fun testPut() { - val lruCache = LRUCache(3) - lruCache.put("key1", "value1") - lruCache.put("key2", "value2") - lruCache.put("key3", "value3") - lruCache.put("key4", "value4") - assertNull(lruCache.get("key1")) - assertEquals("value2", lruCache.get("key2")) - assertEquals("value3", lruCache.get("key3")) - assertEquals("value4", lruCache.get("key4")) - lruCache.put("key5", "value5") - assertNull(lruCache.get("key2")) - } + @Test + fun testPut() { + val lruCache = LRUCache(3) + lruCache.put("key1", "value1") + lruCache.put("key2", "value2") + lruCache.put("key3", "value3") + lruCache.put("key4", "value4") + assertNull(lruCache.get("key1")) + assertEquals("value2", lruCache.get("key2")) + assertEquals("value3", lruCache.get("key3")) + assertEquals("value4", lruCache.get("key4")) + lruCache.put("key5", "value5") + assertNull(lruCache.get("key2")) + } } \ No newline at end of file diff --git a/src/test/kotlin/com/airsaid/localization/translate/util/UrlBuilderTest.kt b/src/test/kotlin/com/airsaid/localization/translate/util/UrlBuilderTest.kt index 5b61673..6382c61 100644 --- a/src/test/kotlin/com/airsaid/localization/translate/util/UrlBuilderTest.kt +++ b/src/test/kotlin/com/airsaid/localization/translate/util/UrlBuilderTest.kt @@ -8,39 +8,45 @@ import org.junit.jupiter.api.Test */ class UrlBuilderTest { - @Test - fun testNoParameterBuild() { - val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") - .build() - Assertions.assertEquals("https://translate.googleapis.com/translate_a/single", result) - } + @Test + fun testNoParameterBuild() { + val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") + .build() + Assertions.assertEquals("https://translate.googleapis.com/translate_a/single", result) + } - @Test - fun testSingleParameterBuild() { - val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") - .addQueryParameter("sl", "en") - .build() - Assertions.assertEquals("https://translate.googleapis.com/translate_a/single?sl=en", result) - } + @Test + fun testSingleParameterBuild() { + val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") + .addQueryParameter("sl", "en") + .build() + Assertions.assertEquals("https://translate.googleapis.com/translate_a/single?sl=en", result) + } - @Test - fun testSomeParameterBuild() { - val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") - .addQueryParameter("sl", "en") - .addQueryParameter("tl", "zh-CN") - .addQueryParameter("client", "gtx") - .addQueryParameter("dt", "t") - .build() - Assertions.assertEquals("https://translate.googleapis.com/translate_a/single?sl=en&tl=zh-CN&client=gtx&dt=t", result) - } + @Test + fun testSomeParameterBuild() { + val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") + .addQueryParameter("sl", "en") + .addQueryParameter("tl", "zh-CN") + .addQueryParameter("client", "gtx") + .addQueryParameter("dt", "t") + .build() + Assertions.assertEquals( + "https://translate.googleapis.com/translate_a/single?sl=en&tl=zh-CN&client=gtx&dt=t", + result + ) + } - @Test - fun testRepeatParameterBuild() { - val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") - .addQueryParameter("sl", "en") - .addQueryParameter("tl", "zh-CN") - .addQueryParameters("dt", "t", "bd", "ex") - .build() - Assertions.assertEquals("https://translate.googleapis.com/translate_a/single?sl=en&tl=zh-CN&dt=t&dt=bd&dt=ex", result) - } + @Test + fun testRepeatParameterBuild() { + val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") + .addQueryParameter("sl", "en") + .addQueryParameter("tl", "zh-CN") + .addQueryParameters("dt", "t", "bd", "ex") + .build() + Assertions.assertEquals( + "https://translate.googleapis.com/translate_a/single?sl=en&tl=zh-CN&dt=t&dt=bd&dt=ex", + result + ) + } } \ No newline at end of file diff --git a/src/test/kotlin/com/airsaid/localization/utils/TextUtilTest.kt b/src/test/kotlin/com/airsaid/localization/utils/TextUtilTest.kt index dc0b2f8..2c9cefd 100644 --- a/src/test/kotlin/com/airsaid/localization/utils/TextUtilTest.kt +++ b/src/test/kotlin/com/airsaid/localization/utils/TextUtilTest.kt @@ -1,6 +1,7 @@ package com.airsaid.localization.utils -import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test /** @@ -8,26 +9,26 @@ import org.junit.jupiter.api.Test */ class TextUtilTest { - @Test - fun isEmptyOrSpacesLineBreak() { - assertTrue(TextUtil.isEmptyOrSpacesLineBreak(null)) - assertTrue(TextUtil.isEmptyOrSpacesLineBreak("")) - assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" ")) - assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" ")) - assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\r")) - assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\n")) - assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\r\n")) - assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" \r\n ")) - assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" \r \n ")) + @Test + fun isEmptyOrSpacesLineBreak() { + assertTrue(TextUtil.isEmptyOrSpacesLineBreak(null)) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak("")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" ")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" ")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\r")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\n")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\r\n")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" \r\n ")) + assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" \r \n ")) - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text")) - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text ")) - assertFalse(TextUtil.isEmptyOrSpacesLineBreak(" text")) - assertFalse(TextUtil.isEmptyOrSpacesLineBreak(" text ")) - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\ntext")) - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text\n")) - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\rtext")) - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text\r")) - assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\r\ntext\r\n")) - } + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text ")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak(" text")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak(" text ")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\ntext")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text\n")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\rtext")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text\r")) + assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\r\ntext\r\n")) + } } \ No newline at end of file From edda8124317627b73649bbab44c7658ba42da37b Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 19:32:15 +0800 Subject: [PATCH 44/58] Update theme to use JBColor for background detection --- .../kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt b/src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt index 65dcfbb..b2de325 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/IdeComposeTheme.kt @@ -12,8 +12,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp import com.intellij.ui.JBColor import com.intellij.util.ui.UIUtil -import java.awt.Color as AwtColor import javax.swing.UIManager +import java.awt.Color as AwtColor /** * Material theme tuned to match the current IntelliJ look and feel. @@ -22,9 +22,9 @@ import javax.swing.UIManager */ @Composable fun IdeTheme(content: @Composable () -> Unit) { - val isDark = UIUtil.isUnderDarcula() + val isDark = !JBColor.isBright() - val panelBackground = UIUtil.getPanelBackground().toComposeColor() + val panelBackground = JBColor.background().toComposeColor() val surfaceColor = getUiColor("Panel.background", UIUtil.getPanelBackground()).toComposeColor() val surfaceVariant = getUiColor("EditorPane.background", UIUtil.getPanelBackground()).toComposeColor() val foreground = UIUtil.getLabelForeground().toComposeColor() From be3c8164bb8d857d3a2091ed83e4046cc2d2ffee Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 20:25:32 +0800 Subject: [PATCH 45/58] Add detailed translation progress tracking --- .../localization/task/TranslateTask.kt | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt index 88dd096..b37c647 100644 --- a/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt +++ b/src/main/kotlin/com/airsaid/localization/task/TranslateTask.kt @@ -88,10 +88,11 @@ class TranslateTask( .getBoolean(Constants.KEY_IS_OVERWRITE_EXISTING_STRING) LOG.info("run isOverwriteExistingString: $isOverwriteExistingString") + val totalTranslatableCount = countTranslatableValues() for (toLanguage in toLanguages) { if (progressIndicator.isCanceled) break - progressIndicator.text = "Translation to ${toLanguage.englishName}..." + updateProgressText(progressIndicator, toLanguage, 0, totalTranslatableCount) val resourceDir = valueFile.parent.parent val valueFileName = valueFile.name @@ -113,11 +114,23 @@ class TranslateTask( }, { it } )) - val translatedValues = doTranslate(progressIndicator, toLanguage, toValuesMap, isOverwriteExistingString) + val translatedValues = doTranslate( + progressIndicator, + toLanguage, + toValuesMap, + isOverwriteExistingString, + totalTranslatableCount + ) translationError?.let { return } writeTranslatedValues(progressIndicator, File(toValuePsiFile.virtualFile.path), translatedValues) } else { - val translatedValues = doTranslate(progressIndicator, toLanguage, null, isOverwriteExistingString) + val translatedValues = doTranslate( + progressIndicator, + toLanguage, + null, + isOverwriteExistingString, + totalTranslatableCount + ) translationError?.let { return } val valueFile = valueService.getValueFile(resourceDir, toLanguage, valueFileName) writeTranslatedValues(progressIndicator, valueFile, translatedValues) @@ -133,11 +146,13 @@ class TranslateTask( progressIndicator: ProgressIndicator, toLanguage: Lang, toValues: Map?, - isOverwrite: Boolean + isOverwrite: Boolean, + totalTranslatableCount: Int ): List { LOG.info("doTranslate toLanguage: ${toLanguage.englishName}, toValues: $toValues, isOverwrite: $isOverwrite") val translatedValues = ArrayList() + var translatedCount = 0 for (value in values) { if (translationError != null) break if (progressIndicator.isCanceled) break @@ -147,6 +162,9 @@ class TranslateTask( continue } + translatedCount += 1 + updateProgressText(progressIndicator, toLanguage, translatedCount, totalTranslatableCount) + val name = ApplicationManager.getApplication().runReadAction(Computable { value.getAttributeValue("name") }) @@ -237,6 +255,28 @@ class TranslateTask( refreshAndOpenFile(valueFile) } + private fun countTranslatableValues(): Int { + return values.count { value -> + value is XmlTag && valueService.isTranslatable(value) + } + } + + private fun updateProgressText( + progressIndicator: ProgressIndicator, + toLanguage: Lang, + current: Int, + total: Int + ) { + progressIndicator.text = if (total > 0) { + val clampedCurrent = current.coerceAtMost(total) + progressIndicator.fraction = clampedCurrent.toDouble() / total + "Translating to ${toLanguage.englishName} ($clampedCurrent/$total)..." + } else { + progressIndicator.fraction = 0.0 + "Translating to ${toLanguage.englishName}..." + } + } + private fun refreshAndOpenFile(file: File) { val virtualFile = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) val isOpenTranslatedFile = PropertiesComponent.getInstance(project) From 06a637e55d0b479ab1479da9aeda4307c9be16ad Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 21:42:26 +0800 Subject: [PATCH 46/58] Refactor SelectLanguagesDialog layout and filtering --- .../localization/ui/SelectLanguagesDialog.kt | 46 ++++++------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt index 0e3815b..dcc2937 100644 --- a/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt +++ b/src/main/kotlin/com/airsaid/localization/ui/SelectLanguagesDialog.kt @@ -122,7 +122,7 @@ class SelectLanguagesDialog(private val project: Project) : ComposeDialog(projec } Surface( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(16.dp), color = MaterialTheme.colorScheme.background, ) { SelectLanguagesContent( @@ -271,12 +271,7 @@ private fun SelectLanguagesContent( derivedStateOf { languages.isNotEmpty() && languages.all { selectedLanguages.contains(it) } } } - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(18.dp) - ) { + Column(modifier = Modifier.fillMaxSize()) { LanguagesCard( filterText = filterText, onFilterChange = { filterText = it }, @@ -336,8 +331,9 @@ private fun LanguagesCard( favoriteLanguages.toList() } else { favoriteLanguages.filter { - it.englishName.contains(filterText, ignoreCase = true) || - it.code.contains(filterText, ignoreCase = true) + it.code.contains(filterText, ignoreCase = true) || + it.englishName.contains(filterText, ignoreCase = true) || + it.name.contains(filterText, ignoreCase = true) } } @@ -352,15 +348,12 @@ private fun LanguagesCard( modifier = modifier .fillMaxWidth() .heightIn(min = 260.dp), - shape = RoundedCornerShape(12.dp), tonalElevation = 0.dp, border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)), color = MaterialTheme.colorScheme.surface, ) { Column( - modifier = Modifier - .fillMaxSize() - .padding(18.dp), + modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp) ) { OptionsSection( @@ -409,7 +402,7 @@ private fun LanguagesCard( onLanguageToggled = onLanguageToggled, onFavoriteToggle = onFavoriteToggle, emptyMessage = "No languages match your filter", - modifier = Modifier.weight(1f, fill = true), + modifier = Modifier.fillMaxSize(), ) } } @@ -430,14 +423,7 @@ private fun FavoriteLanguagesSection( val languagesToDisplay = if (hasFavorites) filteredFavoriteLanguages else emptyList() val emptyMessage = when { !hasFavorites -> "No favorite languages yet. \nClick the star beside a language below to add it." - languagesToDisplay.isEmpty() -> "No favorite languages match your filter" - else -> null - } - val resolvedEmptyMessage = emptyMessage ?: "No favorite languages match your filter" - val gridModifier = if (languagesToDisplay.isEmpty()) { - Modifier.padding(vertical = 8.dp) - } else { - Modifier.heightIn(max = 216.dp) + else -> "No favorite languages match your filter" } Column( modifier = Modifier.fillMaxWidth(), @@ -462,8 +448,8 @@ private fun FavoriteLanguagesSection( favoriteLanguages = favoriteLanguages, onLanguageToggled = onLanguageToggled, onFavoriteToggle = onFavoriteToggle, - emptyMessage = resolvedEmptyMessage, - modifier = gridModifier, + emptyMessage = emptyMessage, + modifier = Modifier.fillMaxWidth().heightIn(max = 142.dp), ) } } @@ -503,9 +489,8 @@ private fun LanguagesGrid( modifier: Modifier = Modifier, emptyAlignment: Alignment = Alignment.Center, ) { - val containerModifier = modifier.fillMaxWidth() if (languages.isEmpty()) { - Box(modifier = containerModifier, contentAlignment = emptyAlignment) { + Box(modifier = modifier, contentAlignment = emptyAlignment) { Text( text = emptyMessage, style = MaterialTheme.typography.bodyMedium, @@ -515,13 +500,13 @@ private fun LanguagesGrid( } } else { val languagesGridState = rememberLazyGridState() - Row(modifier = containerModifier, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(10.dp)) { LazyVerticalGrid( state = languagesGridState, columns = GridCells.Fixed(4), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.weight(1f, fill = true) + modifier = Modifier.wrapContentHeight().weight(1f, fill = true) ) { items(languages, key = { it.code }) { language -> LanguageOption( @@ -635,7 +620,7 @@ private fun LanguageOption( Row( modifier = modifier - .defaultMinSize(minHeight = 64.dp) + .height(64.dp) .border(BorderStroke(1.dp, borderColor), RoundedCornerShape(12.dp)) .background(backgroundColor, RoundedCornerShape(12.dp)) .padding(horizontal = 12.dp, vertical = 8.dp) @@ -718,8 +703,7 @@ private fun TranslatorFooter(translator: AbstractTranslator, onOpenSettings: () color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - }, - delayMillis = 300, + } ) { IconButton(onClick = onOpenSettings) { Icon( From 9e708c9f9454b60aab798f01b326f4655ae2f560 Mon Sep 17 00:00:00 2001 From: Airsaid Date: Fri, 26 Sep 2025 23:45:23 +0800 Subject: [PATCH 47/58] Update changelog with new features and improvements --- CHANGELOG.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8cbbb8..5131aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,21 @@ ## [Unreleased] +### Added +- Detailed translation progress updates showing the current language, processed item counts, and writeback status. +- Quick access settings button in the Select Languages dialog footer and a donations card within the settings UI. +- Provide Qodana and Codecov configuration files. + ### Changed - Align build scripts and workflows with IntelliJ Platform Plugin Template 2025 updates. -- Upgrade Gradle wrapper to 9.0 and align Kotlin toolchain with Compose-compatible 2.0.21. -- Raise minimum supported IntelliJ Platform build to 251 (2025.1). +- Upgrade Gradle wrapper to 9.0 and align the Kotlin toolchain with Compose compatible 2.0.21. +- Raise the minimum supported IntelliJ Platform build to 251 (2025.1). - Refactor TranslateAction to follow IntelliJ action system best practices. - Configure tests to run on the JUnit 5 framework while retaining required runtime compatibility. -- Rebuild plugin UI (settings and dialogs) using Compose. +- Rebuild plugin UI (settings and dialogs) using Compose with searchable grids, favorite chips, and polished empty states. - Load secure credentials asynchronously to avoid password safe access on the EDT. -- Align Compose typography/colours with IDE themes, add language filtering chips, and polish dialog layouts. - -### Added -- Provide Qodana and Codecov configuration files. +- Reduce the minimum translation interval to 50 ms to keep throttled bursts responsive. +- Update Compose theme colours to rely on JBColor so dialogs respect light and dark backgrounds. ### Fixed - Restore visibility of the "Translate to Other Languages" action when selecting resource files from the Project view. From be28a14be24a5ebd28803463e9c99b3e9cacf84f Mon Sep 17 00:00:00 2001 From: Airsaid Date: Sat, 27 Sep 2025 11:33:08 +0800 Subject: [PATCH 48/58] Update preview images and README documentation --- README.md | 8 ++++---- README_CN.md | 8 ++++---- preview/openai_settings.png | Bin 0 -> 48231 bytes preview/preview.gif | Bin 5675484 -> 0 bytes preview/preview.png | Bin 0 -> 179089 bytes preview/settings.png | Bin 39074 -> 129386 bytes 6 files changed, 8 insertions(+), 8 deletions(-) create mode 100644 preview/openai_settings.png delete mode 100644 preview/preview.gif create mode 100644 preview/preview.png diff --git a/README.md b/README.md index ec3e3d6..4f412d9 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ Android localization plugin. supports multiple languages and multiple translator # Preview -![image](preview/preview.gif) +![image](preview/preview.png) ![image](preview/settings.png) +![image](preview/openai_settings.png) # Install [![Install Plugin](preview/install.png)](https://plugins.jetbrains.com/plugin/11174-androidlocalize) @@ -49,9 +50,8 @@ Android localization plugin. supports multiple languages and multiple translator HelloAndroid Check out our 5\u2605 Visit us at https://github.com/Airsaid/AndroidLocalizePlugin - Learn more at Muggle Game Studio + Learn more at Muggle Studio ``` - **Note: Display one line without extra line breaks and spaces in between.** - Q: Translation failure: java.net.HttpRetryException: cannot retry due to redirection, in streaming mode A: Try switching to another translation engine on the settings page and use your own account for translation. Some default translators rely on shared credentials and may be rate limited. @@ -86,7 +86,7 @@ You can contribute and support this project by doing any of the following: - WeChat Play + WeChat Pay diff --git a/README_CN.md b/README_CN.md index 9c6b550..6a6d16f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -32,8 +32,9 @@ Android 本地化插件,支持多种语言和翻译器。 - 第四步:点击 OK。 # 预览 -![image](preview/preview.gif) +![image](preview/preview.png) ![image](preview/settings.png) +![image](preview/openai_settings.png) # 安装 [![Install Plugin](preview/install.png)](https://plugins.jetbrains.com/plugin/11174-androidlocalize) @@ -46,9 +47,8 @@ Android 本地化插件,支持多种语言和翻译器。 HelloAndroid Check out our 5\u2605 Visit us at https://github.com/Airsaid/AndroidLocalizePlugin - Learn more at Muggle Game Studio + Learn more at Muggle Studio ``` - **注意:一行展示,中间不要有多余的换行和空格。** - 问题:Translation failure: java.net.HttpRetryException: cannot retry due to redirection, in streaming mode @@ -84,7 +84,7 @@ Android 本地化插件,支持多种语言和翻译器。 - WeChat Play + WeChat Pay diff --git a/preview/openai_settings.png b/preview/openai_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..c37cffd5da8f63016602b2a005a168f55374283e GIT binary patch literal 48231 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