├── .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 |
6 |
7 |
8 |
9 |
12 |
15 |
16 |
17 | true
18 | true
19 | false
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.run/Run IDE with Plugin.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
12 |
17 |
18 |
19 | true
20 | true
21 | false
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.run/Run Plugin Tests.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | true
20 | true
21 | false
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/.run/Run Plugin Verification.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 | #  AndroidLocalizePlugin
4 | [](https://plugins.jetbrains.com/plugin/11174-androidlocalize)
5 | [](https://plugins.jetbrains.com/plugin/11174-androidlocalize)
6 | [](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 | 
39 | 
40 | 
41 |
42 | # Install
43 | [](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 |
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 | #  AndroidLocalizePlugin
4 | [](https://plugins.jetbrains.com/plugin/11174-androidlocalize)
5 | [](https://plugins.jetbrains.com/plugin/11174-androidlocalize)
6 | [](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 | 
36 | 
37 | 
38 |
39 | # 安装
40 | [](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 |
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