├── .github ├── FUNDING.yml ├── dependabot.yml ├── issue_template └── workflows │ ├── build.yml │ ├── release.yml │ ├── run-ui-tests.yml │ └── stale.yml ├── .gitignore ├── .idea └── icon.png ├── .run ├── Run IDE for UI Tests.run.xml ├── Run IDE with Plugin.run.xml ├── Run Plugin Tests.run.xml └── Run Plugin Verification.run.xml ├── AGENTS.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── README_CN.md ├── build.gradle.kts ├── codecov.yml ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── preview ├── install.png ├── openai_settings.png ├── preview.png └── settings.png ├── qodana.yml ├── settings.gradle.kts └── src ├── main ├── kotlin │ ├── com │ │ └── airsaid │ │ │ └── localization │ │ │ ├── action │ │ │ └── TranslateAction.kt │ │ │ ├── config │ │ │ ├── SettingsComponent.kt │ │ │ ├── SettingsConfigurable.kt │ │ │ ├── SettingsState.kt │ │ │ ├── TranslatorConfigurationManager.kt │ │ │ └── TranslatorCredentialsDialog.kt │ │ │ ├── constant │ │ │ └── Constants.kt │ │ │ ├── extensions │ │ │ └── StringExtensions.kt │ │ │ ├── services │ │ │ └── AndroidValuesService.kt │ │ │ ├── task │ │ │ └── TranslateTask.kt │ │ │ ├── translate │ │ │ ├── AbstractTranslator.kt │ │ │ ├── TranslationException.kt │ │ │ ├── TranslationResult.kt │ │ │ ├── Translator.kt │ │ │ ├── TranslatorConfigurable.kt │ │ │ ├── TranslatorCredentialDescriptor.kt │ │ │ ├── impl │ │ │ │ ├── ali │ │ │ │ │ └── AliTranslator.kt │ │ │ │ ├── baidu │ │ │ │ │ ├── BaiduTranslationResult.kt │ │ │ │ │ └── BaiduTranslator.kt │ │ │ │ ├── deepl │ │ │ │ │ ├── DeepLTranslationResult.kt │ │ │ │ │ ├── DeepLTranslator.kt │ │ │ │ │ ├── DeepLTranslatorCredentialsDialog.kt │ │ │ │ │ └── DeepLTranslatorSettings.kt │ │ │ │ ├── google │ │ │ │ │ ├── AbsGoogleTranslator.kt │ │ │ │ │ ├── GoogleHttp.kt │ │ │ │ │ ├── GoogleToken.kt │ │ │ │ │ ├── GoogleTranslationResponse.kt │ │ │ │ │ ├── GoogleTranslator.kt │ │ │ │ │ ├── GoogleTranslatorSettings.kt │ │ │ │ │ └── GoogleTranslatorSettingsDialog.kt │ │ │ │ ├── microsoft │ │ │ │ │ ├── MicrosoftEdgeAuthService.kt │ │ │ │ │ ├── MicrosoftExceptions.kt │ │ │ │ │ ├── MicrosoftTranslationResult.kt │ │ │ │ │ └── MicrosoftTranslator.kt │ │ │ │ ├── openai │ │ │ │ │ ├── OpenAIModels.kt │ │ │ │ │ ├── OpenAIResponse.kt │ │ │ │ │ ├── OpenAITranslator.kt │ │ │ │ │ ├── OpenAITranslatorSettings.kt │ │ │ │ │ └── OpenAITranslatorSettingsDialog.kt │ │ │ │ └── youdao │ │ │ │ │ ├── YoudaoTranslationResult.kt │ │ │ │ │ └── YoudaoTranslator.kt │ │ │ ├── interceptors │ │ │ │ └── EscapeCharactersInterceptor.kt │ │ │ ├── lang │ │ │ │ ├── Lang.kt │ │ │ │ └── Languages.kt │ │ │ ├── services │ │ │ │ ├── TranslationCacheService.kt │ │ │ │ └── TranslatorService.kt │ │ │ └── util │ │ │ │ ├── GsonUtil.kt │ │ │ │ ├── HttpRequestFactory.kt │ │ │ │ ├── LRUCache.kt │ │ │ │ ├── MD5.kt │ │ │ │ └── UrlBuilder.kt │ │ │ ├── ui │ │ │ ├── ComposeDialog.kt │ │ │ ├── SelectLanguagesDialog.kt │ │ │ ├── SupportedLanguagesDialog.kt │ │ │ └── components │ │ │ │ ├── FormControls.kt │ │ │ │ ├── SwingIcon.kt │ │ │ │ └── TooltipIcon.kt │ │ │ └── utils │ │ │ ├── LanguageUtil.kt │ │ │ ├── NotificationUtil.kt │ │ │ ├── SecureStorage.kt │ │ │ └── TextUtil.kt │ └── icons │ │ └── PluginIcons.kt └── resources │ ├── META-INF │ ├── plugin.xml │ └── pluginIcon.svg │ └── icons │ ├── icon_ali.svg │ ├── icon_baidu.svg │ ├── icon_baidu_dark.svg │ ├── icon_deepl.svg │ ├── icon_google.svg │ ├── icon_microsoft.svg │ ├── icon_openai.svg │ ├── icon_translate.svg │ └── icon_youdao.svg └── test └── kotlin └── com └── airsaid └── localization ├── extensions └── StringExtensionsTest.kt ├── translate ├── services │ └── TranslatorServiceTest.kt └── util │ ├── LRUCacheTest.kt │ └── UrlBuilderTest.kt └── utils └── TextUtilTest.kt /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: airsaid 2 | open_collective: androidlocalizeplugin -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gradle" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/issue_template: -------------------------------------------------------------------------------- 1 | ## Issue Report 2 | 3 | Please ensure you have provided all the following requested information in your report. 4 | 5 | ### Environment Information 6 | 7 | #### Platform 8 | - [ ] Android Studio 9 | - [ ] IntelliJ IDEA Community 10 | - [ ] IntelliJ IDEA Ultimate 11 | 12 | #### IDE Version 13 | - IDE Version: 14 | - IDE Build Number: (Help → About → Build) 15 | 16 | #### Plugin Information 17 | - Plugin Version: 18 | - Plugin Source: [ ] JetBrains Marketplace [ ] Manual Installation 19 | 20 | ### Translation Configuration 21 | 22 | #### Translator Used 23 | - [ ] Google 24 | - [ ] Microsoft 25 | - [ ] Baidu 26 | - [ ] Youdao 27 | - [ ] Ali 28 | - [ ] DeepL 29 | - [ ] OpenAI 30 | 31 | #### Translation Settings 32 | - Using API Key: [ ] Yes [ ] No 33 | - Translation Interval: (ms) 34 | - Cache Enabled: [ ] Yes [ ] No 35 | 36 | ### Issue Details 37 | 38 | #### Issue Type 39 | - [ ] Translation Error/Failure 40 | - [ ] UI/Interface Issue 41 | - [ ] Performance Issue 42 | - [ ] Configuration Problem 43 | - [ ] Feature Request 44 | - [ ] Other 45 | 46 | #### Description 47 | _Please provide a detailed description of the issue:_ 48 | 49 | #### Steps to Reproduce 50 | 1. 51 | 2. 52 | 3. 53 | 54 | #### Expected Behavior 55 | _What you expected to happen:_ 56 | 57 | #### Actual Behavior 58 | _What actually happened:_ 59 | 60 | #### Error Messages 61 | _If applicable, please include any error messages or stack traces:_ 62 | 63 | ``` 64 | [Paste error messages here] 65 | ``` 66 | 67 | #### Sample Files 68 | _If the issue is related to specific string resources, please provide a sample:_ 69 | 70 | ```xml 71 | 72 | ``` 73 | 74 | ### Checklist 75 | - [ ] I have searched existing issues to ensure this is not a duplicate 76 | - [ ] I have provided all the requested information above 77 | - [ ] I have tested with the latest version of the plugin 78 | - [ ] I have checked the FAQ in the README 79 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow created for handling the release process based on the draft release prepared with the Build workflow. 2 | # Running the publishPlugin task requires all the following secrets to be provided: PUBLISH_TOKEN, PRIVATE_KEY, PRIVATE_KEY_PASSWORD, CERTIFICATE_CHAIN. 3 | # See https://plugins.jetbrains.com/docs/intellij/plugin-signing.html for more information. 4 | 5 | name: Release 6 | on: 7 | release: 8 | types: [prereleased, released] 9 | 10 | jobs: 11 | 12 | # Prepare and publish the plugin to JetBrains Marketplace repository 13 | release: 14 | name: Publish Plugin 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | steps: 20 | 21 | # Free GitHub Actions Environment Disk Space 22 | - name: Maximize Build Space 23 | uses: jlumbroso/free-disk-space@v1.3.1 24 | with: 25 | tool-cache: false 26 | large-packages: false 27 | 28 | # Check out the current repository 29 | - name: Fetch Sources 30 | uses: actions/checkout@v4 31 | with: 32 | ref: ${{ github.event.release.tag_name }} 33 | 34 | # Set up the Java environment for the next steps 35 | - name: Setup Java 36 | uses: actions/setup-java@v4 37 | with: 38 | distribution: zulu 39 | java-version: 21 40 | 41 | # Setup Gradle 42 | - name: Setup Gradle 43 | uses: gradle/actions/setup-gradle@v4 44 | with: 45 | cache-read-only: true 46 | 47 | # Update the Unreleased section with the current release note 48 | - name: Patch Changelog 49 | if: ${{ github.event.release.body != '' }} 50 | env: 51 | CHANGELOG: ${{ github.event.release.body }} 52 | run: | 53 | RELEASE_NOTE="./build/tmp/release_note.txt" 54 | mkdir -p "$(dirname "$RELEASE_NOTE")" 55 | echo "$CHANGELOG" > $RELEASE_NOTE 56 | 57 | ./gradlew patchChangelog --release-note-file=$RELEASE_NOTE 58 | 59 | # Publish the plugin to JetBrains Marketplace 60 | - name: Publish Plugin 61 | env: 62 | PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 63 | CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} 64 | PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} 65 | PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} 66 | run: ./gradlew publishPlugin 67 | 68 | # Upload an artifact as a release asset 69 | - name: Upload Release Asset 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | run: gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* --clobber 73 | 74 | # Create a pull request 75 | - name: Create Pull Request 76 | if: ${{ github.event.release.body != '' }} 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | run: | 80 | VERSION="${{ github.event.release.tag_name }}" 81 | BRANCH="changelog-update-$VERSION" 82 | LABEL="release changelog" 83 | 84 | git config user.email "action@github.com" 85 | git config user.name "GitHub Action" 86 | 87 | git checkout -b $BRANCH 88 | git commit -am "Changelog update - $VERSION" 89 | git push --set-upstream origin $BRANCH 90 | 91 | gh label create "$LABEL" \ 92 | --description "Pull requests with release changelog update" \ 93 | --force \ 94 | || true 95 | 96 | gh pr create \ 97 | --title "Changelog update - \`$VERSION\`" \ 98 | --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ 99 | --label "$LABEL" \ 100 | --head $BRANCH 101 | -------------------------------------------------------------------------------- /.github/workflows/run-ui-tests.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow for launching UI tests on Linux, Windows, and Mac in the following steps: 2 | # - Prepare and launch IDE with your plugin and robot-server plugin, which is needed to interact with the UI. 3 | # - Wait for IDE to start. 4 | # - Run UI tests with a separate Gradle task. 5 | # 6 | # Please check https://github.com/JetBrains/intellij-ui-test-robot for information about UI tests with IntelliJ Platform. 7 | # 8 | # Workflow is triggered manually. 9 | 10 | name: Run UI Tests 11 | on: 12 | workflow_dispatch 13 | 14 | jobs: 15 | 16 | testUI: 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - os: ubuntu-latest 23 | runIde: | 24 | export DISPLAY=:99.0 25 | Xvfb -ac :99 -screen 0 1920x1080x16 & 26 | gradle runIdeForUiTests & 27 | - os: windows-latest 28 | runIde: start gradlew.bat runIdeForUiTests 29 | - os: macos-latest 30 | runIde: ./gradlew runIdeForUiTests & 31 | 32 | steps: 33 | 34 | # Check out the current repository 35 | - name: Fetch Sources 36 | uses: actions/checkout@v4 37 | 38 | # Set up the Java environment for the next steps 39 | - name: Setup Java 40 | uses: actions/setup-java@v4 41 | with: 42 | distribution: zulu 43 | java-version: 21 44 | 45 | # Setup Gradle 46 | - name: Setup Gradle 47 | uses: gradle/actions/setup-gradle@v4 48 | with: 49 | cache-read-only: true 50 | 51 | # Run IDEA prepared for UI testing 52 | - name: Run IDE 53 | run: ${{ matrix.runIde }} 54 | 55 | # Wait for IDEA to be started 56 | - name: Health Check 57 | uses: jtalk/url-health-check-action@v4 58 | with: 59 | url: http://127.0.0.1:8082 60 | max-attempts: 15 61 | retry-delay: 30s 62 | 63 | # Run tests 64 | - name: Tests 65 | run: ./gradlew test 66 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues" 2 | on: 3 | schedule: 4 | - cron: "0 0 * * *" 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-issue-message: | 13 | This issue has had no recent activity and is now marked as stale. 14 | Please leave a comment if it is still relevant; otherwise, it will be closed soon. 15 | close-issue-message: | 16 | This issue has been closed due to inactivity. 17 | If the problem persists, feel free to reopen it or open a new issue. 18 | days-before-stale: 30 19 | days-before-close: 7 20 | exempt-issue-labels: "pinned,security" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .gradle 3 | .idea 4 | .intellijPlatform 5 | .kotlin 6 | .qodana 7 | build 8 | local.properties 9 | -------------------------------------------------------------------------------- /.idea/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Airsaid/AndroidLocalizePlugin/ab534471c3cb928f308128935134d6323b809a96/.idea/icon.png -------------------------------------------------------------------------------- /.run/Run IDE for UI Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 15 | 17 | true 18 | true 19 | false 20 | 21 | 22 | -------------------------------------------------------------------------------- /.run/Run IDE with Plugin.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | 23 | 24 | -------------------------------------------------------------------------------- /.run/Run Plugin Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.run/Run Plugin Verification.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | false 23 | 24 | 25 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repository Guidelines 2 | 3 | ## Project Structure & Module Organization 4 | 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. 5 | 6 | ## Build, Test & Development Commands 7 | - `./gradlew buildPlugin` – produce the distributable ZIP in `build/distributions/`. 8 | - `./gradlew check` – execute unit tests, static checks, and coverage verification. 9 | - `./gradlew runIde` – start a sandbox IDE with the plugin; use `./gradlew runIdeForUiTests` for Robot scenarios. 10 | - `./gradlew qodanaScan` – run JetBrains Qodana and review results under `build/reports/qodana/`. 11 | - `./gradlew publishPlugin` – push to Marketplace (requires `CERTIFICATE_CHAIN`, `PRIVATE_KEY`, `PRIVATE_KEY_PASSWORD`, `PUBLISH_TOKEN`). 12 | 13 | ## Coding Style & Naming Conventions 14 | 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. 15 | 16 | ## Testing Guidelines 17 | 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. 18 | 19 | ## Commit & Pull Request Guidelines 20 | 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`. 21 | 22 | ## Security & Configuration Tips 23 | 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. 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Android Localize Plugin Changelog 4 | 5 | ## [Unreleased] 6 | 7 | ### Added 8 | - Auto-select existing languages option in the Select Languages dialog to automatically detect and select languages that already exist in the project. 9 | 10 | ### Fixed 11 | - ExceptionInInitializerError when opening plguin. 12 | 13 | ## [4.0.0] (2025-09-27) 14 | 15 | ### Added 16 | - Detailed translation progress updates showing the current language, processed item counts, and writeback status. 17 | - Quick access settings button in the Select Languages dialog footer and a donations card within the settings UI. 18 | - Provide Qodana and Codecov configuration files. 19 | 20 | ### Changed 21 | - Align build scripts and workflows with IntelliJ Platform Plugin Template 2025 updates. 22 | - Upgrade Gradle wrapper to 9.0 and align the Kotlin toolchain with Compose compatible 2.0.21. 23 | - Raise the minimum supported IntelliJ Platform build to 251 (2025.1). 24 | - Refactor TranslateAction to follow IntelliJ action system best practices. 25 | - Configure tests to run on the JUnit 5 framework while retaining required runtime compatibility. 26 | - Rebuild plugin UI (settings and dialogs) using Compose with searchable grids, favorite chips, and polished empty states. 27 | - Load secure credentials asynchronously to avoid password safe access on the EDT. 28 | - Reduce the minimum translation interval to 50 ms to keep throttled bursts responsive. 29 | - Update Compose theme colours to rely on JBColor so dialogs respect light and dark backgrounds. 30 | 31 | ### Fixed 32 | - Restore visibility of the "Translate to Other Languages" action when selecting resource files from the Project view. 33 | - Prevent Select Languages dialog from failing due to uninitialised UI components. 34 | 35 | ## [3.0.0] (2023-03-24) 36 | 37 | ### Added 38 | - Supported OpenAI ChatGPT translator. [#118](https://github.com/Airsaid/AndroidLocalizePlugin/pull/118) 39 | 40 | ### Other 41 | - Delayed error throwing to avoid losing successfully translated text. 42 | 43 | ## [2.9.0] (2022-11-29) 44 | 45 | ### Added 46 | - Supported DeepLPro translator. [#92](https://github.com/Airsaid/AndroidLocalizePlugin/issues/92) 47 | 48 | ### Fixed 49 | - Fix `xliff:g` attribute does not work. [#91](https://github.com/Airsaid/AndroidLocalizePlugin/issues/91) 50 | 51 | ## [2.8.0] (2022-10-31) 52 | 53 | ### Added 54 | - Supported DeepL translator. 55 | 56 | ## [2.7.0] (2022-10-11) 57 | 58 | ### Changed 59 | - Improve plugin description information. 60 | - Relax translation file name boundaries. 61 | 62 | ## [2.6.1] (2022-08-27) 63 | 64 | ### Added 65 | - Added rich text supported. 66 | - Added signature configuration. 67 | 68 | ### Changed 69 | - Upgrade Gradle Wrapper to `7.5.1`. 70 | - Upgrade Intellij Gradle Plugin to `1.8.1`. 71 | - Plugin description changed to be taken from the `README.md` file. 72 | 73 | ## [2.6.0] (2022-06-05) 74 | 75 | ### Added 76 | - Support Ali translator. 77 | - Support new IDE version. 78 | 79 | ### Fixed 80 | - Fix google translator translation instability. 81 | - Fix single quote character must be escaped in strings.xml. [#54](https://github.com/Airsaid/AndroidLocalizePlugin/issues/54) 82 | - Fix target folder naming. [#61](https://github.com/Airsaid/AndroidLocalizePlugin/issues/61) 83 | 84 | ## [2.5.0] (2022-02-11) 85 | 86 | ### Added 87 | - Support for preserving comments, blank lines and other characters. 88 | 89 | ## [2.4.0] (2022-01-21) 90 | 91 | ### Added 92 | - Supported custom google api key. 93 | - Supported plurals&string-array tags. 94 | - Added baidu icon of light mode. 95 | 96 | ### Changed 97 | - Changed maximum number of cacheable items to 1000. 98 | 99 | ## [2.3.0] (2021-07-09) 100 | 101 | ### Added 102 | - Add translation interval time setting. 103 | 104 | ### Changed 105 | - Replace plugin logo. 106 | 107 | ## [2.2.1] (2021-07-06) 108 | 109 | ### Fixed 110 | - Fix translation error when source text is in chinese [#33](https://github.com/Airsaid/AndroidLocalizePlugin/issues/33). 111 | 112 | ## [2.2.0] (2021-06-08) 113 | 114 | ### Added 115 | - Add power by translator description. 116 | 117 | ### Fixed 118 | - Fix incomplete Google translation long text [#31](https://github.com/Airsaid/AndroidLocalizePlugin/issues/31). 119 | 120 | ## [2.1.0] (2021-06-05) 121 | 122 | ### Added 123 | - Added Microsoft Translator. 124 | - Added "Use google.com" setting. 125 | - Supported more languages. 126 | 127 | ## [2.0.0] (2021-06-04) 128 | 129 | ### Added 130 | - Added multiple translator support. 131 | - Added "Open Translated File" option. 132 | - Added translation cache. 133 | 134 | ### Changed 135 | - Completely refactor the code. 136 | - Optimized the experience. 137 | 138 | ### Fixed 139 | - Fixed bugs. 140 | 141 | ## [1.5.0] (2020-03-28) 142 | 143 | ### Added 144 | - Added "Select All" option. 145 | 146 | ## [1.4.0] (2020-03-28) 147 | 148 | ### Added 149 | - Added proxy support. 150 | 151 | ### Fixed 152 | - Fixed bugs. 153 | 154 | ## [1.3.0] (2018-10-14) 155 | 156 | ### Added 157 | - Added "Overwrite Existing String" option. 158 | - Optimize the experience of choice. 159 | 160 | ## [1.2.0] (2018-09-28) 161 | 162 | ### Fixed 163 | - Fixed garbled bug. 164 | 165 | ## [1.1.0] (2018-09-25) 166 | 167 | ### Added 168 | - Supported for automatic detection of source file language. 169 | 170 | ## [1.0.0] (2018-09-24) 171 | - Initial release of the plugin. 172 | 173 | [Unreleased]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v4.0.0...HEAD 174 | [4.0.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v3.0.0...v4.0.0 175 | [3.0.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.9.0...v3.0.0 176 | [2.9.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.8.0...v2.9.0 177 | [2.8.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.7.0...v2.8.0 178 | [2.7.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.6.1...v2.7.0 179 | [2.6.1]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.6.0...v2.6.1 180 | [2.6.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.5.0...v2.6.0 181 | [2.5.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.4.0...v2.5.0 182 | [2.4.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.3.0...v2.4.0 183 | [2.3.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.2.1...v2.3.0 184 | [2.2.1]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.2.0...v2.2.1 185 | [2.2.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.1.0...v2.2.0 186 | [2.1.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v2.0.0...v2.1.0 187 | [2.0.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v1.5...v2.0.0 188 | [1.5.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v1.4...v1.5 189 | [1.4.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v1.3...v1.4 190 | [1.3.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v1.2...v1.3 191 | [1.2.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v1.1...v1.2 192 | [1.1.0]: https://github.com/Airsaid/AndroidLocalizePlugin/compare/v1.0...v1.1 193 | [1.0.0]: https://github.com/Airsaid/AndroidLocalizePlugin/commits/v1.0 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **English** | [简体中文](README_CN.md) 2 | 3 | # ![image](https://raw.githubusercontent.com/Airsaid/AndroidLocalizePlugin/85cf5020832523ea333ad09286af55880460457a/src/main/resources/META-INF/pluginIcon.svg) AndroidLocalizePlugin 4 | [![Plugin Version](https://img.shields.io/jetbrains/plugin/v/11174)](https://plugins.jetbrains.com/plugin/11174-androidlocalize) 5 | [![Plugin Rating](https://img.shields.io/jetbrains/plugin/r/rating/11174)](https://plugins.jetbrains.com/plugin/11174-androidlocalize) 6 | [![Build](https://github.com/Airsaid/AndroidLocalizePlugin/workflows/Build/badge.svg)](https://github.com/Airsaid/AndroidLocalizePlugin/actions/workflows/build.yml) 7 | 8 | 9 | [Website](https://plugins.jetbrains.com/plugin/11174-androidlocalize) | [GitHub](https://github.com/Airsaid/AndroidLocalizePlugin) | [Issues](https://github.com/Airsaid/AndroidLocalizePlugin/issues) | [Reviews](https://plugins.jetbrains.com/plugin/11174-androidlocalize/reviews) 10 | 11 | Android/KMP(Kotlin Multiplatform) localization plugin. supports multiple languages and multiple translators. 12 | 13 | # Features 14 | - Multiple translator support: 15 | - Google translator. 16 | - Microsoft translator. 17 | - Baidu translator. 18 | - Youdao translator. 19 | - Ali translator. 20 | - DeepL translator. 21 | - OpenAI translator. 22 | - Supports up to 100+ languages. 23 | - One key generates all translation files. 24 | - Support no translation of existing string. 25 | - Support for specifying that text is not translated. 26 | - Support for caching translated strings. 27 | - Support to set the translation interval time. 28 | 29 | # Usage 30 | - Step 1: Select the `values/strings.xml`(or any string resource in `values` directory). 31 | - Step 2: Right click and select "Translate to Other Languages". 32 | - Step 3: Select the languages to be translated. 33 | - Step 4: Click OK. 34 | 35 | 36 | 37 | # Preview 38 | ![image](preview/preview.png) 39 | ![image](preview/settings.png) 40 | ![image](preview/openai_settings.png) 41 | 42 | # Install 43 | [![Install Plugin](preview/install.png)](https://plugins.jetbrains.com/plugin/11174-androidlocalize) 44 | 45 | # FAQ 46 | - Q: How to ignore translation? 47 | 48 | A: Use the [translatable or xliff:g](https://developer.android.com/guide/topics/resources/localization#managing-strings) tags. for example: 49 | ``` 50 | HelloAndroid 51 | Check out our 5\u2605 52 | Visit us at https://github.com/Airsaid/AndroidLocalizePlugin 53 | Learn more at Muggle Studio 54 | ``` 55 | - Q: Translation failure: java.net.HttpRetryException: cannot retry due to redirection, in streaming mode 56 | 57 | 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. 58 | 59 | # ChangeLog 60 | [ChangeLog](CHANGELOG.md) 61 | 62 | # Support and Donations 63 | 64 | You can contribute and support this project by doing any of the following: 65 | 66 | - Star the project on GitHub. 67 | - Give feedback. 68 | - Commit PR. 69 | - Contribute your ideas/suggestions. 70 | - Share the plugin with your friends/colleagues. 71 | - If you like the plugin, please consider making a donation to keep the plugin active: 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 87 | 92 | 97 | 98 |
Open CollectiveWeChat PayAlipay
83 | 84 | Donate To Our Collective 85 | 86 | 88 | 89 | WeChat Pay 90 | 91 | 93 | 94 | Alipay 95 | 96 |
99 | 100 | **Thank you for your support!** 101 | 102 | # License 103 | ``` 104 | Copyright 2018 Airsaid. https://github.com/airsaid 105 | 106 | Licensed under the Apache License, Version 2.0 (the "License"); 107 | you may not use this file except in compliance with the License. 108 | You may obtain a copy of the License at 109 | 110 | http://www.apache.org/licenses/LICENSE-2.0 111 | 112 | Unless required by applicable law or agreed to in writing, software 113 | distributed under the License is distributed on an "AS IS" BASIS, 114 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 115 | See the License for the specific language governing permissions and 116 | limitations under the License. 117 | ``` 118 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | [English](README.md) | **简体中文** 2 | 3 | # ![image](https://raw.githubusercontent.com/Airsaid/AndroidLocalizePlugin/85cf5020832523ea333ad09286af55880460457a/src/main/resources/META-INF/pluginIcon.svg) AndroidLocalizePlugin 4 | [![Plugin Version](https://img.shields.io/jetbrains/plugin/v/11174)](https://plugins.jetbrains.com/plugin/11174-androidlocalize) 5 | [![Plugin Rating](https://img.shields.io/jetbrains/plugin/r/rating/11174)](https://plugins.jetbrains.com/plugin/11174-androidlocalize) 6 | [![Build](https://github.com/Airsaid/AndroidLocalizePlugin/workflows/Build/badge.svg)](https://github.com/Airsaid/AndroidLocalizePlugin/actions/workflows/build.yml) 7 | 8 | [Website](https://plugins.jetbrains.com/plugin/11174-androidlocalize) | [GitHub](https://github.com/Airsaid/AndroidLocalizePlugin) | [Issues](https://github.com/Airsaid/AndroidLocalizePlugin/issues) | [Reviews](https://plugins.jetbrains.com/plugin/11174-androidlocalize/reviews) 9 | 10 | Android/KMP(Kotlin Multiplatform) 本地化插件,支持多种语言和翻译器。 11 | 12 | # 功能 13 | - 多翻译器支持: 14 | - Google 翻译。 15 | - 微软翻译。 16 | - 百度翻译。 17 | - 有道翻译。 18 | - 阿里翻译。 19 | - DeepL 翻译。 20 | - OpenAI 翻译。 21 | - 支持最多 100+ 语言。 22 | - 一键生成所有翻译文件。 23 | - 支持不翻译已经存在的 string。 24 | - 支持不翻译指定的文本。 25 | - 支持缓存已翻译的 strings。 26 | - 支持设置翻译间隔时间。 27 | 28 | # 使用 29 | - 第一步:选择 `values/strings.xml` 文件(或者是 values 目录下的任何资源文件)。 30 | - 第二步:右键选择:“Translate to Other Languages”。 31 | - 第三步:勾选上需要翻译的语言。 32 | - 第四步:点击 OK。 33 | 34 | # 预览 35 | ![image](preview/preview.png) 36 | ![image](preview/settings.png) 37 | ![image](preview/openai_settings.png) 38 | 39 | # 安装 40 | [![Install Plugin](preview/install.png)](https://plugins.jetbrains.com/plugin/11174-androidlocalize) 41 | 42 | # 常见问题 43 | - 问题:如何忽略不让其翻译? 44 | 45 | 回答:可以使用 [translatable 或 xliff:g](https://developer.android.com/guide/topics/resources/localization#managing-strings) 标签。示例: 46 | ``` 47 | HelloAndroid 48 | Check out our 5\u2605 49 | Visit us at https://github.com/Airsaid/AndroidLocalizePlugin 50 | Learn more at Muggle Studio 51 | ``` 52 | 53 | - 问题:Translation failure: java.net.HttpRetryException: cannot retry due to redirection, in streaming mode 54 | 55 | 回答:可以在设置页面尝试切换到其他引擎,并使用自己的账号进行翻译。部分默认翻译引擎依赖共享凭证,可能会被限流。 56 | 57 | # 更新日志 58 | [更新日志](CHANGELOG.md) 59 | 60 | # 支持和捐赠 61 | 62 | 您可以通过执行以下任意操作来贡献和支持此项目: 63 | 64 | - 在 GitHub 上 Star 该项目。 65 | - 反馈问题。 66 | - 提交 PR。 67 | - 提出您的想法或建议。 68 | - 将插件分享给您的朋友和同事。 69 | - 如果您喜欢这个插件,请考虑捐赠以维持该插件和后续的更新: 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 85 | 90 | 95 | 96 |
Open Collective微信支付支付宝
81 | 82 | Donate To Our Collective 83 | 84 | 86 | 87 | WeChat Pay 88 | 89 | 91 | 92 | Alipay 93 | 94 |
97 | 98 | **感谢您的支持!** 99 | 100 | # 许可证 101 | ``` 102 | Copyright 2018 Airsaid. https://github.com/airsaid 103 | 104 | Licensed under the Apache License, Version 2.0 (the "License"); 105 | you may not use this file except in compliance with the License. 106 | You may obtain a copy of the License at 107 | 108 | http://www.apache.org/licenses/LICENSE-2.0 109 | 110 | Unless required by applicable law or agreed to in writing, software 111 | distributed under the License is distributed on an "AS IS" BASIS, 112 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 113 | See the License for the specific language governing permissions and 114 | limitations under the License. 115 | ``` 116 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.changelog.Changelog 2 | import org.jetbrains.changelog.markdownToHTML 3 | import org.jetbrains.intellij.platform.gradle.TestFrameworkType 4 | 5 | plugins { 6 | id("java") 7 | alias(libs.plugins.kotlin) 8 | alias(libs.plugins.kotlinKapt) 9 | alias(libs.plugins.composeCompiler) 10 | alias(libs.plugins.intelliJPlatform) 11 | alias(libs.plugins.changelog) 12 | alias(libs.plugins.qodana) 13 | alias(libs.plugins.kover) 14 | alias(libs.plugins.compose) 15 | } 16 | 17 | group = providers.gradleProperty("pluginGroup").get() 18 | version = providers.gradleProperty("pluginVersion").get() 19 | 20 | kotlin { 21 | jvmToolchain(21) 22 | } 23 | 24 | repositories { 25 | mavenCentral() 26 | google() 27 | maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") 28 | 29 | intellijPlatform { 30 | defaultRepositories() 31 | } 32 | } 33 | 34 | dependencies { 35 | implementation(libs.gson) 36 | implementation(libs.alimt) 37 | 38 | compileOnly(libs.autoServiceAnnotations) 39 | kapt(libs.autoService) 40 | 41 | compileOnly(compose.desktop.currentOs) 42 | 43 | testImplementation(libs.junitJupiterApi) 44 | testRuntimeOnly(libs.junitJupiterEngine) 45 | testRuntimeOnly(libs.junitPlatformLauncher) 46 | testRuntimeOnly(libs.junit4) 47 | 48 | intellijPlatform { 49 | create( 50 | providers.gradleProperty("platformType"), 51 | providers.gradleProperty("platformVersion"), 52 | ) 53 | 54 | // Compose support dependencies 55 | bundledModules( 56 | "intellij.libraries.skiko", 57 | "intellij.libraries.compose.foundation.desktop", 58 | "intellij.platform.jewel.foundation", 59 | "intellij.platform.jewel.ui", 60 | "intellij.platform.jewel.ideLafBridge", 61 | "intellij.platform.compose", 62 | ) 63 | bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',').filter(String::isNotBlank) }) 64 | plugins(providers.gradleProperty("platformPlugins").map { it.split(',').filter(String::isNotBlank) }) 65 | 66 | testFramework(TestFrameworkType.JUnit5) 67 | } 68 | } 69 | 70 | intellijPlatform { 71 | pluginConfiguration { 72 | name = providers.gradleProperty("pluginName") 73 | version = providers.gradleProperty("pluginVersion") 74 | 75 | description = providers.fileContents(layout.projectDirectory.file("README.md")).asText.map { 76 | val start = "" 77 | val end = "" 78 | 79 | with(it.lines()) { 80 | if (!containsAll(listOf(start, end))) { 81 | throw GradleException("Plugin description section not found in README.md:\n$start ... $end") 82 | } 83 | subList(indexOf(start) + 1, indexOf(end)).joinToString("\n").let(::markdownToHTML) 84 | } 85 | } 86 | 87 | val changelog = project.changelog 88 | changeNotes = providers.gradleProperty("pluginVersion").map { pluginVersion -> 89 | with(changelog) { 90 | renderItem( 91 | (getOrNull(pluginVersion) ?: getUnreleased()) 92 | .withHeader(false) 93 | .withEmptySections(false), 94 | Changelog.OutputType.HTML, 95 | ) 96 | } 97 | } 98 | 99 | ideaVersion { 100 | sinceBuild = providers.gradleProperty("pluginSinceBuild") 101 | untilBuild = providers.gradleProperty("pluginUntilBuild").map { it.ifBlank { null } } 102 | } 103 | } 104 | 105 | signing { 106 | certificateChain = providers.environmentVariable("CERTIFICATE_CHAIN") 107 | privateKey = providers.environmentVariable("PRIVATE_KEY") 108 | password = providers.environmentVariable("PRIVATE_KEY_PASSWORD") 109 | } 110 | 111 | publishing { 112 | token = providers.environmentVariable("PUBLISH_TOKEN") 113 | channels = providers.gradleProperty("pluginVersion").map { 114 | listOf(it.substringAfter('-', "").substringBefore('.').ifEmpty { "default" }) 115 | } 116 | } 117 | 118 | pluginVerification { 119 | ides { 120 | recommended() 121 | } 122 | } 123 | } 124 | 125 | changelog { 126 | groups.empty() 127 | repositoryUrl = providers.gradleProperty("pluginRepositoryUrl") 128 | } 129 | 130 | kover { 131 | reports { 132 | total { 133 | xml { 134 | onCheck = true 135 | } 136 | } 137 | } 138 | } 139 | 140 | intellijPlatformTesting { 141 | runIde { 142 | register("runIdeForUiTests") { 143 | task { 144 | jvmArgumentProviders += CommandLineArgumentProvider { 145 | listOf( 146 | "-Drobot-server.port=8082", 147 | "-Dide.mac.message.dialogs.as.sheets=false", 148 | "-Djb.privacy.policy.text=", 149 | "-Djb.consents.confirmation.enabled=false", 150 | ) 151 | } 152 | } 153 | 154 | plugins { 155 | robotServerPlugin() 156 | } 157 | } 158 | } 159 | } 160 | 161 | tasks { 162 | wrapper { 163 | gradleVersion = providers.gradleProperty("gradleVersion").get() 164 | } 165 | 166 | test { 167 | useJUnitPlatform() 168 | } 169 | 170 | publishPlugin { 171 | dependsOn(patchChangelog) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | threshold: 0% 7 | base: auto 8 | patch: 9 | default: 10 | informational: true 11 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # IntelliJ Platform Artifacts Repositories 2 | # -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html 3 | 4 | pluginGroup = com.airsaid 5 | pluginName = AndroidLocalize 6 | pluginRepositoryUrl = https://github.com/Airsaid/AndroidLocalizePlugin 7 | # SemVer format -> https://semver.org 8 | pluginVersion = 4.0.1 9 | 10 | # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html 11 | pluginSinceBuild = 251 12 | pluginUntilBuild = 13 | 14 | # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-extension.html 15 | platformType = IC 16 | platformVersion = 2025.1.5 17 | 18 | # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html 19 | # Example: platformBundledPlugins = com.intellij.java 20 | platformBundledPlugins = com.intellij.java 21 | # Example: platformPlugins = com.jetbrains.php:203.4449.22 22 | platformPlugins = 23 | 24 | # Gradle Releases -> https://github.com/gradle/gradle/releases 25 | gradleVersion = 9.0.0 26 | 27 | # Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib 28 | kotlin.stdlib.default.dependency = false 29 | 30 | # Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html 31 | org.gradle.configuration-cache = true 32 | 33 | # Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html 34 | org.gradle.caching = true 35 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | # libraries 3 | junitJupiter = "5.10.2" 4 | junitPlatform = "1.10.2" 5 | junittest4 = "4.13.2" 6 | autoService = "1.1.1" 7 | autoServiceAnnotations = "1.1.1" 8 | gson = "2.10.1" 9 | alimt = "1.0.3" 10 | 11 | # plugins 12 | changelog = "2.4.0" 13 | intelliJPlatform = "2.9.0" 14 | kotlin = "2.1.20" 15 | kover = "0.9.1" 16 | qodana = "2025.1.1" 17 | composePlugin = "1.9.0" 18 | 19 | [libraries] 20 | junitJupiterApi = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junitJupiter" } 21 | junitJupiterEngine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junitJupiter" } 22 | autoService = { group = "com.google.auto.service", name = "auto-service", version.ref = "autoService" } 23 | autoServiceAnnotations = { group = "com.google.auto.service", name = "auto-service-annotations", version.ref = "autoServiceAnnotations" } 24 | gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } 25 | alimt = { group = "com.aliyun", name = "alimt20181012", version.ref = "alimt" } 26 | junitPlatformLauncher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatform" } 27 | junit4 = { group = "junit", name = "junit", version.ref = "junittest4" } 28 | 29 | [plugins] 30 | changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } 31 | intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } 32 | kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 33 | kotlinKapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } 34 | composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } 35 | kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } 36 | qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } 37 | compose = { id = "org.jetbrains.compose", version.ref = "composePlugin" } 38 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Airsaid/AndroidLocalizePlugin/ab534471c3cb928f308128935134d6323b809a96/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS='"-Xmx64m"' 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /preview/install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Airsaid/AndroidLocalizePlugin/ab534471c3cb928f308128935134d6323b809a96/preview/install.png -------------------------------------------------------------------------------- /preview/openai_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Airsaid/AndroidLocalizePlugin/ab534471c3cb928f308128935134d6323b809a96/preview/openai_settings.png -------------------------------------------------------------------------------- /preview/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Airsaid/AndroidLocalizePlugin/ab534471c3cb928f308128935134d6323b809a96/preview/preview.png -------------------------------------------------------------------------------- /preview/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Airsaid/AndroidLocalizePlugin/ab534471c3cb928f308128935134d6323b809a96/preview/settings.png -------------------------------------------------------------------------------- /qodana.yml: -------------------------------------------------------------------------------- 1 | # Qodana configuration: 2 | # https://www.jetbrains.com/help/qodana/qodana-yaml.html 3 | 4 | version: "1.0" 5 | linter: jetbrains/qodana-jvm-community:2024.3 6 | projectJDK: "21" 7 | profile: 8 | name: qodana.recommended 9 | exclude: 10 | - name: All 11 | paths: 12 | - .qodana 13 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "AndroidLocalizePlugin" 2 | 3 | plugins { 4 | id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/action/TranslateAction.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.action 19 | 20 | import com.airsaid.localization.config.SettingsState 21 | import com.airsaid.localization.services.AndroidValuesService 22 | import com.airsaid.localization.task.TranslateTask 23 | import com.airsaid.localization.translate.lang.Lang 24 | import com.airsaid.localization.ui.SelectLanguagesDialog 25 | import com.airsaid.localization.utils.NotificationUtil 26 | import com.intellij.openapi.actionSystem.* 27 | import com.intellij.openapi.application.ReadAction 28 | import com.intellij.openapi.project.Project 29 | import com.intellij.psi.PsiElement 30 | import com.intellij.psi.PsiFile 31 | import com.intellij.psi.PsiManager 32 | import com.intellij.psi.xml.XmlTag 33 | 34 | /** 35 | * Translate android string value to other languages that can be used to localize your Android APP. 36 | * 37 | * @author airsaid 38 | */ 39 | class TranslateAction : AnAction() { 40 | 41 | override fun actionPerformed(e: AnActionEvent) { 42 | val project = e.getData(CommonDataKeys.PROJECT) ?: return 43 | val valueFile = e.getData(CommonDataKeys.PSI_FILE) ?: return 44 | val valueService = AndroidValuesService.getInstance() 45 | 46 | SettingsState.getInstance().initSetting() 47 | 48 | valueService.loadValuesByAsync(valueFile) { loadedValues -> 49 | if (!isTranslatable(loadedValues, valueService)) { 50 | NotificationUtil.notifyInfo(project, "The ${valueFile.name} has no text to translate.") 51 | return@loadValuesByAsync 52 | } 53 | showSelectLanguageDialog(project, loadedValues, valueFile) 54 | } 55 | } 56 | 57 | override fun update(e: AnActionEvent) { 58 | val project = e.project 59 | if (project == null) { 60 | e.presentation.isEnabledAndVisible = false 61 | return 62 | } 63 | 64 | val psiFile = resolvePsiFile(e) 65 | val isValueFile = AndroidValuesService.getInstance().isValueFile(psiFile) 66 | 67 | e.presentation.isEnabledAndVisible = isValueFile 68 | } 69 | 70 | override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT 71 | 72 | private fun resolvePsiFile(event: AnActionEvent): PsiFile? { 73 | event.getData(CommonDataKeys.PSI_FILE)?.let { return it } 74 | 75 | val element = event.getData(LangDataKeys.PSI_ELEMENT) 76 | element?.containingFile?.let { return it } 77 | 78 | val project = event.project ?: return null 79 | val virtualFile = event.getData(CommonDataKeys.VIRTUAL_FILE) ?: return null 80 | if (virtualFile.isDirectory) { 81 | return null 82 | } 83 | 84 | return ReadAction.compute { 85 | PsiManager.getInstance(project).findFile(virtualFile) 86 | } 87 | } 88 | } 89 | 90 | private fun isTranslatable(values: List, valueService: AndroidValuesService): Boolean { 91 | for (psiElement in values) { 92 | if (psiElement is XmlTag && valueService.isTranslatable(psiElement)) { 93 | return true 94 | } 95 | } 96 | return false 97 | } 98 | 99 | private fun showSelectLanguageDialog(project: Project, values: List, valueFile: PsiFile) { 100 | val dialog = SelectLanguagesDialog(project) 101 | 102 | // Set resource directory for auto-selecting existing languages 103 | val resourceDir = valueFile.virtualFile.parent.parent 104 | dialog.setResourceDir(resourceDir) 105 | 106 | dialog.setOnClickListener(object : SelectLanguagesDialog.OnClickListener { 107 | override fun onClickListener(selectedLanguage: List) { 108 | val translationTask = TranslateTask(project, "Translating...", selectedLanguage, values, valueFile) 109 | translationTask.setOnTranslateListener(object : TranslateTask.OnTranslateListener { 110 | override fun onTranslateSuccess() { 111 | NotificationUtil.notifyInfo(project, "Translation completed!") 112 | } 113 | 114 | override fun onTranslateError(e: Throwable) { 115 | NotificationUtil.notifyError(project, "Translation failure: ${e.localizedMessage}") 116 | } 117 | }) 118 | translationTask.queue() 119 | } 120 | }) 121 | dialog.show() 122 | } 123 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/config/SettingsConfigurable.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.config 19 | 20 | import com.airsaid.localization.constant.Constants 21 | import com.airsaid.localization.translate.services.TranslatorService 22 | import com.intellij.openapi.diagnostic.Logger 23 | import com.intellij.openapi.options.Configurable 24 | import com.intellij.openapi.options.ConfigurationException 25 | import javax.swing.JComponent 26 | 27 | /** 28 | * @author airsaid 29 | */ 30 | class SettingsConfigurable : Configurable { 31 | companion object { 32 | private val LOG = Logger.getInstance(SettingsConfigurable::class.java) 33 | } 34 | 35 | private var settingsComponent: SettingsComponent? = null 36 | 37 | override fun getDisplayName(): String { 38 | return Constants.PLUGIN_NAME 39 | } 40 | 41 | override fun getPreferredFocusedComponent(): JComponent? { 42 | return settingsComponent?.preferredFocusedComponent 43 | } 44 | 45 | override fun createComponent(): JComponent? { 46 | settingsComponent = SettingsComponent() 47 | initComponents() 48 | return settingsComponent?.content 49 | } 50 | 51 | private fun initComponents() { 52 | val settingsState = SettingsState.getInstance() 53 | val translators = TranslatorService.getInstance().getTranslators() 54 | val selected = settingsState.selectedTranslator 55 | settingsComponent?.let { component -> 56 | component.setTranslators(translators) 57 | component.setSelectedTranslator(translators[selected.key]!!) 58 | component.setEnableCache(settingsState.isEnableCache) 59 | component.setMaxCacheSize(settingsState.maxCacheSize) 60 | component.setTranslationInterval(settingsState.translationInterval) 61 | } 62 | } 63 | 64 | override fun isModified(): Boolean { 65 | val settingsState = SettingsState.getInstance() 66 | val selectedTranslator = settingsComponent?.selectedTranslator ?: return false 67 | 68 | var isChanged = settingsState.selectedTranslator != selectedTranslator 69 | 70 | isChanged = isChanged || settingsState.isEnableCache != (settingsComponent?.isEnableCache ?: false) 71 | isChanged = isChanged || settingsState.maxCacheSize != (settingsComponent?.maxCacheSize ?: 0) 72 | isChanged = isChanged || settingsState.translationInterval != (settingsComponent?.translationInterval ?: 0) 73 | 74 | LOG.info("isModified: $isChanged") 75 | return isChanged 76 | } 77 | 78 | @Throws(ConfigurationException::class) 79 | override fun apply() { 80 | val settingsState = SettingsState.getInstance() 81 | val selectedTranslator = settingsComponent?.selectedTranslator 82 | ?: throw ConfigurationException("No translator selected") 83 | 84 | LOG.info("apply selectedTranslator: ${selectedTranslator.name}") 85 | 86 | // Verify credential requirements 87 | settingsState.selectedTranslator = selectedTranslator 88 | 89 | selectedTranslator.credentialDefinitions.forEach { descriptor -> 90 | if (descriptor.required) { 91 | val storedValue = settingsState.getCredential(selectedTranslator.key, descriptor) 92 | if (storedValue.isBlank()) { 93 | throw ConfigurationException("${descriptor.label} not configured") 94 | } 95 | } 96 | } 97 | 98 | settingsComponent?.let { component -> 99 | settingsState.isEnableCache = component.isEnableCache 100 | settingsState.maxCacheSize = component.maxCacheSize 101 | settingsState.translationInterval = component.translationInterval 102 | 103 | val translatorService = TranslatorService.getInstance() 104 | translatorService.setSelectedTranslator(selectedTranslator) 105 | translatorService.setEnableCache(component.isEnableCache) 106 | translatorService.maxCacheSize = component.maxCacheSize 107 | translatorService.translationInterval = component.translationInterval 108 | } 109 | } 110 | 111 | override fun reset() { 112 | LOG.info("reset") 113 | val settingsState = SettingsState.getInstance() 114 | val selectedTranslator = settingsState.selectedTranslator 115 | settingsComponent?.let { component -> 116 | component.setSelectedTranslator(selectedTranslator) 117 | component.setEnableCache(settingsState.isEnableCache) 118 | component.setMaxCacheSize(settingsState.maxCacheSize) 119 | component.setTranslationInterval(settingsState.translationInterval) 120 | } 121 | } 122 | 123 | override fun disposeUIResources() { 124 | settingsComponent = null 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/config/SettingsState.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.config 19 | 20 | import com.airsaid.localization.services.AndroidValuesService 21 | import com.airsaid.localization.translate.AbstractTranslator 22 | import com.airsaid.localization.translate.TranslatorCredentialDescriptor 23 | import com.airsaid.localization.translate.services.TranslatorService 24 | import com.airsaid.localization.utils.SecureStorage 25 | import com.intellij.openapi.components.* 26 | import com.intellij.openapi.diagnostic.Logger 27 | import com.intellij.openapi.util.text.StringUtil 28 | 29 | /** 30 | * @author airsaid 31 | */ 32 | @State( 33 | name = "com.airsaid.localization.config.SettingsState", 34 | storages = [Storage("androidLocalizeSettings.xml")] 35 | ) 36 | @Service 37 | class SettingsState : PersistentStateComponent { 38 | companion object { 39 | private val LOG = Logger.getInstance(SettingsState::class.java) 40 | 41 | fun getInstance(): SettingsState { 42 | return ServiceManager.getService(SettingsState::class.java) 43 | } 44 | } 45 | 46 | private val credentialSecureStorage = mutableMapOf() 47 | private var state = State() 48 | 49 | fun initSetting() { 50 | val translatorService = TranslatorService.getInstance() 51 | translatorService.setSelectedTranslator(this.selectedTranslator) 52 | translatorService.setEnableCache(isEnableCache) 53 | translatorService.maxCacheSize = maxCacheSize 54 | translatorService.translationInterval = translationInterval 55 | 56 | AndroidValuesService.getInstance().isSkipNonTranslatable = isSkipNonTranslatable 57 | } 58 | 59 | var selectedTranslator: AbstractTranslator 60 | get() = if (StringUtil.isEmpty(state.selectedTranslatorKey)) { 61 | TranslatorService.getInstance().getDefaultTranslator() 62 | } else { 63 | TranslatorService.getInstance().getTranslators()[state.selectedTranslatorKey] 64 | ?: TranslatorService.getInstance().getDefaultTranslator() 65 | } 66 | set(translator) { 67 | state.selectedTranslatorKey = translator.key 68 | } 69 | 70 | fun setCredential(translatorKey: String, descriptor: TranslatorCredentialDescriptor, value: String) { 71 | if (descriptor.isSecret) { 72 | secureStorage(translatorKey, descriptor.id).save(value) 73 | } else { 74 | val credentials = state.credentials.getOrPut(translatorKey) { mutableMapOf() } 75 | if (value.isBlank()) { 76 | credentials.remove(descriptor.id) 77 | if (credentials.isEmpty()) { 78 | state.credentials.remove(translatorKey) 79 | } 80 | } else { 81 | credentials[descriptor.id] = value 82 | } 83 | } 84 | } 85 | 86 | fun getCredential(translatorKey: String, descriptor: TranslatorCredentialDescriptor): String { 87 | return if (descriptor.isSecret) { 88 | readSecret(translatorKey, descriptor) 89 | } else { 90 | state.credentials[translatorKey]?.get(descriptor.id) ?: "" 91 | } 92 | } 93 | 94 | fun getCredentials(translatorKey: String, descriptors: List): Map { 95 | if (descriptors.isEmpty()) return emptyMap() 96 | return buildMap { 97 | descriptors.forEach { descriptor -> 98 | put(descriptor.id, getCredential(translatorKey, descriptor)) 99 | } 100 | } 101 | } 102 | 103 | var isEnableCache: Boolean 104 | get() = state.isEnableCache 105 | set(isEnable) { 106 | state.isEnableCache = isEnable 107 | } 108 | 109 | var maxCacheSize: Int 110 | get() = state.maxCacheSize 111 | set(maxCacheSize) { 112 | state.maxCacheSize = maxCacheSize 113 | } 114 | 115 | var translationInterval: Int 116 | get() = state.translationInterval 117 | set(intervalTime) { 118 | state.translationInterval = intervalTime 119 | } 120 | 121 | var isSkipNonTranslatable: Boolean 122 | get() = state.isSkipNonTranslatable 123 | set(isSkipNonTranslatable) { 124 | state.isSkipNonTranslatable = isSkipNonTranslatable 125 | } 126 | 127 | override fun getState(): State { 128 | return state 129 | } 130 | 131 | override fun loadState(state: State) { 132 | this.state = state 133 | normalizeTranslationInterval() 134 | } 135 | 136 | data class State( 137 | var selectedTranslatorKey: String? = null, 138 | var credentials: MutableMap> = mutableMapOf(), 139 | var isEnableCache: Boolean = true, 140 | var maxCacheSize: Int = 500, 141 | var translationInterval: Int = 500, // milliseconds 142 | var isSkipNonTranslatable: Boolean = false, 143 | ) 144 | 145 | private fun secureStorage(translatorKey: String, credentialId: String): SecureStorage { 146 | val key = "$translatorKey::$credentialId" 147 | return credentialSecureStorage.getOrPut(key) { SecureStorage(key) } 148 | } 149 | 150 | private fun normalizeTranslationInterval() { 151 | if (state.translationInterval in 1..10) { 152 | state.translationInterval *= 1000 153 | } 154 | if (state.translationInterval <= 0) { 155 | state.translationInterval = 50 156 | } 157 | } 158 | 159 | private fun readSecret(translatorKey: String, descriptor: TranslatorCredentialDescriptor): String { 160 | val storage = secureStorage(translatorKey, descriptor.id) 161 | val value = storage.read() 162 | if (value.isNotEmpty()) { 163 | return value 164 | } 165 | 166 | // Backwards compatibility: migrate legacy key-only storage if present. 167 | val legacy = credentialSecureStorage.getOrPut(translatorKey) { SecureStorage(translatorKey) } 168 | val legacyValue = legacy.read() 169 | if (legacyValue.isNotEmpty()) { 170 | storage.save(legacyValue) 171 | return legacyValue 172 | } 173 | return "" 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/config/TranslatorConfigurationManager.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.config 2 | 3 | import com.airsaid.localization.translate.AbstractTranslator 4 | import com.airsaid.localization.translate.impl.deepl.DeepLTranslatorCredentialsDialog 5 | import com.airsaid.localization.translate.impl.google.GoogleTranslatorSettingsDialog 6 | import com.airsaid.localization.translate.impl.openai.OpenAITranslatorSettingsDialog 7 | import com.intellij.openapi.diagnostic.Logger 8 | 9 | /** 10 | * Routes translator configuration requests to the appropriate UI dialog. 11 | * 12 | * @author airsaid 13 | */ 14 | object TranslatorConfigurationManager { 15 | 16 | private val LOG = Logger.getInstance(TranslatorConfigurationManager::class.java) 17 | 18 | fun showConfigurationDialog(translator: AbstractTranslator): Boolean { 19 | return when (translator.key) { 20 | "Google" -> GoogleTranslatorSettingsDialog().showAndGet() 21 | "OpenAI" -> OpenAITranslatorSettingsDialog(translator, SettingsState.getInstance()).showAndGet() 22 | "DeepL" -> DeepLTranslatorCredentialsDialog(translator, SettingsState.getInstance()).showAndGet() 23 | else -> showCredentialDialog(translator) 24 | } 25 | } 26 | 27 | fun hasConfiguration(translator: AbstractTranslator): Boolean { 28 | return translator.key == "Google" || translator.key == "OpenAI" || translator.credentialDefinitions.isNotEmpty() 29 | } 30 | 31 | private fun showCredentialDialog(translator: AbstractTranslator): Boolean { 32 | if (translator.credentialDefinitions.isEmpty()) { 33 | LOG.debug("Translator ${translator.key} has no configurable credentials") 34 | return false 35 | } 36 | val dialog = TranslatorCredentialsDialog(translator, SettingsState.getInstance()) 37 | return dialog.showAndGet() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/config/TranslatorCredentialsDialog.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.config 2 | 3 | import androidx.compose.foundation.layout.* 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.mutableStateMapOf 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.runtime.snapshots.SnapshotStateMap 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.unit.dp 10 | import com.airsaid.localization.translate.AbstractTranslator 11 | import com.airsaid.localization.translate.TranslatorCredentialDescriptor 12 | import com.airsaid.localization.ui.ComposeDialog 13 | import com.airsaid.localization.ui.components.IdeTextField 14 | import com.intellij.ide.BrowserUtil 15 | import org.jetbrains.jewel.foundation.theme.JewelTheme 16 | import org.jetbrains.jewel.ui.component.Link 17 | import org.jetbrains.jewel.ui.component.Text 18 | 19 | /** 20 | * Base Compose dialog for collecting and persisting translator credentials. 21 | * 22 | * @author airsaid 23 | */ 24 | open class TranslatorCredentialsDialog( 25 | protected val translator: AbstractTranslator, 26 | protected val settingsState: SettingsState, 27 | ) : ComposeDialog() { 28 | 29 | protected val credentialValuesState: SnapshotStateMap = mutableStateMapOf() 30 | 31 | init { 32 | title = "${translator.name} Settings" 33 | 34 | translator.credentialDefinitions.forEach { descriptor -> 35 | credentialValuesState[descriptor.id] = settingsState.getCredential(translator.key, descriptor) 36 | } 37 | } 38 | 39 | @Composable 40 | override fun Content() { 41 | val descriptors = remember { translator.credentialDefinitions } 42 | 43 | Column( 44 | modifier = Modifier 45 | .padding(horizontal = 20.dp, vertical = 16.dp), 46 | verticalArrangement = Arrangement.spacedBy(10.dp) 47 | ) { 48 | Header() 49 | 50 | descriptors.forEach { descriptor -> 51 | CredentialField(descriptor = descriptor) 52 | } 53 | 54 | translator.credentialHelpUrl?.takeUnless { it.isBlank() }?.let { url -> 55 | Link( 56 | text = "How to obtain credentials?", 57 | onClick = { BrowserUtil.browse(url) } 58 | ) 59 | } 60 | 61 | Footer() 62 | } 63 | } 64 | 65 | @Composable 66 | private fun CredentialField(descriptor: TranslatorCredentialDescriptor) { 67 | Column(modifier = Modifier.width(320.dp)) { 68 | Text( 69 | text = descriptor.label, 70 | color = JewelTheme.globalColors.text.info 71 | ) 72 | Spacer(modifier = Modifier.height(6.dp)) 73 | IdeTextField( 74 | value = credentialValuesState[descriptor.id] ?: "", 75 | onValueChange = { newValue -> credentialValuesState[descriptor.id] = newValue.trimStart() }, 76 | modifier = Modifier.fillMaxWidth(), 77 | singleLine = true, 78 | secureInput = descriptor.isSecret 79 | ) 80 | descriptor.description?.takeIf { it.isNotBlank() }?.let { helper -> 81 | Spacer(modifier = Modifier.height(4.dp)) 82 | Text( 83 | text = helper, 84 | color = JewelTheme.globalColors.text.info 85 | ) 86 | } 87 | } 88 | } 89 | 90 | /** 91 | * Persists the sanitized credential values before closing the dialog. 92 | */ 93 | override fun doOKAction() { 94 | super.doOKAction() 95 | credentialValuesState.forEach { (id, value) -> 96 | translator.credentialDefinitions.firstOrNull { it.id == id }?.let { descriptor -> 97 | settingsState.setCredential(translator.key, descriptor, value.trim()) 98 | } 99 | } 100 | } 101 | 102 | @Composable 103 | protected open fun Header() = Unit 104 | 105 | @Composable 106 | protected open fun Footer() = Unit 107 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/constant/Constants.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.constant 19 | 20 | /** 21 | * Constant Store. 22 | * 23 | * @author airsaid 24 | */ 25 | object Constants { 26 | const val PLUGIN_NAME = "AndroidLocalize" 27 | const val PLUGIN_ID = "com.github.airsaid.androidlocalize" 28 | const val KEY_SELECTED_LANGUAGES = "$PLUGIN_ID.selected_languages.v2" 29 | const val KEY_FAVORITE_LANGUAGES = "$PLUGIN_ID.favorite_languages" 30 | const val KEY_IS_OVERWRITE_EXISTING_STRING = "$PLUGIN_ID.is_overwrite_existing_string" 31 | const val KEY_IS_OPEN_TRANSLATED_FILE = "$PLUGIN_ID.is_open_translated_file" 32 | const val KEY_IS_AUTO_SELECT_EXISTING = "$PLUGIN_ID.is_auto_select_existing" 33 | } 34 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/extensions/StringExtensions.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.extensions 19 | 20 | private val NON_TRANSLATABLE_REGEX = Regex("^[\\d\\p{P}\\p{S}\\s]+$") 21 | 22 | /** 23 | * Checks if this string contains only numbers, symbols, punctuation, and whitespace. 24 | * This is used to determine if text needs translation - strings with only these characters 25 | * typically don't require translation. 26 | * 27 | * @return `true` if this string is not null, not empty, and contains only digits, punctuation, 28 | * symbols, and whitespace. `false` if this string is null, empty, or contains any 29 | * letters that would need translation. 30 | * 31 | * @sample 32 | * ``` 33 | * "123".hasNoTranslatableText() // returns true 34 | * "12.3".hasNoTranslatableText() // returns true 35 | * "-123".hasNoTranslatableText() // returns true 36 | * "123!@#".hasNoTranslatableText() // returns true 37 | * "12 34".hasNoTranslatableText() // returns true 38 | * "abc".hasNoTranslatableText() // returns false 39 | * "123abc".hasNoTranslatableText() // returns false 40 | * null.hasNoTranslatableText() // returns true 41 | * ``` 42 | */ 43 | fun String?.hasNoTranslatableText(): Boolean { 44 | if (this.isNullOrEmpty()) return true 45 | return this.matches(NON_TRANSLATABLE_REGEX) 46 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/TranslationException.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate 19 | 20 | import com.airsaid.localization.translate.lang.Lang 21 | import com.intellij.openapi.diagnostic.Logger 22 | 23 | /** 24 | * @author airsaid 25 | */ 26 | class TranslationException : RuntimeException { 27 | 28 | companion object { 29 | private val LOG = Logger.getInstance(TranslationException::class.java) 30 | } 31 | 32 | val fromLang: Lang 33 | val toLang: Lang 34 | val text: String 35 | 36 | constructor(fromLang: Lang, toLang: Lang, text: String, cause: Throwable) : super( 37 | "Failed to translate \"$text\" from ${fromLang.englishName} to ${toLang.englishName} with error: ${cause.message}", 38 | cause 39 | ) { 40 | this.fromLang = fromLang 41 | this.toLang = toLang 42 | this.text = text 43 | LOG.warn("TranslationException: ${cause.message}", cause) 44 | } 45 | 46 | constructor(fromLang: Lang, toLang: Lang, text: String, message: String) : super( 47 | "Failed to translate \"$text\" from ${fromLang.englishName} to ${toLang.englishName} with error: $message" 48 | ) { 49 | this.fromLang = fromLang 50 | this.toLang = toLang 51 | this.text = text 52 | LOG.warn("TranslationException: $message") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/TranslationResult.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate 19 | 20 | 21 | /** 22 | * Translation results interface to obtain common translation result. 23 | * 24 | * @author airsaid 25 | */ 26 | interface TranslationResult { 27 | 28 | /** 29 | * Get a translation result of the specified text. 30 | * 31 | * @return translation result text. 32 | */ 33 | val translationResult: String 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/Translator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate 19 | 20 | import com.airsaid.localization.translate.lang.Lang 21 | 22 | /** 23 | * The translator interface, the direct implementation class is [AbstractTranslator], 24 | * and all translators should extends [AbstractTranslator] to avoid writing duplicate code. 25 | * 26 | * @author airsaid 27 | * @see AbstractTranslator 28 | */ 29 | interface Translator { 30 | 31 | /** 32 | * Invoke translation operation. 33 | * 34 | * @param fromLang the language of text. 35 | * @param toLang the language to be translated into. 36 | * @param text the text to be translated. 37 | * @return the translated text. 38 | * @throws TranslationException this exception is thrown if the translation failed. 39 | */ 40 | @Throws(TranslationException::class) 41 | fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/TranslatorConfigurable.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate 19 | 20 | import com.airsaid.localization.translate.lang.Lang 21 | import javax.swing.Icon 22 | 23 | /** 24 | * @author airsaid 25 | */ 26 | interface TranslatorConfigurable { 27 | 28 | val key: String 29 | 30 | val name: String 31 | 32 | val icon: Icon? 33 | 34 | val supportedLanguages: List 35 | 36 | /** Describes credentials required to use this translator. */ 37 | val credentialDefinitions: List 38 | 39 | /** Optional URL for requesting credentials or reading setup instructions. */ 40 | val credentialHelpUrl: String? 41 | } 42 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/TranslatorCredentialDescriptor.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate 2 | 3 | /** 4 | * Descriptor of a credential field required by a translator. 5 | * 6 | * @author airsaid 7 | */ 8 | data class TranslatorCredentialDescriptor( 9 | val id: String, 10 | val label: String, 11 | val isSecret: Boolean = true, 12 | val required: Boolean = true, 13 | val description: String? = null, 14 | val placeholder: String? = null, 15 | ) 16 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/ali/AliTranslator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.impl.ali 19 | 20 | import com.airsaid.localization.translate.AbstractTranslator 21 | import com.airsaid.localization.translate.TranslationException 22 | import com.airsaid.localization.translate.TranslatorCredentialDescriptor 23 | import com.airsaid.localization.translate.lang.Lang 24 | import com.airsaid.localization.translate.lang.Languages 25 | import com.airsaid.localization.translate.lang.toLang 26 | import com.aliyun.alimt20181012.Client 27 | import com.aliyun.alimt20181012.models.TranslateGeneralRequest 28 | import com.aliyun.alimt20181012.models.TranslateGeneralResponse 29 | import com.aliyun.teaopenapi.models.Config 30 | import com.aliyun.teautil.models.RuntimeOptions 31 | import com.google.auto.service.AutoService 32 | import icons.PluginIcons 33 | 34 | /** 35 | * @author airsaid 36 | */ 37 | @AutoService(AbstractTranslator::class) 38 | class AliTranslator : AbstractTranslator() { 39 | 40 | companion object { 41 | private const val KEY = "Ali" 42 | private const val ENDPOINT = "mt.aliyuncs.com" 43 | private const val APPLY_APP_ID_URL = "https://www.aliyun.com/product/ai/base_alimt" 44 | } 45 | 46 | override val key = KEY 47 | 48 | override val icon = PluginIcons.ALI_ICON 49 | 50 | override val credentialDefinitions = listOf( 51 | TranslatorCredentialDescriptor(id = "appId", label = "AccessKey ID", isSecret = false), 52 | TranslatorCredentialDescriptor(id = "appKey", label = "AccessKey Secret", isSecret = true) 53 | ) 54 | 55 | override val credentialHelpUrl: String? = APPLY_APP_ID_URL 56 | 57 | override val supportedLanguages: List by lazy { 58 | Languages.entries 59 | .asSequence() 60 | .filter { language -> 61 | language != Languages.AUTO && 62 | language != Languages.UKRAINIAN && 63 | language != Languages.DARI 64 | } 65 | .map { language -> 66 | val lang = language.toLang() 67 | when (language) { 68 | Languages.CHINESE_SIMPLIFIED -> lang.setTranslationCode("zh") 69 | Languages.CHINESE_TRADITIONAL -> lang.setTranslationCode("zh-tw") 70 | Languages.INDONESIAN -> lang.setTranslationCode("id") 71 | Languages.CROATIAN -> lang.setTranslationCode("hbs") 72 | Languages.HEBREW -> lang.setTranslationCode("he") 73 | else -> lang 74 | } 75 | } 76 | .toList() 77 | } 78 | 79 | @Throws(TranslationException::class) 80 | override fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { 81 | checkSupportedLanguages(fromLang, toLang, text) 82 | 83 | val credentials = resolveCredentials(fromLang, toLang, text) 84 | 85 | val config = Config() 86 | .setAccessKeyId(credentials.first) 87 | .setAccessKeySecret(credentials.second) 88 | .setEndpoint(ENDPOINT) 89 | val client = try { 90 | Client(config) 91 | } catch (e: Exception) { 92 | throw TranslationException(fromLang, toLang, text, e) 93 | } 94 | 95 | val request = TranslateGeneralRequest() 96 | .setFormatType("text") 97 | .setSourceLanguage(fromLang.translationCode) 98 | .setTargetLanguage(toLang.translationCode) 99 | .setSourceText(text) 100 | .setScene("general") 101 | 102 | val runtime = RuntimeOptions() 103 | val response: TranslateGeneralResponse 104 | 105 | try { 106 | response = client.translateGeneralWithOptions(request, runtime) 107 | } catch (e: Exception) { 108 | throw TranslationException(fromLang, toLang, text, e) 109 | } 110 | 111 | val body = response.body 112 | return if (body.code == 200) { 113 | body.data.translated 114 | } else { 115 | throw TranslationException(fromLang, toLang, text, "${body.message}(${body.code})") 116 | } 117 | } 118 | 119 | private fun resolveCredentials( 120 | fromLang: Lang, 121 | toLang: Lang, 122 | text: String 123 | ): Pair { 124 | val accessKeyId = credentialValue("appId").takeIf { it.isNotBlank() } 125 | val accessKeySecret = credentialValue("appKey").takeIf { it.isNotBlank() } 126 | if (accessKeyId == null || accessKeySecret == null) { 127 | throw TranslationException(fromLang, toLang, text, "AccessKey credentials are not configured") 128 | } 129 | return Pair(accessKeyId, accessKeySecret) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslationResult.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.impl.baidu 19 | 20 | import com.airsaid.localization.translate.TranslationResult 21 | import com.google.gson.annotations.SerializedName 22 | import com.intellij.openapi.util.text.StringUtil 23 | 24 | /** 25 | * @author airsaid 26 | */ 27 | data class BaiduTranslationResult( 28 | var from: String? = null, 29 | var to: String? = null, 30 | @SerializedName("trans_result") 31 | var contents: List? = null, 32 | @SerializedName("error_code") 33 | var errorCode: String? = null, 34 | @SerializedName("error_msg") 35 | var errorMsg: String? = null 36 | ) : TranslationResult { 37 | 38 | fun isSuccess(): Boolean { 39 | val errorCode = this.errorCode 40 | return StringUtil.isEmpty(errorCode) || "52000" == errorCode 41 | } 42 | 43 | override val translationResult: String 44 | get() { 45 | val contents = this.contents 46 | if (contents.isNullOrEmpty()) { 47 | return "" 48 | } 49 | val dst = contents[0].dst 50 | return dst ?: "" 51 | } 52 | 53 | data class Content( 54 | var src: String? = null, 55 | var dst: String? = null 56 | ) 57 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/baidu/BaiduTranslator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.impl.baidu 19 | 20 | import com.airsaid.localization.translate.AbstractTranslator 21 | import com.airsaid.localization.translate.TranslationException 22 | import com.airsaid.localization.translate.TranslatorCredentialDescriptor 23 | import com.airsaid.localization.translate.lang.Lang 24 | import com.airsaid.localization.translate.lang.Languages 25 | import com.airsaid.localization.translate.lang.toLang 26 | import com.airsaid.localization.translate.util.GsonUtil 27 | import com.airsaid.localization.translate.util.MD5 28 | import com.google.auto.service.AutoService 29 | import com.intellij.openapi.diagnostic.Logger 30 | import com.intellij.openapi.util.Pair 31 | import com.intellij.util.io.RequestBuilder 32 | import icons.PluginIcons 33 | 34 | /** 35 | * @author airsaid 36 | */ 37 | @AutoService(AbstractTranslator::class) 38 | class BaiduTranslator : AbstractTranslator() { 39 | 40 | companion object { 41 | private val LOG = Logger.getInstance(BaiduTranslator::class.java) 42 | private const val KEY = "Baidu" 43 | private const val HOST_URL = "http://api.fanyi.baidu.com" 44 | private const val TRANSLATE_URL = "$HOST_URL/api/trans/vip/translate" 45 | private const val APPLY_APP_ID_URL = "http://api.fanyi.baidu.com/api/trans/product/desktop?req=developer" 46 | } 47 | 48 | override val key = KEY 49 | 50 | override val icon = PluginIcons.BAIDU_ICON 51 | 52 | override val supportedLanguages: List by lazy { 53 | listOf( 54 | Languages.CHINESE_SIMPLIFIED.toLang().setTranslationCode("zh"), 55 | Languages.ENGLISH.toLang(), 56 | Languages.JAPANESE.toLang().setTranslationCode("jp"), 57 | Languages.KOREAN.toLang().setTranslationCode("kor"), 58 | Languages.FRENCH.toLang().setTranslationCode("fra"), 59 | Languages.SPANISH.toLang().setTranslationCode("spa"), 60 | Languages.THAI.toLang(), 61 | Languages.ARABIC.toLang().setTranslationCode("ara"), 62 | Languages.RUSSIAN.toLang(), 63 | Languages.PORTUGUESE.toLang(), 64 | Languages.GERMAN.toLang(), 65 | Languages.ITALIAN.toLang(), 66 | Languages.GREEK.toLang(), 67 | Languages.DUTCH.toLang(), 68 | Languages.POLISH.toLang(), 69 | Languages.BULGARIAN.toLang().setTranslationCode("bul"), 70 | Languages.ESTONIAN.toLang().setTranslationCode("est"), 71 | Languages.DANISH.toLang().setTranslationCode("dan"), 72 | Languages.FINNISH.toLang().setTranslationCode("fin"), 73 | Languages.CZECH.toLang(), 74 | Languages.ROMANIAN.toLang().setTranslationCode("rom"), 75 | Languages.SLOVENIAN.toLang().setTranslationCode("slo"), 76 | Languages.SWEDISH.toLang().setTranslationCode("swe"), 77 | Languages.HUNGARIAN.toLang(), 78 | Languages.CHINESE_TRADITIONAL.toLang().setTranslationCode("cht"), 79 | Languages.VIETNAMESE.toLang().setTranslationCode("vie"), 80 | ) 81 | } 82 | 83 | override val credentialDefinitions = listOf( 84 | TranslatorCredentialDescriptor(id = "appId", label = "APP ID", isSecret = false), 85 | TranslatorCredentialDescriptor(id = "appKey", label = "APP KEY", isSecret = true) 86 | ) 87 | 88 | override val credentialHelpUrl: String? = APPLY_APP_ID_URL 89 | 90 | override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String = TRANSLATE_URL 91 | 92 | override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { 93 | val salt = System.currentTimeMillis().toString() 94 | val appId = credentialValue("appId") 95 | val securityKey = credentialValue("appKey") 96 | val sign = MD5.md5("$appId$text$salt$securityKey") 97 | 98 | return listOf( 99 | Pair.create("from", fromLang.translationCode), 100 | Pair.create("to", toLang.translationCode), 101 | Pair.create("appid", appId), 102 | Pair.create("salt", salt), 103 | Pair.create("sign", sign), 104 | Pair.create("q", text) 105 | ) 106 | } 107 | 108 | override fun configureRequestBuilder(requestBuilder: RequestBuilder) { 109 | requestBuilder.tuner { connection -> 110 | connection.setRequestProperty("Referer", HOST_URL) 111 | } 112 | } 113 | 114 | override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { 115 | LOG.info("parsingResult: $resultText") 116 | val baiduTranslationResult = GsonUtil.getInstance().gson.fromJson(resultText, BaiduTranslationResult::class.java) 117 | return if (baiduTranslationResult.isSuccess()) { 118 | baiduTranslationResult.translationResult 119 | } else { 120 | val message = "${baiduTranslationResult.errorMsg}(${baiduTranslationResult.errorCode})" 121 | throw TranslationException(fromLang, toLang, text, message) 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslationResult.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.impl.deepl 19 | 20 | import com.airsaid.localization.translate.TranslationResult 21 | 22 | /** 23 | * @author musagil 24 | */ 25 | data class DeepLTranslationResult( 26 | var translations: List? = null 27 | ) : TranslationResult { 28 | 29 | override val translationResult: String 30 | get() { 31 | return if (!translations.isNullOrEmpty()) { 32 | val result = translations!![0].text 33 | result ?: "" 34 | } else { 35 | "" 36 | } 37 | } 38 | 39 | data class Translation( 40 | var text: String? = null, 41 | var to: String? = null 42 | ) 43 | 44 | data class Error( 45 | var code: String? = null, 46 | var message: String? = null 47 | ) 48 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.impl.deepl 19 | 20 | import com.airsaid.localization.translate.AbstractTranslator 21 | import com.airsaid.localization.translate.TranslatorCredentialDescriptor 22 | import com.airsaid.localization.translate.lang.Lang 23 | import com.airsaid.localization.translate.lang.Languages 24 | import com.airsaid.localization.translate.lang.toLang 25 | import com.airsaid.localization.translate.util.GsonUtil 26 | import com.airsaid.localization.translate.util.UrlBuilder 27 | import com.google.auto.service.AutoService 28 | import com.intellij.openapi.diagnostic.Logger 29 | import com.intellij.openapi.util.Pair 30 | import com.intellij.util.io.RequestBuilder 31 | import icons.PluginIcons 32 | 33 | /** 34 | * @author musagil 35 | */ 36 | @AutoService(AbstractTranslator::class) 37 | open class DeepLTranslator : AbstractTranslator() { 38 | 39 | companion object { 40 | private val LOG = Logger.getInstance(DeepLTranslator::class.java) 41 | private const val KEY = "DeepL" 42 | private const val FREE_HOST_URL = "https://api-free.deepl.com/v2" 43 | private const val PRO_HOST_URL = "https://api.deepl.com/v2" 44 | private const val TRANSLATE_PATH = "/translate" 45 | private const val APPLY_APP_ID_URL = "https://www.deepl.com/pro-api?cta=header-pro-api/" 46 | } 47 | 48 | private val deeplSettings by lazy { DeepLTranslatorSettings.getInstance() } 49 | 50 | override val key = KEY 51 | 52 | override val icon = PluginIcons.DEEP_L_ICON 53 | 54 | override val credentialDefinitions = listOf( 55 | TranslatorCredentialDescriptor(id = "appKey", label = "KEY", isSecret = true) 56 | ) 57 | 58 | override val credentialHelpUrl: String? = APPLY_APP_ID_URL 59 | 60 | override val supportedLanguages: List by lazy { 61 | buildList { 62 | add(Languages.BULGARIAN.toLang()) 63 | add(Languages.CZECH.toLang()) 64 | add(Languages.DANISH.toLang()) 65 | add(Languages.GERMAN.toLang()) 66 | add(Languages.GREEK.toLang()) 67 | add( 68 | Languages.ENGLISH.toLang().copy( 69 | code = "en-gb", 70 | name = "English (British)", 71 | englishName = "English (British)", 72 | directoryName = "en-rGB", 73 | ).setTranslationCode("en-gb") 74 | ) 75 | add( 76 | Languages.ENGLISH.toLang().copy( 77 | code = "en-us", 78 | name = "English (American)", 79 | englishName = "English (American)", 80 | directoryName = "en-rUS", 81 | ).setTranslationCode("en-us") 82 | ) 83 | add(Languages.SPANISH.toLang()) 84 | add(Languages.ESTONIAN.toLang()) 85 | add(Languages.FINNISH.toLang()) 86 | add(Languages.FRENCH.toLang()) 87 | add(Languages.HUNGARIAN.toLang()) 88 | add(Languages.INDONESIAN.toLang()) 89 | add(Languages.ITALIAN.toLang()) 90 | add(Languages.JAPANESE.toLang()) 91 | add(Languages.KOREAN.toLang().setTranslationCode("KO")) 92 | add(Languages.LITHUANIAN.toLang()) 93 | add(Languages.LATVIAN.toLang()) 94 | add(Languages.NORWEGIAN.toLang().setTranslationCode("NB")) 95 | add(Languages.DUTCH.toLang()) 96 | add(Languages.POLISH.toLang()) 97 | add( 98 | Languages.PORTUGUESE.toLang().copy( 99 | code = "pt-br", 100 | name = "Portuguese (Brazilian)", 101 | englishName = "Portuguese (Brazilian)", 102 | directoryName = "pt-rBR", 103 | ).setTranslationCode("pt-br") 104 | ) 105 | add( 106 | Languages.PORTUGUESE.toLang().copy( 107 | code = "pt-pt", 108 | name = "Portuguese (European)", 109 | englishName = "Portuguese (European)", 110 | directoryName = "pt-rPT", 111 | ).setTranslationCode("pt-pt") 112 | ) 113 | add(Languages.ROMANIAN.toLang()) 114 | add(Languages.RUSSIAN.toLang()) 115 | add(Languages.SLOVAK.toLang()) 116 | add(Languages.SLOVENIAN.toLang()) 117 | add(Languages.SWEDISH.toLang()) 118 | add(Languages.TURKISH.toLang()) 119 | add(Languages.UKRAINIAN.toLang()) 120 | add(Languages.CHINESE_SIMPLIFIED.toLang().setTranslationCode("zh")) 121 | } 122 | } 123 | 124 | override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { 125 | val baseUrl = if (deeplSettings.usePro) PRO_HOST_URL else FREE_HOST_URL 126 | return UrlBuilder(baseUrl + TRANSLATE_PATH).build() 127 | } 128 | 129 | override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { 130 | return listOf( 131 | Pair.create("text", text), 132 | Pair.create("target_lang", toLang.code) 133 | ) 134 | } 135 | 136 | override fun configureRequestBuilder(requestBuilder: RequestBuilder) { 137 | requestBuilder.tuner { connection -> 138 | connection.setRequestProperty("Authorization", "DeepL-Auth-Key ${credentialValue("appKey")}") 139 | connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded") 140 | } 141 | } 142 | 143 | override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { 144 | LOG.info("parsingResult: $resultText") 145 | return GsonUtil.getInstance().gson.fromJson(resultText, DeepLTranslationResult::class.java).translationResult 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorCredentialsDialog.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.deepl 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import com.airsaid.localization.config.SettingsState 8 | import com.airsaid.localization.config.TranslatorCredentialsDialog 9 | import com.airsaid.localization.translate.AbstractTranslator 10 | import com.airsaid.localization.ui.components.IdeCheckBox 11 | 12 | /** 13 | * Credentials dialog that exposes DeepL-specific configuration switches. 14 | * 15 | * @author airsaid 16 | */ 17 | class DeepLTranslatorCredentialsDialog( 18 | translator: AbstractTranslator, 19 | settingsState: SettingsState, 20 | ) : TranslatorCredentialsDialog(translator, settingsState) { 21 | 22 | private val deeplSettings = DeepLTranslatorSettings.getInstance() 23 | private var useDeepLPro by mutableStateOf(deeplSettings.usePro) 24 | 25 | @Composable 26 | override fun Header() { 27 | IdeCheckBox( 28 | checked = useDeepLPro, 29 | onCheckedChange = { 30 | useDeepLPro = !useDeepLPro 31 | }, 32 | title = "Use DeepL Pro", 33 | subTitle = "Route requests through the paid DeepL API endpoint." 34 | ) 35 | } 36 | 37 | /** 38 | * Persists the DeepL Pro preference alongside credential values. 39 | */ 40 | override fun doOKAction() { 41 | super.doOKAction() 42 | deeplSettings.usePro = useDeepLPro 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/deepl/DeepLTranslatorSettings.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.deepl 2 | 3 | import com.intellij.openapi.components.PersistentStateComponent 4 | import com.intellij.openapi.components.Service 5 | import com.intellij.openapi.components.State 6 | import com.intellij.openapi.components.Storage 7 | import com.intellij.openapi.components.service 8 | 9 | /** 10 | * Persistent storage for DeepL translator runtime preferences. 11 | * 12 | * @author airsaid 13 | */ 14 | @Service 15 | @State(name = "com.airsaid.localization.DeepLTranslatorSettings", storages = [Storage("deeplTranslatorSettings.xml")]) 16 | class DeepLTranslatorSettings : PersistentStateComponent { 17 | 18 | data class State( 19 | var usePro: Boolean = false 20 | ) 21 | 22 | private var state = State() 23 | 24 | var usePro: Boolean 25 | get() = state.usePro 26 | set(value) { 27 | state = state.copy(usePro = value) 28 | } 29 | 30 | override fun getState(): State = state 31 | 32 | override fun loadState(state: State) { 33 | this.state = state 34 | } 35 | 36 | companion object { 37 | fun getInstance(): DeepLTranslatorSettings = service() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/google/AbsGoogleTranslator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.impl.google 19 | 20 | import com.airsaid.localization.translate.AbstractTranslator 21 | import com.airsaid.localization.translate.TranslatorCredentialDescriptor 22 | import com.airsaid.localization.translate.lang.Lang 23 | import com.airsaid.localization.translate.lang.Languages 24 | import icons.PluginIcons 25 | import javax.swing.Icon 26 | 27 | /** 28 | * @author airsaid 29 | */ 30 | abstract class AbsGoogleTranslator : AbstractTranslator() { 31 | 32 | override val icon: Icon = PluginIcons.GOOGLE_ICON 33 | 34 | override val credentialDefinitions: List = emptyList() 35 | 36 | override val supportedLanguages: List by lazy { 37 | Languages.allSupportedLanguages() 38 | .map { lang -> 39 | when (lang.code) { 40 | Languages.CHINESE_SIMPLIFIED.code -> lang.setTranslationCode("zh-CN") 41 | Languages.CHINESE_TRADITIONAL.code -> lang.setTranslationCode("zh-TW") 42 | Languages.FILIPINO.code -> lang.setTranslationCode("tl") 43 | Languages.INDONESIAN.code -> lang.setTranslationCode("id") 44 | Languages.JAVANESE.code -> lang.setTranslationCode("jw") 45 | else -> lang 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleHttp.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.google 2 | 3 | import com.intellij.util.io.RequestBuilder 4 | 5 | private const val GOOGLE_REFERER = "https://translate.google.com/" 6 | 7 | /** 8 | * Builds the Google translate HTTP endpoint, respecting custom server overrides. 9 | * 10 | * @author airsaid 11 | */ 12 | internal fun googleApiUrl(path: String): String { 13 | val settings = GoogleTranslatorSettings.getInstance() 14 | val base = if (settings.useCustomServer) settings.serverUrl else GoogleTranslatorSettings.DEFAULT_SERVER_URL 15 | val normalizedBase = base.trim().removeSuffix("/") 16 | val normalizedPath = if (path.startsWith('/')) path.drop(1) else path 17 | return "$normalizedBase/$normalizedPath" 18 | } 19 | 20 | /** 21 | * Applies the HTTP headers expected by the Google translate service. 22 | * 23 | * @author airsaid 24 | */ 25 | internal fun RequestBuilder.withGoogleHeaders(): RequestBuilder = apply { 26 | tuner { connection -> 27 | connection.setRequestProperty("Referer", GOOGLE_REFERER) 28 | connection.setRequestProperty( 29 | "User-Agent", 30 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + 31 | "(KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleToken.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.google 2 | 3 | import com.airsaid.localization.translate.util.HttpRequestFactory 4 | import com.intellij.openapi.diagnostic.Logger 5 | import com.intellij.util.concurrency.annotations.RequiresBackgroundThread 6 | import java.lang.StrictMath.abs 7 | import java.util.concurrent.ThreadLocalRandom 8 | import java.util.regex.Pattern 9 | 10 | private const val TOKEN_TTL_MILLIS = 60 * 60 * 1000L 11 | private const val ELEMENT_JS_PATH = "/translate_a/element.js" 12 | 13 | private val TKK_PATTERN: Pattern = Pattern.compile("tkk='(\\d+).(-?\\d+)'") 14 | 15 | private data class Token(val value1: Long, val value2: Long, val hour: Long) 16 | 17 | private val LOG: Logger = Logger.getInstance("GoogleToken") 18 | 19 | private var cachedToken: Token? = null 20 | private val tokenLock = Any() 21 | 22 | /** 23 | * Clears any cached TKK tokens so subsequent calls fetch fresh credentials. 24 | * 25 | * @author airsaid 26 | */ 27 | internal fun resetGoogleTokenCache() { 28 | synchronized(tokenLock) { 29 | cachedToken = null 30 | } 31 | } 32 | 33 | /** 34 | * Returns the current TKK token pair, fetching or regenerating when required. 35 | * 36 | * @author airsaid 37 | */ 38 | @RequiresBackgroundThread 39 | internal fun currentTkk(): Pair { 40 | synchronized(tokenLock) { 41 | val nowHour = System.currentTimeMillis() / TOKEN_TTL_MILLIS 42 | cachedToken?.takeIf { it.hour == nowHour }?.let { return it.value1 to it.value2 } 43 | 44 | val updated = fetchFromGoogle()?.takeIf { it.hour == nowHour } ?: generateLocal(nowHour) 45 | cachedToken = updated 46 | return updated.value1 to updated.value2 47 | } 48 | } 49 | 50 | private fun fetchFromGoogle(): Token? { 51 | return try { 52 | val url = googleApiUrl(ELEMENT_JS_PATH) 53 | val response = HttpRequestFactory.get(url, 10_000) 54 | .withGoogleHeaders() 55 | .connect { it.readString(null) } 56 | val matcher = TKK_PATTERN.matcher(response) 57 | if (!matcher.find()) { 58 | LOG.warn("TKK not found in element.js response") 59 | null 60 | } else { 61 | val nowHour = System.currentTimeMillis() / TOKEN_TTL_MILLIS 62 | val value1 = matcher.group(1).toLong() 63 | val value2 = matcher.group(2).toLong() 64 | Token(value1, value2, nowHour) 65 | } 66 | } catch (error: Throwable) { 67 | LOG.warn("Failed to refresh Google TKK", error) 68 | null 69 | } 70 | } 71 | 72 | private fun generateLocal(hour: Long): Token { 73 | val random = ThreadLocalRandom.current() 74 | val value = abs(random.nextInt().toLong()) + random.nextInt().toLong() 75 | return Token(hour, value, hour) 76 | } 77 | 78 | /** 79 | * Computes the Google translate token for the given payload. 80 | * 81 | * @author airsaid 82 | */ 83 | internal fun String.tk(): String { 84 | val (d, e) = currentTkk() 85 | val bytes = mutableListOf() 86 | var index = 0 87 | while (index < length) { 88 | var charCode = this[index].code 89 | when { 90 | charCode < 0x80 -> bytes += charCode.toLong() 91 | charCode < 0x800 -> { 92 | bytes += (charCode shr 6 or 0xC0).toLong() 93 | bytes += (charCode and 0x3F or 0x80).toLong() 94 | } 95 | 96 | charCode in 0xD800..0xDBFF && index + 1 < length -> { 97 | val next = this[index + 1].code 98 | if (next and 0xFC00 == 0xDC00) { 99 | charCode = 0x10000 + ((charCode and 0x3FF) shl 10) + (next and 0x3FF) 100 | bytes += (charCode shr 18 or 0xF0).toLong() 101 | bytes += (charCode shr 12 and 0x3F or 0x80).toLong() 102 | bytes += (charCode shr 6 and 0x3F or 0x80).toLong() 103 | bytes += (charCode and 0x3F or 0x80).toLong() 104 | index++ 105 | } else { 106 | bytes += (charCode shr 12 or 0xE0).toLong() 107 | bytes += (charCode shr 6 and 0x3F or 0x80).toLong() 108 | bytes += (charCode and 0x3F or 0x80).toLong() 109 | } 110 | } 111 | 112 | else -> { 113 | bytes += (charCode shr 12 or 0xE0).toLong() 114 | bytes += (charCode shr 6 and 0x3F or 0x80).toLong() 115 | bytes += (charCode and 0x3F or 0x80).toLong() 116 | } 117 | } 118 | index++ 119 | } 120 | 121 | var result = d 122 | for (byte in bytes) { 123 | result += byte 124 | result = applyTransformation(result, "+-a^+6") 125 | } 126 | result = applyTransformation(result, "+-3^+b+-f") 127 | result = result xor e 128 | if (result < 0) { 129 | result = (result and 0x7FFFFFFF) + 0x80000000 + 1 130 | } 131 | result %= 1_000_000 132 | 133 | return "$result.${result xor d}" 134 | } 135 | 136 | private fun applyTransformation(value: Long, op: String): Long { 137 | var acc = value 138 | var i = 0 139 | while (i < op.length - 2) { 140 | val shiftChar = op[i + 2] 141 | val shift = if (shiftChar >= 'a') shiftChar.code - 87 else shiftChar.digitToInt() 142 | val operand = if (op[i + 1] == '+') acc ushr shift else acc shl shift 143 | acc = if (op[i] == '+') (acc + operand) and 0xFFFFFFFFL else acc xor operand 144 | i += 3 145 | } 146 | return acc 147 | } 148 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslationResponse.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.google 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | /** 6 | * Response model capturing fields returned by the Google translate endpoint. 7 | * 8 | * @author airsaid 9 | */ 10 | internal data class GoogleTranslationResponse( 11 | @SerializedName("sentences") val sentences: List?, 12 | @SerializedName("src") val sourceLanguage: String?, 13 | @SerializedName("ld_result") val languageDetection: LanguageDetectionResult?, 14 | @SerializedName("error") val error: ErrorBody? = null 15 | ) { 16 | data class Sentence( 17 | @SerializedName("trans") val translation: String?, 18 | @SerializedName("orig") val original: String? 19 | ) 20 | 21 | data class LanguageDetectionResult( 22 | @SerializedName("srclangs") val sourceLanguages: List? 23 | ) 24 | 25 | data class ErrorBody( 26 | @SerializedName("message") val message: String? = null 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslator.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.google 2 | 3 | import com.airsaid.localization.translate.AbstractTranslator 4 | import com.airsaid.localization.translate.TranslationException 5 | import com.airsaid.localization.translate.lang.Lang 6 | import com.airsaid.localization.translate.lang.Languages 7 | import com.airsaid.localization.translate.lang.toLang 8 | import com.airsaid.localization.translate.util.GsonUtil 9 | import com.airsaid.localization.translate.util.UrlBuilder 10 | import com.google.auto.service.AutoService 11 | import com.intellij.openapi.diagnostic.Logger 12 | import com.intellij.openapi.util.Pair 13 | import com.intellij.util.io.RequestBuilder 14 | import icons.PluginIcons 15 | import javax.swing.Icon 16 | 17 | /** 18 | * Translator implementation that proxies requests through the Google translate web endpoint. 19 | * 20 | * @author airsaid 21 | */ 22 | @AutoService(AbstractTranslator::class) 23 | class GoogleTranslator : AbsGoogleTranslator() { 24 | 25 | private val log = Logger.getInstance(GoogleTranslator::class.java) 26 | 27 | override val key: String = KEY 28 | 29 | override val icon: Icon = PluginIcons.GOOGLE_ICON 30 | 31 | override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { 32 | val source = if (fromLang.code.equals(Languages.AUTO.code, ignoreCase = true)) "auto" else fromLang.translationCode 33 | val builder = UrlBuilder(googleApiUrl(TRANSLATE_PATH)) 34 | .addQueryParameter("client", "gtx") 35 | .addQueryParameter("sl", source) 36 | .addQueryParameter("tl", toLang.translationCode) 37 | .addQueryParameters("dt", "t", "bd", "rm", "qca", "ex") 38 | .addQueryParameter("dj", "1") 39 | .addQueryParameter("ie", "UTF-8") 40 | .addQueryParameter("oe", "UTF-8") 41 | .addQueryParameter("hl", Languages.ENGLISH.toLang().translationCode) 42 | .addQueryParameter("tk", text.tk()) 43 | return builder.build() 44 | } 45 | 46 | override fun getRequestParams(fromLang: Lang, toLang: Lang, text: String): List> { 47 | return listOf(Pair.create("q", text)) 48 | } 49 | 50 | override fun configureRequestBuilder(requestBuilder: RequestBuilder) { 51 | requestBuilder.withGoogleHeaders() 52 | } 53 | 54 | /** 55 | * Parses the JSON payload and surfaces API errors as `TranslationException`. 56 | */ 57 | override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { 58 | val response = GsonUtil.getInstance().gson.fromJson(resultText, GoogleTranslationResponse::class.java) 59 | response.error?.message?.let { message -> 60 | throw TranslationException(fromLang, toLang, text, message) 61 | } 62 | 63 | val translation = response.sentences 64 | ?.mapNotNull { it.translation } 65 | ?.joinToString(separator = "") 66 | ?.trim() 67 | .orEmpty() 68 | 69 | if (translation.isEmpty()) { 70 | log.warn("Empty translation from Google API: $resultText") 71 | return "" 72 | } 73 | return translation 74 | } 75 | 76 | companion object { 77 | private const val KEY = "Google" 78 | private const val TRANSLATE_PATH = "/translate_a/single" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettings.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.google 2 | 3 | import com.intellij.openapi.components.PersistentStateComponent 4 | import com.intellij.openapi.components.Service 5 | import com.intellij.openapi.components.State 6 | import com.intellij.openapi.components.Storage 7 | import com.intellij.openapi.components.service 8 | 9 | /** 10 | * Persisted configuration controlling how Google translator requests are routed. 11 | * 12 | * @author airsaid 13 | */ 14 | @Service 15 | @State(name = "com.airsaid.localization.GoogleTranslatorSettings", storages = [Storage("googleTranslatorSettings.xml")]) 16 | class GoogleTranslatorSettings : PersistentStateComponent { 17 | 18 | data class State( 19 | var useCustomServer: Boolean = false, 20 | var serverUrl: String = DEFAULT_SERVER_URL 21 | ) 22 | 23 | private var state = State() 24 | 25 | var useCustomServer: Boolean 26 | get() = state.useCustomServer 27 | set(value) { 28 | state = state.copy(useCustomServer = value) 29 | } 30 | 31 | var serverUrl: String 32 | get() = state.serverUrl.ifBlank { DEFAULT_SERVER_URL } 33 | set(value) { 34 | state = state.copy(serverUrl = value.ifBlank { DEFAULT_SERVER_URL }) 35 | } 36 | 37 | override fun getState(): State = state 38 | 39 | override fun loadState(state: State) { 40 | this.state = state 41 | } 42 | 43 | companion object { 44 | const val DEFAULT_SERVER_URL = "https://translate.googleapis.com" 45 | 46 | fun getInstance(): GoogleTranslatorSettings = service() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/google/GoogleTranslatorSettingsDialog.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.google 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.fillMaxWidth 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.foundation.text.selection.SelectionContainer 8 | import androidx.compose.runtime.* 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.unit.dp 11 | import com.airsaid.localization.ui.ComposeDialog 12 | import com.airsaid.localization.ui.components.IdeCheckBox 13 | import com.airsaid.localization.ui.components.IdeTextField 14 | import org.jetbrains.jewel.foundation.theme.JewelTheme 15 | import org.jetbrains.jewel.ui.component.Text 16 | 17 | /** 18 | * Compose dialog allowing users to toggle the Google translator backend address. 19 | * 20 | * @author airsaid 21 | */ 22 | class GoogleTranslatorSettingsDialog : ComposeDialog() { 23 | 24 | override val defaultPreferredSize 25 | get() = 400 to 160 26 | 27 | private val settings = GoogleTranslatorSettings.getInstance() 28 | 29 | init { 30 | title = "Google Translator Settings" 31 | } 32 | 33 | @Composable 34 | override fun Content() { 35 | var useCustomServer by remember { mutableStateOf(settings.useCustomServer) } 36 | var serverUrl by remember { mutableStateOf(settings.serverUrl) } 37 | 38 | Column( 39 | modifier = Modifier 40 | .padding(horizontal = 20.dp, vertical = 16.dp), 41 | verticalArrangement = Arrangement.spacedBy(16.dp) 42 | ) { 43 | IdeCheckBox( 44 | checked = useCustomServer, 45 | onCheckedChange = { 46 | useCustomServer = it 47 | if (!it) { 48 | serverUrl = settings.serverUrl 49 | } 50 | }, 51 | title = "Use custom server", 52 | ) 53 | 54 | Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { 55 | Text( 56 | text = "Server URL", 57 | color = JewelTheme.globalColors.text.info 58 | ) 59 | IdeTextField( 60 | value = serverUrl, 61 | onValueChange = { serverUrl = it.trimStart() }, 62 | modifier = Modifier.fillMaxWidth(), 63 | singleLine = true, 64 | enabled = useCustomServer, 65 | placeholder = { 66 | Text( 67 | text = GoogleTranslatorSettings.DEFAULT_SERVER_URL, 68 | color = JewelTheme.globalColors.text.info 69 | ) 70 | } 71 | ) 72 | } 73 | 74 | SelectionContainer { 75 | Text( 76 | text = "Defaults to translate.googleapis.com when not specified.", 77 | color = JewelTheme.globalColors.text.info 78 | ) 79 | } 80 | } 81 | 82 | OnClickOK { 83 | // Persist the selected endpoint and toggle when the user accepts the dialog. 84 | settings.useCustomServer = useCustomServer 85 | if (useCustomServer) { 86 | settings.serverUrl = serverUrl.ifBlank { GoogleTranslatorSettings.DEFAULT_SERVER_URL } 87 | } 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftEdgeAuthService.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.microsoft 2 | 3 | import com.airsaid.localization.translate.util.HttpRequestFactory 4 | import com.google.gson.Gson 5 | import com.google.gson.annotations.SerializedName 6 | import com.intellij.openapi.components.Service 7 | import com.intellij.openapi.components.service 8 | import com.intellij.openapi.diagnostic.Logger 9 | import com.intellij.openapi.diagnostic.logger 10 | import com.intellij.util.concurrency.annotations.RequiresBackgroundThread 11 | import com.intellij.util.io.HttpRequests 12 | import java.io.IOException 13 | import java.util.* 14 | import java.util.concurrent.atomic.AtomicReference 15 | 16 | /** 17 | * Fetches and caches Microsoft Translator access tokens using the same public 18 | * endpoint leveraged by the Microsoft Edge browser. This removes the need for 19 | * user-provided subscription keys. 20 | * 21 | * @author airsaid 22 | */ 23 | @Service 24 | class MicrosoftEdgeAuthService { 25 | 26 | private val tokenRef = AtomicReference() 27 | 28 | private val gson = Gson() 29 | 30 | @RequiresBackgroundThread 31 | @Throws(MicrosoftAuthenticationException::class) 32 | fun getAccessToken(): String { 33 | val currentTime = System.currentTimeMillis() 34 | tokenRef.get()?.takeIf { currentTime < it.expireAtMillis }?.let { return it.value } 35 | 36 | synchronized(this) { 37 | tokenRef.get()?.takeIf { System.currentTimeMillis() < it.expireAtMillis }?.let { return it.value } 38 | val token = requestAccessToken() 39 | val expireAt = extractExpirationTime(token) 40 | val cached = Token(token, expireAt) 41 | tokenRef.set(cached) 42 | LOG.debug("Fetched Microsoft access token. Expires at ${Date(expireAt)}") 43 | return cached.value 44 | } 45 | } 46 | 47 | @Throws(MicrosoftAuthenticationException::class) 48 | private fun requestAccessToken(): String { 49 | val endpoint = authUrlOverride ?: AUTH_URL 50 | return try { 51 | HttpRequestFactory.get(endpoint, CONNECTION_TIMEOUT_MS) 52 | .tuner { connection -> 53 | // Endpoint expects a modern browser style user agent 54 | connection.setRequestProperty("Accept", "*/*") 55 | connection.setRequestProperty( 56 | "User-Agent", 57 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + 58 | "(KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36" 59 | ) 60 | } 61 | .connect { request -> request.readString(null) } 62 | .also { token -> 63 | if (!TOKEN_REGEX.matches(token.trim())) { 64 | throw MicrosoftAuthenticationException("Authentication failed: invalid token format") 65 | } 66 | } 67 | } catch (statusEx: HttpRequests.HttpStatusException) { 68 | throw MicrosoftAuthenticationException( 69 | "Authentication failed: HTTP ${statusEx.statusCode}", 70 | statusEx 71 | ) 72 | } catch (io: IOException) { 73 | throw MicrosoftAuthenticationException("Authentication failed: ${io.message}", io) 74 | } 75 | } 76 | 77 | private fun extractExpirationTime(token: String): Long { 78 | return try { 79 | val payload = token.split('.') 80 | .getOrNull(1) 81 | ?.let { chunk -> 82 | val decoder = Base64.getUrlDecoder() 83 | String(decoder.decode(chunk)) 84 | } 85 | ?: return System.currentTimeMillis() + DEFAULT_EXPIRATION_MS 86 | val jwt = gson.fromJson(payload, JwtPayload::class.java) 87 | jwt.expirationTime * 1_000L - PRE_EXPIRATION_BUFFER_MS 88 | } catch (_: Throwable) { 89 | System.currentTimeMillis() + DEFAULT_EXPIRATION_MS 90 | } 91 | } 92 | 93 | private data class Token(val value: String, val expireAtMillis: Long) 94 | 95 | private data class JwtPayload(@SerializedName("exp") val expirationTime: Long) 96 | 97 | internal fun clearCacheForTests() { 98 | tokenRef.set(null) 99 | } 100 | 101 | companion object { 102 | private val LOG: Logger = logger() 103 | 104 | private const val AUTH_URL = "https://edge.microsoft.com/translate/auth" 105 | private const val CONNECTION_TIMEOUT_MS = 15_000 106 | private const val PRE_EXPIRATION_BUFFER_MS = 2 * 60 * 1_000L // Refresh 2 minutes early 107 | private const val DEFAULT_EXPIRATION_MS = 8 * 60 * 1_000L 108 | private val TOKEN_REGEX = Regex("""^[A-Za-z0-9\-_]+(\.[A-Za-z0-9\-_]+){2}$""") 109 | 110 | @Volatile 111 | internal var authUrlOverride: String? = null 112 | 113 | fun getInstance(): MicrosoftEdgeAuthService = service() 114 | 115 | internal fun resetForTests() { 116 | getInstance().clearCacheForTests() 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftExceptions.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.microsoft 2 | 3 | import java.io.IOException 4 | 5 | /** 6 | * Exception thrown when Microsoft authentication fails. 7 | * 8 | * @author airsaid 9 | */ 10 | class MicrosoftAuthenticationException( 11 | message: String? = null, 12 | cause: Throwable? = null 13 | ) : IOException(message, cause) 14 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/microsoft/MicrosoftTranslationResult.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.impl.microsoft 19 | 20 | import com.airsaid.localization.translate.TranslationResult 21 | 22 | /** 23 | * @author airsaid 24 | */ 25 | data class MicrosoftTranslationResult( 26 | var translations: List? = null 27 | ) : TranslationResult { 28 | 29 | override val translationResult: String 30 | get() { 31 | return if (!translations.isNullOrEmpty()) { 32 | val result = translations!![0].text 33 | result ?: "" 34 | } else { 35 | "" 36 | } 37 | } 38 | 39 | data class Translation( 40 | var text: String? = null, 41 | var to: String? = null 42 | ) 43 | 44 | data class Error( 45 | var code: String? = null, 46 | var message: String? = null 47 | ) 48 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIModels.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.openai 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | /** 6 | * Request payload sent to the OpenAI chat completions endpoint. 7 | * 8 | * @author airsaid 9 | */ 10 | data class OpenAIRequest( 11 | var model: String, 12 | var messages: List 13 | ) 14 | 15 | /** 16 | * Single message exchanged with the OpenAI chat API. 17 | * 18 | * @author airsaid 19 | */ 20 | data class OpenAIMessage( 21 | var role: String, 22 | var content: String 23 | ) 24 | 25 | /** 26 | * Response wrapper returned when listing available OpenAI models. 27 | * 28 | * @author airsaid 29 | */ 30 | data class OpenAIModelsResponse( 31 | @SerializedName("data") 32 | val data: List = emptyList() 33 | ) 34 | 35 | /** 36 | * Model descriptor returned by the OpenAI catalog API. 37 | * 38 | * @author airsaid 39 | */ 40 | data class OpenAIModel( 41 | @SerializedName("id") 42 | val id: String = "" 43 | ) 44 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAIResponse.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.impl.openai 19 | 20 | import com.google.gson.annotations.SerializedName 21 | 22 | /** 23 | * Response envelope returned by the OpenAI chat completions API. 24 | * 25 | * @author airsaid 26 | */ 27 | data class OpenAIResponse( 28 | var choices: List?, 29 | var created: Int?, 30 | var id: String?, 31 | var `object`: String?, 32 | var usage: Usage? 33 | ) { 34 | val translation: String 35 | get() { 36 | return if (!choices.isNullOrEmpty()) { 37 | val result = choices!![0].message?.content 38 | result?.trim() ?: "" 39 | } else { 40 | "" 41 | } 42 | } 43 | 44 | data class Choice( 45 | @SerializedName("finish_reason") 46 | var finishReason: String?, 47 | var index: Int?, 48 | var message: Message? 49 | ) 50 | 51 | data class Message( 52 | var content: String?, 53 | var role: String? 54 | ) 55 | 56 | data class Usage( 57 | @SerializedName("completion_tokens") 58 | var completionTokens: Int?, 59 | @SerializedName("prompt_tokens") 60 | var promptTokens: Int?, 61 | @SerializedName("total_tokens") 62 | var totalTokens: Int? 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslator.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.impl.openai 19 | 20 | import com.airsaid.localization.translate.AbstractTranslator 21 | import com.airsaid.localization.translate.TranslationException 22 | import com.airsaid.localization.translate.TranslatorCredentialDescriptor 23 | import com.airsaid.localization.translate.lang.Lang 24 | import com.airsaid.localization.translate.util.GsonUtil 25 | import com.google.auto.service.AutoService 26 | import com.intellij.openapi.diagnostic.Logger 27 | import com.intellij.util.io.RequestBuilder 28 | import icons.PluginIcons 29 | 30 | /** 31 | * Translator backed by the OpenAI chat completions API. 32 | * 33 | * @author airsaid 34 | */ 35 | @AutoService(AbstractTranslator::class) 36 | class OpenAITranslator : AbstractTranslator() { 37 | companion object { 38 | private val LOG = Logger.getInstance(OpenAITranslator::class.java) 39 | private const val KEY = "OpenAI" 40 | private const val CHAT_COMPLETIONS_PATH = "/v1/chat/completions" 41 | } 42 | 43 | private val settings: OpenAITranslatorSettings 44 | get() = OpenAITranslatorSettings.getInstance() 45 | 46 | override val key = KEY 47 | 48 | override val icon = PluginIcons.OPENAI_ICON 49 | 50 | override val credentialDefinitions: List 51 | get() = listOf( 52 | TranslatorCredentialDescriptor(id = "appKey", label = "API Key", isSecret = true) 53 | ) 54 | 55 | override val requestContentType: String 56 | get() = "application/json" 57 | 58 | /** 59 | * Verifies credentials before delegating to the base network workflow. 60 | */ 61 | @Throws(TranslationException::class) 62 | override fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { 63 | if (credentialValue("appKey").isBlank()) { 64 | throw TranslationException(fromLang, toLang, text, "OpenAI API key is required. Add it in OpenAI Settings.") 65 | } 66 | return super.doTranslate(fromLang, toLang, text) 67 | } 68 | 69 | override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { 70 | return buildUrl(settings.resolvedBaseUrl(), CHAT_COMPLETIONS_PATH) 71 | } 72 | 73 | override fun getRequestBody(fromLang: Lang, toLang: Lang, text: String): String { 74 | val lang = toLang.englishName 75 | val roleSystem = String.format( 76 | "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!).", 77 | lang, lang 78 | ) 79 | 80 | val role = OpenAIMessage("system", roleSystem) 81 | val msg = OpenAIMessage("user", String.format("Text to translate: %s", text)) 82 | 83 | val body = OpenAIRequest(settings.resolvedModel(), listOf(role, msg)) 84 | 85 | return GsonUtil.getInstance().gson.toJson(body) 86 | } 87 | 88 | override fun configureRequestBuilder(requestBuilder: RequestBuilder) { 89 | val apiKey = credentialValue("appKey").trim() 90 | requestBuilder.tuner { connection -> 91 | connection.setRequestProperty("Authorization", "Bearer $apiKey") 92 | connection.setRequestProperty("Content-Type", "application/json") 93 | } 94 | } 95 | 96 | override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { 97 | LOG.info("parsingResult OpenAI: $resultText") 98 | return GsonUtil.getInstance().gson.fromJson(resultText, OpenAIResponse::class.java).translation 99 | } 100 | 101 | private fun buildUrl(base: String, path: String): String { 102 | val prefix = base.trimEnd('/') 103 | val suffix = path.trimStart('/') 104 | return "$prefix/$suffix" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/openai/OpenAITranslatorSettings.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.impl.openai 2 | 3 | import com.intellij.openapi.components.* 4 | import java.net.URI 5 | 6 | /** 7 | * Persisted configuration controlling OpenAI translator models and API host. 8 | * 9 | * @author airsaid 10 | */ 11 | @Service 12 | @State( 13 | name = "com.airsaid.localization.OpenAITranslatorSettings", 14 | storages = [Storage("openAITranslatorSettings.xml")] 15 | ) 16 | class OpenAITranslatorSettings : PersistentStateComponent { 17 | 18 | data class State( 19 | var selectedModel: String = DEFAULT_MODEL, 20 | var useCustomModel: Boolean = false, 21 | var customModel: String = "", 22 | var apiHost: String = "", 23 | var cachedModels: MutableList = mutableListOf(), 24 | var lastModelSyncTimestamp: Long = 0L, 25 | ) 26 | 27 | private var state = State() 28 | 29 | var selectedModel: String 30 | get() = state.selectedModel.ifBlank { DEFAULT_MODEL } 31 | set(value) { 32 | state = state.copy(selectedModel = value.ifBlank { DEFAULT_MODEL }) 33 | } 34 | 35 | var useCustomModel: Boolean 36 | get() = state.useCustomModel 37 | set(value) { 38 | state = state.copy(useCustomModel = value) 39 | } 40 | 41 | var customModel: String 42 | get() = state.customModel 43 | set(value) { 44 | state = state.copy(customModel = value) 45 | } 46 | 47 | var apiHost: String 48 | get() = state.apiHost 49 | set(value) { 50 | state = state.copy(apiHost = value.trim()) 51 | } 52 | 53 | var cachedModels: List 54 | get() = state.cachedModels.takeIf { it.isNotEmpty() } ?: DEFAULT_MODELS 55 | private set(value) { 56 | state = state.copy(cachedModels = value.toMutableList()) 57 | } 58 | 59 | var lastModelSyncTimestamp: Long 60 | get() = state.lastModelSyncTimestamp 61 | private set(value) { 62 | state = state.copy(lastModelSyncTimestamp = value) 63 | } 64 | 65 | fun resolvedModel(): String { 66 | val custom = customModel.trim() 67 | return if (useCustomModel && custom.isNotEmpty()) { 68 | custom 69 | } else { 70 | selectedModel 71 | } 72 | } 73 | 74 | fun resolvedBaseUrl(): String = normalizeBaseUrl(apiHost) 75 | 76 | fun shouldRefreshModels(currentTimeMillis: Long = System.currentTimeMillis()): Boolean { 77 | return currentTimeMillis - lastModelSyncTimestamp >= MODEL_CACHE_TTL_MS 78 | } 79 | 80 | fun updateCachedModels(models: List, fetchTimestamp: Long = System.currentTimeMillis()) { 81 | val sanitized = models.filter { it.isNotBlank() }.map { it.trim() } 82 | cachedModels = if (sanitized.isEmpty()) DEFAULT_MODELS else sanitized.distinct().sorted() 83 | lastModelSyncTimestamp = fetchTimestamp 84 | 85 | if (!useCustomModel && selectedModel !in cachedModels) { 86 | selectedModel = cachedModels.firstOrNull() ?: DEFAULT_MODEL 87 | } 88 | } 89 | 90 | override fun getState(): State = state 91 | 92 | override fun loadState(state: State) { 93 | this.state = state.copy( 94 | cachedModels = state.cachedModels.takeIf { it.isNotEmpty() }?.toMutableList() ?: DEFAULT_MODELS.toMutableList() 95 | ) 96 | } 97 | 98 | companion object { 99 | const val DEFAULT_API_HOST = "https://api.openai.com" 100 | val DEFAULT_MODELS: List = listOf( 101 | "gpt-5", "gpt-5-mini", "gpt-5-nano", 102 | "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano" 103 | ) 104 | val DEFAULT_MODEL: String = DEFAULT_MODELS.first() 105 | 106 | private val MODEL_CACHE_TTL_MS: Long = java.time.Duration.ofDays(1).toMillis() 107 | 108 | fun getInstance(): OpenAITranslatorSettings = service() 109 | 110 | /** 111 | * Sanitizes the configured host into a valid absolute URL used for API calls. 112 | */ 113 | fun normalizeBaseUrl(host: String): String { 114 | val rawHost = host.trim().ifBlank { DEFAULT_API_HOST } 115 | val hostWithScheme = ensureScheme(rawHost) 116 | val sanitized = hostWithScheme.removeSuffix("/") 117 | return runCatching { URI(sanitized) } 118 | .map { uri -> 119 | URI(uri.scheme, uri.userInfo, uri.host, uri.port, uri.path, uri.query, uri.fragment).toString() 120 | } 121 | .getOrDefault(sanitized) 122 | } 123 | 124 | private fun ensureScheme(host: String): String { 125 | return if (host.startsWith("http://") || host.startsWith("https://")) { 126 | host 127 | } else { 128 | "https://$host" 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/impl/youdao/YoudaoTranslationResult.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.impl.youdao 19 | 20 | import com.airsaid.localization.translate.TranslationResult 21 | 22 | /** 23 | * @author airsaid 24 | */ 25 | data class YoudaoTranslationResult( 26 | var requestId: String? = null, 27 | var errorCode: String? = null, 28 | var translation: List? = null 29 | ) : TranslationResult { 30 | 31 | val isSuccess: Boolean 32 | get() { 33 | val errorCode = this.errorCode 34 | return !errorCode.isNullOrEmpty() && "0" == errorCode 35 | } 36 | 37 | override val translationResult: String 38 | get() { 39 | val translation = this.translation 40 | return if (translation != null && translation.isNotEmpty()) { 41 | translation[0] 42 | } else { 43 | "" 44 | } 45 | } 46 | 47 | override fun equals(other: Any?): Boolean { 48 | if (this === other) return true 49 | if (other == null || javaClass != other.javaClass) return false 50 | val that = other as YoudaoTranslationResult 51 | return requestId == that.requestId 52 | } 53 | 54 | override fun hashCode(): Int { 55 | return requestId?.hashCode() ?: 0 56 | } 57 | 58 | override fun toString(): String { 59 | return "YoudaoTranslationResult{" + 60 | "requestId='$requestId', " + 61 | "errorCode='$errorCode', " + 62 | "translation=$translation" + 63 | '}' 64 | } 65 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/interceptors/EscapeCharactersInterceptor.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.interceptors 19 | 20 | import com.airsaid.localization.translate.services.TranslatorService 21 | import com.intellij.openapi.util.text.StringUtil 22 | 23 | /** 24 | * @author airsaid 25 | */ 26 | class EscapeCharactersInterceptor : TranslatorService.TranslationInterceptor { 27 | 28 | private val needEscapeChars = mutableListOf() 29 | 30 | init { 31 | needEscapeChars.addAll(listOf('@', '?', '\'', '\"')) 32 | } 33 | 34 | override fun process(text: String?): String? { 35 | if (StringUtil.isEmpty(text)) { 36 | return text 37 | } 38 | val result = StringBuilder() 39 | text!!.forEach { ch -> 40 | if (needEscapeChars.contains(ch)) { 41 | result.append('\\') 42 | } 43 | result.append(ch) 44 | } 45 | return result.toString() 46 | } 47 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/lang/Lang.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.lang 19 | 20 | import com.intellij.openapi.util.text.StringUtil 21 | 22 | /** 23 | * Language data class, which is an immutable class, 24 | * any modification to it will generate you a new object. 25 | * 26 | * @author airsaid 27 | */ 28 | data class Lang( 29 | val code: String, 30 | val name: String, 31 | val englishName: String, 32 | val flag: String, 33 | val directoryName: String, 34 | private val _translationCode: String? = null 35 | ) : Cloneable { 36 | 37 | val translationCode: String 38 | get() = if (!StringUtil.isEmpty(_translationCode)) _translationCode!! else code 39 | 40 | fun setTranslationCode(translationCode: String): Lang { 41 | return this.copy(_translationCode = translationCode) 42 | } 43 | 44 | override fun equals(other: Any?): Boolean { 45 | if (this === other) return true 46 | if (other == null || javaClass != other.javaClass) return false 47 | val language = other as Lang 48 | return code == language.code 49 | } 50 | 51 | override fun hashCode(): Int { 52 | return code.hashCode() 53 | } 54 | 55 | public override fun clone(): Lang { 56 | return try { 57 | super.clone() as Lang 58 | } catch (e: CloneNotSupportedException) { 59 | e.printStackTrace() 60 | this.copy() 61 | } 62 | } 63 | 64 | override fun toString(): String { 65 | return "Lang{" + 66 | "code='$code', " + 67 | "name='$name', " + 68 | "englishName='$englishName', " + 69 | "flag='$flag', " + 70 | "directoryName='$directoryName', " + 71 | "translationCode='$translationCode'" + 72 | '}' 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/services/TranslationCacheService.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.services 19 | 20 | import com.airsaid.localization.translate.util.GsonUtil 21 | import com.airsaid.localization.translate.util.LRUCache 22 | import com.google.gson.reflect.TypeToken 23 | import com.intellij.openapi.Disposable 24 | import com.intellij.openapi.components.* 25 | import com.intellij.util.xmlb.Converter 26 | import com.intellij.util.xmlb.XmlSerializerUtil 27 | import com.intellij.util.xmlb.annotations.OptionTag 28 | import com.intellij.util.xmlb.annotations.Transient 29 | import java.lang.reflect.Type 30 | 31 | /** 32 | * Cache the translated text to local disk. 33 | * 34 | * The maximum number of caches is set by the [setMaxCacheSize] method, 35 | * if exceed this size, remove old data through the LRU algorithm. 36 | * 37 | * @author airsaid 38 | */ 39 | @State( 40 | name = "com.airsaid.localization.translate.services.TranslationCacheService", 41 | storages = [Storage("androidLocalizeTranslationCaches.xml")] 42 | ) 43 | @Service 44 | class TranslationCacheService : PersistentStateComponent, Disposable { 45 | 46 | @Transient 47 | private val lruCache = LRUCache(CACHE_MAX_SIZE) 48 | 49 | @OptionTag(converter = LruCacheConverter::class) 50 | fun getLruCache(): LRUCache = lruCache 51 | 52 | fun put(key: String, value: String) { 53 | lruCache.put(key, value) 54 | } 55 | 56 | fun get(key: String?): String { 57 | val value = lruCache.get(key) 58 | return value ?: "" 59 | } 60 | 61 | fun setMaxCacheSize(maxCacheSize: Int) { 62 | lruCache.setMaxCapacity(maxCacheSize) 63 | } 64 | 65 | override fun getState(): TranslationCacheService = this 66 | 67 | override fun loadState(state: TranslationCacheService) { 68 | XmlSerializerUtil.copyBean(state, this) 69 | } 70 | 71 | override fun dispose() { 72 | lruCache.clear() 73 | } 74 | 75 | class LruCacheConverter : Converter>() { 76 | override fun fromString(value: String): LRUCache? { 77 | val type: Type = object : TypeToken>() {}.type 78 | val map: Map = GsonUtil.getInstance().gson.fromJson(value, type) 79 | val lruCache = LRUCache(CACHE_MAX_SIZE) 80 | for ((key, value1) in map) { 81 | lruCache.put(key, value1) 82 | } 83 | return lruCache 84 | } 85 | 86 | override fun toString(lruCache: LRUCache): String? { 87 | val values = linkedMapOf() 88 | lruCache.forEach { key, value -> values[key] = value } 89 | return GsonUtil.getInstance().gson.toJson(values) 90 | } 91 | } 92 | 93 | companion object { 94 | private const val CACHE_MAX_SIZE = 500 95 | 96 | fun getInstance(): TranslationCacheService = service() 97 | } 98 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/services/TranslatorService.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.services 19 | 20 | import com.airsaid.localization.extensions.hasNoTranslatableText 21 | import com.airsaid.localization.translate.AbstractTranslator 22 | import com.airsaid.localization.translate.interceptors.EscapeCharactersInterceptor 23 | import com.airsaid.localization.translate.lang.Lang 24 | import com.intellij.openapi.application.ApplicationManager 25 | import com.intellij.openapi.components.Service 26 | import com.intellij.openapi.components.service 27 | import com.intellij.openapi.diagnostic.Logger 28 | import java.util.* 29 | import java.util.function.Consumer 30 | 31 | /** 32 | * @author airsaid 33 | */ 34 | @Service 35 | class TranslatorService { 36 | 37 | interface TranslationInterceptor { 38 | fun process(text: String?): String? 39 | } 40 | 41 | private val defaultTranslator: AbstractTranslator 42 | private val cacheService: TranslationCacheService 43 | private val translators: Map 44 | private val translationInterceptors: MutableList 45 | private var isEnableCache = true 46 | private var intervalTime = 0 47 | 48 | @Volatile 49 | private var selectedTranslator: AbstractTranslator 50 | var maxCacheSize: Int = 1000 51 | set(value) { 52 | field = value 53 | cacheService.setMaxCacheSize(value) 54 | } 55 | var translationInterval: Int = 0 56 | set(value) { 57 | field = value 58 | intervalTime = value 59 | } 60 | 61 | init { 62 | val translatorsMap = linkedMapOf() 63 | val serviceLoader = ServiceLoader.load( 64 | AbstractTranslator::class.java, javaClass.classLoader 65 | ) 66 | for (translator in serviceLoader) { 67 | translatorsMap[translator.key] = translator 68 | } 69 | if (translatorsMap.isEmpty()) { 70 | LOG.error("No translators were registered. Translation functionality will be unavailable.") 71 | throw IllegalStateException("No translators registered") 72 | } 73 | translators = translatorsMap 74 | 75 | defaultTranslator = selectDefaultTranslator(translatorsMap) 76 | selectedTranslator = defaultTranslator 77 | 78 | cacheService = TranslationCacheService.getInstance() 79 | 80 | translationInterceptors = mutableListOf() 81 | translationInterceptors.add(EscapeCharactersInterceptor()) 82 | } 83 | 84 | fun getDefaultTranslator(): AbstractTranslator = defaultTranslator 85 | 86 | fun getTranslators(): Map = translators 87 | 88 | fun setSelectedTranslator(selectedTranslator: AbstractTranslator) { 89 | if (this.selectedTranslator != selectedTranslator) { 90 | LOG.info("setTranslator: $selectedTranslator") 91 | this.selectedTranslator = selectedTranslator 92 | } 93 | } 94 | 95 | fun getSelectedTranslator(): AbstractTranslator = selectedTranslator 96 | 97 | fun doTranslateByAsync(fromLang: Lang, toLang: Lang, text: String, consumer: Consumer) { 98 | ApplicationManager.getApplication().executeOnPooledThread { 99 | val translatedText = doTranslate(fromLang, toLang, text) 100 | ApplicationManager.getApplication().invokeLater { 101 | consumer.accept(translatedText) 102 | } 103 | } 104 | } 105 | 106 | fun doTranslate(fromLang: Lang, toLang: Lang, text: String): String { 107 | LOG.info("doTranslate fromLang: $fromLang, toLang: $toLang, text: $text") 108 | 109 | if (isEnableCache) { 110 | val cacheResult = cacheService.get(getCacheKey(fromLang, toLang, text)) 111 | if (cacheResult.isNotEmpty()) { 112 | LOG.info("doTranslate cache result: $cacheResult") 113 | return cacheResult 114 | } 115 | } 116 | 117 | // Skip translation for text containing only numbers, symbols, and punctuation 118 | if (text.hasNoTranslatableText()) { 119 | return text 120 | } 121 | 122 | val translator = selectedTranslator 123 | var result = translator.doTranslate(fromLang, toLang, text) 124 | LOG.info("doTranslate result: $result") 125 | for (interceptor in translationInterceptors) { 126 | result = interceptor.process(result) ?: result 127 | LOG.info("doTranslate interceptor process result: $result") 128 | } 129 | cacheService.put(getCacheKey(fromLang, toLang, text), result) 130 | delay(intervalTime) 131 | return result 132 | } 133 | 134 | fun setEnableCache(isEnableCache: Boolean) { 135 | this.isEnableCache = isEnableCache 136 | } 137 | 138 | fun isEnableCache(): Boolean = isEnableCache 139 | 140 | 141 | private fun getCacheKey(fromLang: Lang, toLang: Lang, text: String): String { 142 | return "${fromLang.code}_${toLang.code}_$text" 143 | } 144 | 145 | private fun delay(milliseconds: Int) { 146 | if (milliseconds <= 0) return 147 | try { 148 | LOG.info("doTranslate delay time: ${milliseconds} ms.") 149 | Thread.sleep(milliseconds.toLong()) 150 | } catch (e: InterruptedException) { 151 | e.printStackTrace() 152 | } 153 | } 154 | 155 | companion object { 156 | private val LOG = Logger.getInstance(TranslatorService::class.java) 157 | 158 | fun getInstance(): TranslatorService = service() 159 | 160 | internal fun selectDefaultTranslator(translators: Map): AbstractTranslator { 161 | val preferred = translators["Microsoft"] ?: translators.values.first() 162 | LOG.info("Selected ${preferred.key} as default translator.") 163 | return preferred 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/util/GsonUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.util 19 | 20 | import com.google.gson.Gson 21 | import com.google.gson.GsonBuilder 22 | 23 | /** 24 | * @author airsaid 25 | */ 26 | class GsonUtil private constructor() { 27 | 28 | val gson: Gson = GsonBuilder().create() 29 | 30 | companion object { 31 | @JvmStatic 32 | fun getInstance(): GsonUtil = GsonUtilHolder.instance 33 | } 34 | 35 | private object GsonUtilHolder { 36 | val instance = GsonUtil() 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/util/HttpRequestFactory.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.util 19 | 20 | import com.intellij.util.io.HttpRequests 21 | import com.intellij.util.io.RequestBuilder 22 | 23 | /** 24 | * Factory methods for creating [RequestBuilder] instances that honour the IDE's 25 | * proxy and timeout settings. 26 | * 27 | * @author airsaid 28 | */ 29 | object HttpRequestFactory { 30 | 31 | private const val DEFAULT_TIMEOUT_MS = 60 * 1000 32 | 33 | fun post(url: String, contentType: String, timeoutMs: Int = DEFAULT_TIMEOUT_MS): RequestBuilder { 34 | return HttpRequests.post(url, contentType) 35 | .productNameAsUserAgent() 36 | .connectTimeout(timeoutMs) 37 | .readTimeout(timeoutMs) 38 | } 39 | 40 | fun get(url: String, timeoutMs: Int = DEFAULT_TIMEOUT_MS): RequestBuilder { 41 | return HttpRequests.request(url) 42 | .productNameAsUserAgent() 43 | .connectTimeout(timeoutMs) 44 | .readTimeout(timeoutMs) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/util/LRUCache.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.util 19 | 20 | import java.util.function.BiConsumer 21 | 22 | /** 23 | * @author airsaid 24 | */ 25 | class LRUCache(initialCapacity: Int) { 26 | 27 | private val caches: MutableMap> 28 | private var head: Node? = null 29 | private var tail: Node? = null 30 | private var maxCapacity: Int 31 | 32 | init { 33 | maxCapacity = initialCapacity 34 | if (initialCapacity <= 0) { 35 | throw IllegalArgumentException("Illegal Capacity: $initialCapacity") 36 | } 37 | caches = linkedMapOf() 38 | } 39 | 40 | fun put(key: K, value: V) { 41 | while (isFull()) { 42 | removeTailNode() 43 | } 44 | val newNode = Node(key, value) 45 | caches[key] = newNode 46 | moveToHeadNode(newNode) 47 | } 48 | 49 | fun get(key: K?): V? { 50 | if (caches.containsKey(key)) { 51 | val newHead = caches[key]!! 52 | moveToHeadNode(newHead) 53 | return newHead.value 54 | } 55 | return null 56 | } 57 | 58 | fun size(): Int = caches.size 59 | 60 | fun isFull(): Boolean = size() > 0 && size() >= maxCapacity 61 | 62 | fun isEmpty(): Boolean = size() <= 0 63 | 64 | fun forEach(consumer: BiConsumer) { 65 | for ((key, value) in caches) { 66 | consumer.accept(key, value.value) 67 | } 68 | } 69 | 70 | fun forEach(action: (K, V) -> Unit) { 71 | for ((key, value) in caches) { 72 | action(key, value.value) 73 | } 74 | } 75 | 76 | fun clear() { 77 | caches.clear() 78 | head = null 79 | tail = null 80 | } 81 | 82 | private fun moveToHeadNode(node: Node) { 83 | if (head == null) { 84 | head = node 85 | tail = node 86 | return 87 | } 88 | 89 | node.next = head 90 | head?.prev = node 91 | head = node 92 | } 93 | 94 | private fun removeTailNode() { 95 | val currentTail = tail ?: return 96 | 97 | caches.remove(currentTail.key) 98 | val prev = currentTail.prev 99 | prev?.next = null 100 | currentTail.prev = null 101 | tail = prev 102 | } 103 | 104 | fun setMaxCapacity(maxCapacity: Int) { 105 | this.maxCapacity = maxCapacity 106 | } 107 | 108 | fun getMaxCapacity(): Int = maxCapacity 109 | 110 | private class Node( 111 | val key: K, 112 | val value: V 113 | ) { 114 | var prev: Node? = null 115 | var next: Node? = null 116 | } 117 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/util/MD5.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.util 19 | 20 | import java.nio.charset.StandardCharsets 21 | import java.security.MessageDigest 22 | import java.security.NoSuchAlgorithmException 23 | 24 | /** 25 | * @author airsaid 26 | */ 27 | object MD5 { 28 | 29 | private val hexDigits = charArrayOf( 30 | '0', '1', '2', '3', '4', '5', '6', '7', 31 | '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' 32 | ) 33 | 34 | fun md5(input: String?): String? { 35 | if (input == null) { 36 | return null 37 | } 38 | 39 | return try { 40 | val messageDigest = MessageDigest.getInstance("MD5") 41 | val inputByteArray = input.toByteArray(StandardCharsets.UTF_8) 42 | messageDigest.update(inputByteArray) 43 | val resultByteArray = messageDigest.digest() 44 | byteArrayToHex(resultByteArray) 45 | } catch (e: NoSuchAlgorithmException) { 46 | null 47 | } 48 | } 49 | 50 | private fun byteArrayToHex(byteArray: ByteArray): String { 51 | val resultCharArray = CharArray(byteArray.size * 2) 52 | var index = 0 53 | for (b in byteArray) { 54 | resultCharArray[index++] = hexDigits[b.toInt() ushr 4 and 0xf] 55 | resultCharArray[index++] = hexDigits[b.toInt() and 0xf] 56 | } 57 | return String(resultCharArray) 58 | } 59 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/translate/util/UrlBuilder.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.translate.util 19 | 20 | /** 21 | * @author airsaid 22 | */ 23 | class UrlBuilder(private val baseUrl: String) { 24 | 25 | private val queryParameters: MutableList> = mutableListOf() 26 | 27 | fun addQueryParameter(key: String, value: String): UrlBuilder { 28 | queryParameters.add(Pair(key, value)) 29 | return this 30 | } 31 | 32 | fun addQueryParameters(key: String, vararg values: String): UrlBuilder { 33 | queryParameters.addAll(values.map { value -> Pair(key, value) }) 34 | return this 35 | } 36 | 37 | fun build(): String { 38 | val result = StringBuilder(baseUrl) 39 | for (i in queryParameters.indices) { 40 | if (i == 0) { 41 | result.append("?") 42 | } else { 43 | result.append("&") 44 | } 45 | val param = queryParameters[i] 46 | val key = param.first 47 | val value = param.second 48 | result.append(key) 49 | .append("=") 50 | .append(value) 51 | } 52 | return result.toString() 53 | } 54 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/ui/ComposeDialog.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.ui 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.LaunchedEffect 5 | import com.intellij.openapi.project.Project 6 | import com.intellij.openapi.ui.DialogWrapper 7 | import org.jetbrains.jewel.bridge.JewelComposePanel 8 | import java.awt.Dimension 9 | import javax.swing.JComponent 10 | 11 | /** 12 | * Base dialog wrapper that embeds Compose UI inside IntelliJ Swing dialogs. 13 | * 14 | * @author airsaid 15 | */ 16 | abstract class ComposeDialog( 17 | project: Project? = null, 18 | canBeParent: Boolean = true 19 | ) : DialogWrapper(project, canBeParent) { 20 | 21 | protected open val defaultPreferredSize: Pair? = null 22 | 23 | private val composePanel by lazy { 24 | JewelComposePanel(config = { 25 | println("pSize: $defaultPreferredSize") 26 | defaultPreferredSize?.let { preferredSize = Dimension(it.first, it.second) } 27 | }, content = { 28 | Content() 29 | }) 30 | } 31 | private var onClickOKCallback: (() -> Unit)? = null 32 | 33 | override fun createCenterPanel(): JComponent = composePanel 34 | 35 | init { 36 | init() 37 | } 38 | 39 | @Composable 40 | protected abstract fun Content() 41 | 42 | @Composable 43 | protected fun OnClickOK(callback: () -> Unit) { 44 | LaunchedEffect(callback) { 45 | onClickOKCallback = callback 46 | } 47 | } 48 | 49 | /** 50 | * Invokes the registered OK callback prior to delegating to the Swing dialog handler. 51 | */ 52 | override fun doOKAction() { 53 | onClickOKCallback?.invoke() 54 | super.doOKAction() 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/ui/SupportedLanguagesDialog.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.airsaid.localization.ui 18 | 19 | import androidx.compose.foundation.VerticalScrollbar 20 | import androidx.compose.foundation.background 21 | import androidx.compose.foundation.layout.* 22 | import androidx.compose.foundation.lazy.LazyColumn 23 | import androidx.compose.foundation.lazy.items 24 | import androidx.compose.foundation.lazy.rememberLazyListState 25 | import androidx.compose.foundation.rememberScrollbarAdapter 26 | import androidx.compose.runtime.Composable 27 | import androidx.compose.ui.Alignment 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.text.font.FontWeight 30 | import androidx.compose.ui.unit.dp 31 | import androidx.compose.ui.unit.sp 32 | import com.airsaid.localization.translate.AbstractTranslator 33 | import com.airsaid.localization.translate.lang.Lang 34 | import org.jetbrains.jewel.foundation.theme.JewelTheme 35 | import org.jetbrains.jewel.ui.component.Text 36 | 37 | /** 38 | * A dialog to show the supported languages of the [translator]. 39 | * 40 | * @author airsaid 41 | */ 42 | class SupportedLanguagesDialog(private val translator: AbstractTranslator) : ComposeDialog() { 43 | 44 | override val defaultPreferredSize 45 | get() = 460 to 420 46 | 47 | private val supportedLanguages = translator.supportedLanguages.sortedBy { it.code } 48 | 49 | init { 50 | title = "${translator.name} Translator Supported Languages" 51 | } 52 | 53 | @Composable 54 | override fun Content() { 55 | Box( 56 | modifier = Modifier 57 | .fillMaxWidth() 58 | .background(JewelTheme.globalColors.panelBackground), 59 | ) { 60 | SupportLanguagesContent(languages = supportedLanguages) 61 | } 62 | } 63 | 64 | override fun getDimensionServiceKey(): String { 65 | return "#com.airsaid.localization.ui.SupportLanguagesDialog#${translator.key}" 66 | } 67 | } 68 | 69 | @Composable 70 | private fun SupportLanguagesContent(languages: List) { 71 | val listState = rememberLazyListState() 72 | 73 | Column( 74 | modifier = Modifier 75 | .fillMaxWidth() 76 | .padding(horizontal = 20.dp, vertical = 24.dp) 77 | ) { 78 | Text( 79 | text = "Supported languages (${languages.size})", 80 | fontWeight = FontWeight.Medium, 81 | color = JewelTheme.globalColors.text.normal 82 | ) 83 | Box( 84 | modifier = Modifier 85 | .fillMaxWidth() 86 | .padding(top = 12.dp) 87 | ) { 88 | LazyColumn( 89 | modifier = Modifier.fillMaxSize(), 90 | state = listState, 91 | verticalArrangement = Arrangement.spacedBy(12.dp) 92 | ) { 93 | items(languages, key = { it.code }) { language -> 94 | Row( 95 | modifier = Modifier.fillMaxWidth(), 96 | verticalAlignment = Alignment.CenterVertically, 97 | horizontalArrangement = Arrangement.spacedBy(8.dp) 98 | ) { 99 | val flag = language.flag 100 | if (flag.isNotEmpty()) { 101 | Text( 102 | text = flag, 103 | fontSize = 22.sp 104 | ) 105 | } 106 | Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { 107 | Text( 108 | text = language.name, 109 | color = JewelTheme.globalColors.text.normal 110 | ) 111 | Text( 112 | text = "${language.englishName} (${language.code})", 113 | color = JewelTheme.globalColors.text.info 114 | ) 115 | } 116 | } 117 | } 118 | } 119 | VerticalScrollbar( 120 | modifier = Modifier.align(Alignment.CenterEnd), 121 | adapter = rememberScrollbarAdapter(listState) 122 | ) 123 | } 124 | } 125 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/ui/components/SwingIcon.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.ui.components 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.layout.size 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.remember 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.graphics.Color 9 | import androidx.compose.ui.graphics.ColorFilter 10 | import androidx.compose.ui.graphics.ImageBitmap 11 | import androidx.compose.ui.graphics.painter.BitmapPainter 12 | import androidx.compose.ui.graphics.toComposeImageBitmap 13 | import androidx.compose.ui.unit.dp 14 | import java.awt.image.BufferedImage 15 | import javax.swing.Icon 16 | 17 | /** 18 | * A icon component to show a swing [Icon] in Jetpack Compose UI. 19 | * 20 | * @author airsaid 21 | */ 22 | @Composable 23 | fun SwingIcon(icon: Icon?, modifier: Modifier = Modifier) { 24 | if (icon == null) return 25 | val imageBitmap = remember(icon) { icon.toImageBitmap() } 26 | Image( 27 | painter = remember(imageBitmap) { BitmapPainter(imageBitmap) }, 28 | contentDescription = null, 29 | modifier = modifier.size(20.dp), 30 | ) 31 | } 32 | 33 | /** 34 | * A icon component to show a swing [Icon] in Jetpack Compose UI. 35 | * 36 | * @author airsaid 37 | */ 38 | @Composable 39 | fun SwingIcon(icon: Icon?, modifier: Modifier = Modifier, tintColor: Color) { 40 | if (icon == null) return 41 | val imageBitmap = remember(icon) { icon.toImageBitmap() } 42 | Image( 43 | painter = remember(imageBitmap) { BitmapPainter(imageBitmap) }, 44 | contentDescription = null, 45 | modifier = modifier.size(20.dp), 46 | colorFilter = ColorFilter.tint(tintColor) 47 | ) 48 | } 49 | 50 | private fun Icon.toImageBitmap(): ImageBitmap { 51 | val image = BufferedImage(iconWidth, iconHeight, BufferedImage.TYPE_INT_ARGB) 52 | val graphics = image.createGraphics() 53 | graphics.background = java.awt.Color(0, 0, 0, 0) 54 | paintIcon(null, graphics, 0, 0) 55 | graphics.dispose() 56 | return image.toComposeImageBitmap() 57 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/ui/components/TooltipIcon.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.ui.components 2 | 3 | import androidx.compose.foundation.ExperimentalFoundationApi 4 | import androidx.compose.foundation.layout.size 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.unit.dp 8 | import com.intellij.icons.AllIcons 9 | import org.jetbrains.jewel.ui.component.Text 10 | import org.jetbrains.jewel.ui.component.Tooltip 11 | 12 | /** 13 | * Renders a context-help icon that reveals the provided message inside a tooltip when hovered. 14 | * 15 | * @param text Human-readable description shown inside the tooltip popup. 16 | */ 17 | @OptIn(ExperimentalFoundationApi::class) 18 | @Composable 19 | fun TooltipIcon(text: String) { 20 | Tooltip(tooltip = { Text(text) }) { 21 | SwingIcon( 22 | icon = AllIcons.General.ContextHelp, 23 | modifier = Modifier.size(16.dp) 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/utils/LanguageUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.utils 19 | 20 | import com.airsaid.localization.constant.Constants 21 | import com.airsaid.localization.translate.lang.Lang 22 | import com.airsaid.localization.translate.lang.Languages 23 | import com.intellij.ide.util.PropertiesComponent 24 | import com.intellij.openapi.project.Project 25 | import com.intellij.openapi.vfs.VirtualFile 26 | 27 | /** 28 | * A util class that operates on language data. 29 | * 30 | * @author airsaid 31 | */ 32 | object LanguageUtil { 33 | 34 | private const val SEPARATOR_SELECTED_LANGUAGES_CODE = "," 35 | 36 | /** 37 | * Persist the selected language codes in the current project. 38 | * 39 | * @param project current project. 40 | * @param languages selected language. 41 | */ 42 | fun saveSelectedLanguages(project: Project, languages: List) { 43 | PropertiesComponent.getInstance(project) 44 | .setValue(Constants.KEY_SELECTED_LANGUAGES, getLanguageCodeString(languages)) 45 | } 46 | 47 | /** 48 | * Fetch the persisted selected language codes for the given project. 49 | * 50 | * @param project current project. 51 | * @return the selected language codes, or null if not set before. 52 | */ 53 | fun getSelectedLanguageIds(project: Project): List { 54 | val codeString = PropertiesComponent.getInstance(project) 55 | .getValue(Constants.KEY_SELECTED_LANGUAGES) 56 | 57 | return parseStoredLanguageCodes(codeString) 58 | } 59 | 60 | /** 61 | * Persist the favorite language codes in the current project. 62 | * 63 | * @param project current project. 64 | * @param languages favorite language. 65 | */ 66 | fun saveFavoriteLanguages(project: Project, languages: List) { 67 | PropertiesComponent.getInstance(project) 68 | .setValue(Constants.KEY_FAVORITE_LANGUAGES, getLanguageCodeString(languages)) 69 | } 70 | 71 | /** 72 | * Fetch the persisted favorite language codes for the given project. 73 | * 74 | * @param project current project. 75 | * @return the favorite language codes, or null if not set before. 76 | */ 77 | fun getFavoriteLanguageIds(project: Project): List { 78 | val codeString = PropertiesComponent.getInstance(project) 79 | .getValue(Constants.KEY_FAVORITE_LANGUAGES) 80 | 81 | return parseStoredLanguageCodes(codeString) 82 | } 83 | 84 | private fun getLanguageCodeString(language: List): String { 85 | return language.joinToString(SEPARATOR_SELECTED_LANGUAGES_CODE) { it.code } 86 | } 87 | 88 | /** 89 | * Get languages that already exist in the project by scanning the resource directories. 90 | * 91 | * @param resourceDir the resource directory (parent of values directories). 92 | * @param supportedLanguages the list of supported languages from translator. 93 | * @return list of languages that have corresponding values directories in the project. 94 | */ 95 | fun getExistingProjectLanguages(resourceDir: VirtualFile, supportedLanguages: List): List { 96 | val existingLanguages = mutableListOf() 97 | 98 | for (child in resourceDir.children) { 99 | if (child.isDirectory) { 100 | val dirName = child.name 101 | 102 | if (dirName.startsWith("values-")) { 103 | val languageCode = dirName.substring(7) // Remove "values-" prefix 104 | 105 | // Find matching language by directory name 106 | val matchingLang = supportedLanguages.find { lang -> 107 | lang.directoryName == languageCode 108 | } 109 | 110 | matchingLang?.let { existingLanguages.add(it) } 111 | } 112 | } 113 | } 114 | 115 | return existingLanguages.distinct() 116 | } 117 | 118 | private fun parseStoredLanguageCodes(value: String?): List { 119 | if (value.isNullOrEmpty()) return emptyList() 120 | 121 | return value.split(SEPARATOR_SELECTED_LANGUAGES_CODE) 122 | .map { it.trim() } 123 | .filter { it.isNotEmpty() && Languages.entries.any { lang -> lang.code == it } } 124 | .distinct() 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/utils/NotificationUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.utils 19 | 20 | import com.intellij.notification.NotificationGroup 21 | import com.intellij.notification.NotificationGroupManager 22 | import com.intellij.notification.NotificationType 23 | import com.intellij.openapi.project.Project 24 | 25 | /** 26 | * @author airsaid 27 | */ 28 | object NotificationUtil { 29 | 30 | private const val NOTIFICATION_GROUP_ID = "Android Localize Plugin" 31 | 32 | private val NOTIFICATION_GROUP: NotificationGroup = 33 | NotificationGroupManager.getInstance().getNotificationGroup(NOTIFICATION_GROUP_ID) 34 | 35 | fun notifyInfo(project: Project?, content: String) { 36 | NOTIFICATION_GROUP.createNotification(content, NotificationType.INFORMATION) 37 | .notify(project) 38 | } 39 | 40 | fun notifyWarning(project: Project?, content: String) { 41 | NOTIFICATION_GROUP.createNotification(content, NotificationType.WARNING) 42 | .notify(project) 43 | } 44 | 45 | fun notifyError(project: Project?, content: String) { 46 | NOTIFICATION_GROUP.createNotification(content, NotificationType.ERROR) 47 | .notify(project) 48 | } 49 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/utils/SecureStorage.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.utils 19 | 20 | import com.airsaid.localization.constant.Constants 21 | import com.intellij.credentialStore.CredentialAttributes 22 | import com.intellij.credentialStore.Credentials 23 | import com.intellij.credentialStore.generateServiceName 24 | import com.intellij.ide.passwordSafe.PasswordSafe 25 | 26 | /** 27 | * @author airsaid 28 | */ 29 | class SecureStorage(private val key: String) { 30 | 31 | fun save(text: String) { 32 | val credentialAttributes = createCredentialAttributes() 33 | val credentials = Credentials(key, text) 34 | PasswordSafe.instance.set(credentialAttributes, credentials) 35 | } 36 | 37 | fun read(): String { 38 | val password = PasswordSafe.instance.getPassword(createCredentialAttributes()) 39 | return password ?: "" 40 | } 41 | 42 | private fun createCredentialAttributes(): CredentialAttributes { 43 | return CredentialAttributes(generateServiceName(Constants.PLUGIN_NAME, key)) 44 | } 45 | } -------------------------------------------------------------------------------- /src/main/kotlin/com/airsaid/localization/utils/TextUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2021 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | package com.airsaid.localization.utils 19 | 20 | import com.intellij.openapi.util.text.StringUtil 21 | 22 | /** 23 | * @author airsaid 24 | */ 25 | object TextUtil { 26 | 27 | fun isEmptyOrSpacesLineBreak(s: CharSequence?): Boolean { 28 | if (StringUtil.isEmpty(s)) { 29 | return true 30 | } 31 | for (i in s!!.indices) { 32 | if (s[i] != ' ' && s[i] != '\r' && s[i] != '\n') { 33 | return false 34 | } 35 | } 36 | return true 37 | } 38 | } -------------------------------------------------------------------------------- /src/main/kotlin/icons/PluginIcons.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Airsaid. https://github.com/airsaid 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package icons 18 | 19 | import com.intellij.openapi.util.IconLoader 20 | import javax.swing.Icon 21 | 22 | /** 23 | * @author airsaid 24 | */ 25 | object PluginIcons { 26 | @JvmField 27 | val TRANSLATE_ACTION_ICON: Icon = load("/icons/icon_translate.svg") 28 | 29 | @JvmField 30 | val GOOGLE_ICON: Icon = load("/icons/icon_google.svg") 31 | 32 | @JvmField 33 | val BAIDU_ICON: Icon = load("/icons/icon_baidu.svg") 34 | 35 | @JvmField 36 | val YOUDAO_ICON: Icon = load("/icons/icon_youdao.svg") 37 | 38 | @JvmField 39 | val MICROSOFT_ICON: Icon = load("/icons/icon_microsoft.svg") 40 | 41 | @JvmField 42 | val ALI_ICON: Icon = load("/icons/icon_ali.svg") 43 | 44 | @JvmField 45 | val DEEP_L_ICON: Icon = load("/icons/icon_deepl.svg") 46 | 47 | @JvmField 48 | val OPENAI_ICON: Icon = load("/icons/icon_openai.svg") 49 | 50 | private fun load(path: String): Icon { 51 | return IconLoader.getIcon(path, PluginIcons::class.java) 52 | } 53 | } -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | com.github.airsaid.androidlocalize 3 | AndroidLocalize 4 | Airsaid 5 | 6 | com.intellij.modules.platform 7 | 8 | 9 | 13 | 14 | 16 | 17 | 18 | 19 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/pluginIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/main/resources/icons/icon_ali.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /src/main/resources/icons/icon_baidu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/resources/icons/icon_baidu_dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/resources/icons/icon_deepl.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 7 | 9 | 11 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/main/resources/icons/icon_google.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main/resources/icons/icon_openai.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/main/resources/icons/icon_translate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/main/resources/icons/icon_youdao.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/kotlin/com/airsaid/localization/extensions/StringExtensionsTest.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.extensions 2 | 3 | import org.junit.jupiter.api.Assertions.assertFalse 4 | import org.junit.jupiter.api.Assertions.assertTrue 5 | import org.junit.jupiter.api.Test 6 | 7 | class StringExtensionsTest { 8 | 9 | @Test 10 | fun hasNoTranslatableText() { 11 | // Numbers only - should return true 12 | assertTrue("123".hasNoTranslatableText()) 13 | assertTrue("0".hasNoTranslatableText()) 14 | assertTrue("999999".hasNoTranslatableText()) 15 | 16 | // Numbers with symbols/punctuation - should return true 17 | assertTrue("12.3".hasNoTranslatableText()) 18 | assertTrue("-123".hasNoTranslatableText()) 19 | assertTrue("+123".hasNoTranslatableText()) 20 | assertTrue("123!@#".hasNoTranslatableText()) 21 | assertTrue("12,34".hasNoTranslatableText()) 22 | assertTrue("$100".hasNoTranslatableText()) 23 | assertTrue("50%".hasNoTranslatableText()) 24 | assertTrue("(123)".hasNoTranslatableText()) 25 | 26 | // Numbers with whitespace - should return true 27 | assertTrue("12 3".hasNoTranslatableText()) 28 | assertTrue("12\n3".hasNoTranslatableText()) 29 | assertTrue("12\t3".hasNoTranslatableText()) 30 | assertTrue(" 123 ".hasNoTranslatableText()) 31 | 32 | // Symbols/punctuation only - should return true 33 | assertTrue("!@#".hasNoTranslatableText()) 34 | assertTrue("...".hasNoTranslatableText()) 35 | assertTrue("***".hasNoTranslatableText()) 36 | assertTrue("???".hasNoTranslatableText()) 37 | 38 | // Text with letters - should return false (needs translation) 39 | assertFalse("abc".hasNoTranslatableText()) 40 | assertFalse("123abc".hasNoTranslatableText()) 41 | assertFalse("abc123".hasNoTranslatableText()) 42 | assertFalse("Hello".hasNoTranslatableText()) 43 | assertFalse("Hello123".hasNoTranslatableText()) 44 | assertFalse("123Hello".hasNoTranslatableText()) 45 | assertFalse("中文".hasNoTranslatableText()) 46 | assertFalse("测试123".hasNoTranslatableText()) 47 | 48 | // Empty/null - should return true 49 | assertTrue("".hasNoTranslatableText()) 50 | val nullString: String? = null 51 | assertTrue(nullString.hasNoTranslatableText()) 52 | } 53 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/airsaid/localization/translate/services/TranslatorServiceTest.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.services 2 | 3 | import com.airsaid.localization.translate.AbstractTranslator 4 | import com.airsaid.localization.translate.lang.Lang 5 | import org.junit.jupiter.api.Assertions.assertEquals 6 | import org.junit.jupiter.api.Test 7 | 8 | /** 9 | * Unit tests covering translator selection logic. 10 | * 11 | * @author airsaid 12 | */ 13 | class TranslatorServiceTest { 14 | 15 | @Test 16 | fun `selectDefaultTranslator returns first translator`() { 17 | val first = StubTranslator("First") 18 | val second = StubTranslator("Second") 19 | val translators = linkedMapOf( 20 | first.key to first, 21 | second.key to second, 22 | ) 23 | 24 | val defaultTranslator = TranslatorService.selectDefaultTranslator(translators) 25 | 26 | assertEquals(first, defaultTranslator) 27 | } 28 | 29 | /** 30 | * Minimal translator stub used to drive selection scenarios. 31 | * 32 | * @author airsaid 33 | */ 34 | private class StubTranslator(override val key: String) : AbstractTranslator() { 35 | override val name: String = key 36 | override val supportedLanguages: List = emptyList() 37 | 38 | override fun getRequestUrl(fromLang: Lang, toLang: Lang, text: String): String { 39 | throw UnsupportedOperationException() 40 | } 41 | 42 | override fun parsingResult(fromLang: Lang, toLang: Lang, text: String, resultText: String): String { 43 | throw UnsupportedOperationException() 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/test/kotlin/com/airsaid/localization/translate/util/LRUCacheTest.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.util 2 | 3 | import org.junit.jupiter.api.Assertions.* 4 | import org.junit.jupiter.api.Test 5 | 6 | /** 7 | * @author airsaid 8 | */ 9 | class LRUCacheTest { 10 | 11 | @Test 12 | fun testEmpty() { 13 | val lruCache = LRUCache(10) 14 | assertTrue(lruCache.isEmpty()) 15 | assertFalse(lruCache.isFull()) 16 | assertNull(lruCache.get("key")) 17 | } 18 | 19 | @Test 20 | fun testFull() { 21 | val lruCache = LRUCache(1) 22 | lruCache.put("key", "value") 23 | assertFalse(lruCache.isEmpty()) 24 | assertTrue(lruCache.isFull()) 25 | assertNotNull(lruCache.get("key")) 26 | } 27 | 28 | @Test 29 | fun testPut() { 30 | val lruCache = LRUCache(3) 31 | lruCache.put("key1", "value1") 32 | lruCache.put("key2", "value2") 33 | lruCache.put("key3", "value3") 34 | lruCache.put("key4", "value4") 35 | assertNull(lruCache.get("key1")) 36 | assertEquals("value2", lruCache.get("key2")) 37 | assertEquals("value3", lruCache.get("key3")) 38 | assertEquals("value4", lruCache.get("key4")) 39 | lruCache.put("key5", "value5") 40 | assertNull(lruCache.get("key2")) 41 | } 42 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/airsaid/localization/translate/util/UrlBuilderTest.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.translate.util 2 | 3 | import org.junit.jupiter.api.Assertions 4 | import org.junit.jupiter.api.Test 5 | 6 | /** 7 | * @author airsaid 8 | */ 9 | class UrlBuilderTest { 10 | 11 | @Test 12 | fun testNoParameterBuild() { 13 | val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") 14 | .build() 15 | Assertions.assertEquals("https://translate.googleapis.com/translate_a/single", result) 16 | } 17 | 18 | @Test 19 | fun testSingleParameterBuild() { 20 | val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") 21 | .addQueryParameter("sl", "en") 22 | .build() 23 | Assertions.assertEquals("https://translate.googleapis.com/translate_a/single?sl=en", result) 24 | } 25 | 26 | @Test 27 | fun testSomeParameterBuild() { 28 | val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") 29 | .addQueryParameter("sl", "en") 30 | .addQueryParameter("tl", "zh-CN") 31 | .addQueryParameter("client", "gtx") 32 | .addQueryParameter("dt", "t") 33 | .build() 34 | Assertions.assertEquals( 35 | "https://translate.googleapis.com/translate_a/single?sl=en&tl=zh-CN&client=gtx&dt=t", 36 | result 37 | ) 38 | } 39 | 40 | @Test 41 | fun testRepeatParameterBuild() { 42 | val result = UrlBuilder("https://translate.googleapis.com/translate_a/single") 43 | .addQueryParameter("sl", "en") 44 | .addQueryParameter("tl", "zh-CN") 45 | .addQueryParameters("dt", "t", "bd", "ex") 46 | .build() 47 | Assertions.assertEquals( 48 | "https://translate.googleapis.com/translate_a/single?sl=en&tl=zh-CN&dt=t&dt=bd&dt=ex", 49 | result 50 | ) 51 | } 52 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/airsaid/localization/utils/TextUtilTest.kt: -------------------------------------------------------------------------------- 1 | package com.airsaid.localization.utils 2 | 3 | import org.junit.jupiter.api.Assertions.assertFalse 4 | import org.junit.jupiter.api.Assertions.assertTrue 5 | import org.junit.jupiter.api.Test 6 | 7 | /** 8 | * @author airsaid 9 | */ 10 | class TextUtilTest { 11 | 12 | @Test 13 | fun isEmptyOrSpacesLineBreak() { 14 | assertTrue(TextUtil.isEmptyOrSpacesLineBreak(null)) 15 | assertTrue(TextUtil.isEmptyOrSpacesLineBreak("")) 16 | assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" ")) 17 | assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" ")) 18 | assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\r")) 19 | assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\n")) 20 | assertTrue(TextUtil.isEmptyOrSpacesLineBreak("\r\n")) 21 | assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" \r\n ")) 22 | assertTrue(TextUtil.isEmptyOrSpacesLineBreak(" \r \n ")) 23 | 24 | assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text")) 25 | assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text ")) 26 | assertFalse(TextUtil.isEmptyOrSpacesLineBreak(" text")) 27 | assertFalse(TextUtil.isEmptyOrSpacesLineBreak(" text ")) 28 | assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\ntext")) 29 | assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text\n")) 30 | assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\rtext")) 31 | assertFalse(TextUtil.isEmptyOrSpacesLineBreak("text\r")) 32 | assertFalse(TextUtil.isEmptyOrSpacesLineBreak("\r\ntext\r\n")) 33 | } 34 | } --------------------------------------------------------------------------------