diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 70d86ad..f6b0d11 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,15 @@ version: 2 updates: + # Maintain dependencies for Gradle dependencies - package-ecosystem: "gradle" directory: "/" + target-branch: "next" schedule: interval: "daily" + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "next" + schedule: + interval: "daily" \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f9ada39..47d5a19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,111 +1,53 @@ # GitHub Actions Workflow created for testing and preparing the plugin release in following steps: # - validate Gradle Wrapper, -# - run test and verifyPlugin tasks, -# - run buildPlugin task and prepare artifact for the further tests, -# - run IntelliJ Plugin Verifier, +# - run 'test' and 'verifyPlugin' tasks, +# - run Qodana inspections, +# - run 'buildPlugin' task and prepare artifact for the further tests, +# - run 'runPluginVerifier' task, # - create a draft release. # # Workflow is triggered on push and pull_request events. # -# Docs: -# - GitHub Actions: https://help.github.com/en/actions -# - IntelliJ Plugin Verifier GitHub Action: https://github.com/ChrisCarini/intellij-platform-plugin-verifier-action +# GitHub Actions reference: https://help.github.com/en/actions # ## JBIJPPTPL name: Build -on: [push, pull_request] +on: + # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g. for dependabot pull requests) + push: + branches: [main] + # Trigger the workflow on any pull request + pull_request: + jobs: # Run Gradle Wrapper Validation Action to verify the wrapper's checksum - gradleValidation: - name: Gradle Wrapper + # Run verifyPlugin, IntelliJ Plugin Verifier, and test Gradle tasks + # Build plugin and provide the artifact for the next workflow jobs + build: + name: Build runs-on: ubuntu-latest + outputs: + version: ${{ steps.properties.outputs.version }} + changelog: ${{ steps.properties.outputs.changelog }} steps: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 # Validate wrapper - name: Gradle Wrapper Validation - uses: gradle/wrapper-validation-action@v1.0.3 + uses: gradle/wrapper-validation-action@v1.0.4 - # Run verifyPlugin and test Gradle tasks - test: - name: Test - needs: gradleValidation - runs-on: ubuntu-latest - steps: - - # Setup Java 1.8 environment for the next steps + # Setup Java 11 environment for the next steps - name: Setup Java - uses: actions/setup-java@v1 + uses: actions/setup-java@v2 with: + distribution: zulu java-version: 11 - - # Check out current repository - - name: Fetch Sources - uses: actions/checkout@v2 - - # Cache Gradle dependencies - - name: Setup Gradle Dependencies Cache - uses: actions/cache@v2 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle.properties') }} - - # Cache Gradle Wrapper - - name: Setup Gradle Wrapper Cache - uses: actions/cache@v2 - with: - path: ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} - - # Run detekt, ktlint and tests - - name: Run Linters and Test - run: ./gradlew check - - # Run verifyPlugin Gradle task - - name: Verify Plugin - run: ./gradlew verifyPlugin - - # Build plugin with buildPlugin Gradle task and provide the artifact for the next workflow jobs - # Requires test job to be passed - build: - name: Build - needs: test - runs-on: ubuntu-latest - outputs: - name: ${{ steps.properties.outputs.name }} - version: ${{ steps.properties.outputs.version }} - changelog: ${{ steps.properties.outputs.changelog }} - artifact: ${{ steps.properties.outputs.artifact }} - steps: - - # Setup Java 1.8 environment for the next steps - - name: Setup Java - uses: actions/setup-java@v1 - with: - java-version: 11 - - # Check out current repository - - name: Fetch Sources - uses: actions/checkout@v2 - - # Cache Gradle Dependencies - - name: Setup Gradle Dependencies Cache - uses: actions/cache@v2 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle.properties') }} - - # Cache Gradle Wrapper - - name: Setup Gradle Wrapper Cache - uses: actions/cache@v2 - with: - path: ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} + cache: gradle # Set environment variables - name: Export Properties @@ -119,128 +61,99 @@ jobs: CHANGELOG="${CHANGELOG//'%'/'%25'}" CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" - ARTIFACT="${NAME}-${VERSION}.zip" - echo "::set-output name=version::$VERSION" echo "::set-output name=name::$NAME" echo "::set-output name=changelog::$CHANGELOG" - echo "::set-output name=artifact::$ARTIFACT" - - # Build artifact using buildPlugin Gradle task - - name: Build Plugin - run: ./gradlew buildPlugin - - # Upload plugin artifact to make it available in the next jobs - - name: Upload artifact - uses: actions/upload-artifact@v1 - with: - name: plugin-artifact - path: ./build/distributions/${{ steps.properties.outputs.artifact }} - - # Verify built plugin using IntelliJ Plugin Verifier tool - # Requires build job to be passed - verify: - name: Verify - needs: build - runs-on: ubuntu-latest - steps: - - # Setup Java 1.8 environment for the next steps - - name: Setup Java - uses: actions/setup-java@v1 - with: - java-version: 11 - - # Check out current repository - - name: Fetch Sources - uses: actions/checkout@v2 - - # Cache Gradle Dependencies - - name: Setup Gradle Dependencies Cache - uses: actions/cache@v2 - with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-caches-${{ hashFiles('**/*.gradle', '**/*.gradle.kts', 'gradle.properties') }} - - # Cache Gradle Wrapper - - name: Setup Gradle Wrapper Cache - uses: actions/cache@v2 - with: - path: ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} - - # Set environment variables - - name: Export Properties - id: properties - shell: bash - run: | - PROPERTIES="$(./gradlew properties --console=plain -q)" - IDE_VERSIONS="$(echo "$PROPERTIES" | grep "^pluginVerifierIdeVersions:" | base64)" - - echo "::set-output name=ideVersions::$IDE_VERSIONS" echo "::set-output name=pluginVerifierHomeDir::~/.pluginVerifier" + ./gradlew listProductsReleases # prepare list of IDEs for Plugin Verifier + # Run tests + - name: Run Tests + run: ./gradlew test + + # Collect Tests Result of failed tests + - name: Collect Tests Result + if: ${{ failure() }} + uses: actions/upload-artifact@v2 + with: + name: tests-result + path: ${{ github.workspace }}/build/reports/tests # Cache Plugin Verifier IDEs - name: Setup Plugin Verifier IDEs Cache - uses: actions/cache@v2 + uses: actions/cache@v2.1.6 with: path: ${{ steps.properties.outputs.pluginVerifierHomeDir }}/ides - key: ${{ runner.os }}-plugin-verifier-${{ steps.properties.outputs.ideVersions }} + key: plugin-verifier-${{ hashFiles('build/listProductsReleases.txt') }} - # Run IntelliJ Plugin Verifier action using GitHub Action - - name: Verify Plugin + # Run Verify Plugin task and IntelliJ Plugin Verifier tool + - name: Run Plugin Verification tasks run: ./gradlew runPluginVerifier -Pplugin.verifier.home.dir=${{ steps.properties.outputs.pluginVerifierHomeDir }} + # Collect Plugin Verifier Result + - name: Collect Plugin Verifier Result + if: ${{ always() }} + uses: actions/upload-artifact@v2 + with: + name: pluginVerifier-result + path: ${{ github.workspace }}/build/reports/pluginVerifier + + # Run Qodana inspections + - name: Qodana - Code Inspection + uses: JetBrains/qodana-action@v2.1-eap + + # Collect Qodana Result + - name: Collect Qodana Result + uses: actions/upload-artifact@v2 + with: + name: qodana-result + path: ${{ github.workspace }}/qodana + + # Prepare plugin archive content for creating artifact + - name: Prepare Plugin Artifact + id: artifact + shell: bash + run: | + cd ${{ github.workspace }}/build/distributions + FILENAME=`ls *.zip` + unzip "$FILENAME" -d content + echo "::set-output name=filename::$FILENAME" + # Store already-built plugin as an artifact for downloading + - name: Upload artifact + uses: actions/upload-artifact@v2.2.4 + with: + name: ${{ steps.artifact.outputs.filename }} + path: ./build/distributions/content/*/* + # Prepare a draft release for GitHub Releases page for the manual verification # If accepted and published, release workflow would be triggered releaseDraft: name: Release Draft if: github.event_name != 'pull_request' - needs: [build, verify] + needs: build runs-on: ubuntu-latest steps: # Check out current repository - name: Fetch Sources - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 # Remove old release drafts by using the curl request for the available releases with draft flag - name: Remove Old Release Drafts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - curl -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/repos/$GITHUB_REPOSITORY/releases \ - | tr '\r\n' ' ' \ - | jq '.[] | select(.draft == true) | .id' \ - | xargs -I '{}' \ - curl -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/repos/$GITHUB_REPOSITORY/releases/{} - + gh api repos/{owner}/{repo}/releases \ + --jq '.[] | select(.draft == true) | .id' \ + | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} # Create new release draft - which is not publicly visible and requires manual acceptance - name: Create Release Draft - id: createDraft - uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: v${{ needs.build.outputs.version }} - release_name: v${{ needs.build.outputs.version }} - body: ${{ needs.build.outputs.changelog }} - draft: true - - # Download plugin artifact provided by the previous job - - name: Download Artifact - uses: actions/download-artifact@v2 - with: - name: plugin-artifact - - # Upload artifact as a release asset - - name: Upload Release Asset - id: upload-release-asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.createDraft.outputs.upload_url }} - asset_path: ./${{ needs.build.outputs.artifact }} - asset_name: ${{ needs.build.outputs.artifact }} - asset_content_type: application/zip + run: | + gh release create v${{ needs.build.outputs.version }} \ + --draft \ + --title "v${{ needs.build.outputs.version }}" \ + --notes "$(cat << 'EOM' + ${{ needs.build.outputs.changelog }} + EOM + )" \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7afcbe..fff3941 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,57 +14,66 @@ jobs: runs-on: ubuntu-latest steps: - # Setup Java 1.8 environment for the next steps - - name: Setup Java - uses: actions/setup-java@v1 - with: - java-version: 11 - # Check out current repository - name: Fetch Sources - uses: actions/checkout@v2 + uses: actions/checkout@v2.4.0 with: ref: ${{ github.event.release.tag_name }} + # Setup Java 11 environment for the next steps + - name: Setup Java + uses: actions/setup-java@v2 + with: + distribution: zulu + java-version: 11 + cache: gradle + + # Set environment variables + - name: Export Properties + id: properties + shell: bash + run: | + CHANGELOG="$(cat << 'EOM' | sed -e 's/^[[:space:]]*$//g' -e '/./,$!d' + ${{ github.event.release.body }} + EOM + )" + + echo "::set-output name=changelog::$CHANGELOG" + # Update Unreleased section with the current release note + - name: Patch Changelog + if: ${{ steps.properties.outputs.changelog != '' }} + run: | + ./gradlew patchChangelog --release-note "$(cat << 'EOM' + ${{ steps.properties.outputs.changelog }} + EOM + )" # Publish the plugin to the Marketplace - name: Publish Plugin env: PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} run: ./gradlew publishPlugin - # Patch changelog, commit and push to the current repository - changelog: - name: Update Changelog - needs: release - runs-on: ubuntu-latest - steps: + # Upload artifact as a release asset + - name: Upload Release Asset + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* - # Setup Java 1.8 environment for the next steps - - name: Setup Java - uses: actions/setup-java@v1 - with: - java-version: 11 - - # Check out current repository - - name: Fetch Sources - uses: actions/checkout@v2 - with: - ref: ${{ github.event.release.tag_name }} - - # Update Unreleased section with the current version - - name: Patch Changelog - run: ./gradlew patchChangelog - - # Commit patched Changelog - - name: Commit files + # Create pull request + - name: Create Pull Request + if: ${{ steps.properties.outputs.changelog != '' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git commit -m "Update changelog" -a - - # Push changes - - name: Push changes - uses: ad-m/github-push-action@master - with: - branch: main - github_token: ${{ secrets.GITHUB_TOKEN }} + VERSION="${{ github.event.release.tag_name }}" + BRANCH="changelog-update-$VERSION" + git config user.email "action@github.com" + git config user.name "GitHub Action" + git checkout -b $BRANCH + git commit -am "Changelog update - $VERSION" + git push --set-upstream origin $BRANCH + gh pr create \ + --title "Changelog update - \`$VERSION\`" \ + --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ + --base main \ + --head $BRANCH \ No newline at end of file diff --git a/.github/workflows/run-ui-tests.yml b/.github/workflows/run-ui-tests.yml new file mode 100644 index 0000000..1027273 --- /dev/null +++ b/.github/workflows/run-ui-tests.yml @@ -0,0 +1,60 @@ +# GitHub Actions Workflow for launching UI tests on Linux, Windows, and Mac in the following steps: +# - prepare and launch IDE with your plugin and robot-server plugin, which is needed to interact with UI +# - wait for IDE to start +# - run UI tests with separate Gradle task +# +# Please check https://github.com/JetBrains/intellij-ui-test-robot for information about UI tests with IntelliJ Platform +# +# Workflow is triggered manually. + +name: Run UI Tests +on: + workflow_dispatch + +jobs: + + testUI: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + runIde: | + export DISPLAY=:99.0 + Xvfb -ac :99 -screen 0 1920x1080x16 & + gradle runIdeForUiTests & + - os: windows-latest + runIde: start gradlew.bat runIdeForUiTests + - os: macos-latest + runIde: ./gradlew runIdeForUiTests & + + steps: + + # Check out current repository + - name: Fetch Sources + uses: actions/checkout@v2.4.0 + + # Setup Java 11 environment for the next steps + - name: Setup Java + uses: actions/setup-java@v2 + with: + distribution: zulu + java-version: 11 + cache: gradle + + # Run IDEA prepared for UI testing + - name: Run IDE + run: ${{ matrix.runIde }} + + # Wait for IDEA to be started + - name: Health Check + uses: jtalk/url-health-check-action@v2 + with: + url: http://127.0.0.1:8082 + max-attempts: 15 + retry-delay: 30s + + # Run tests + - name: Tests + run: ./gradlew test \ No newline at end of file diff --git a/.gitignore b/.gitignore index e0d53d8..61032ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .gradle .idea -build +.qodana +build \ No newline at end of file diff --git a/.run/Run IDE for UI Tests.run.xml b/.run/Run IDE for UI Tests.run.xml new file mode 100644 index 0000000..5b76189 --- /dev/null +++ b/.run/Run IDE for UI Tests.run.xml @@ -0,0 +1,22 @@ + + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/.run/Run IDE with Plugin.run.xml b/.run/Run IDE with Plugin.run.xml index d15ff68..f42721a 100644 --- a/.run/Run IDE with Plugin.run.xml +++ b/.run/Run IDE with Plugin.run.xml @@ -1,24 +1,24 @@ - - - - - - - true - true - false - - + + + + + + + true + true + false + + \ No newline at end of file diff --git a/.run/Run Plugin Tests.run.xml b/.run/Run Plugin Tests.run.xml index 03d0287..5e7d02e 100644 --- a/.run/Run Plugin Tests.run.xml +++ b/.run/Run Plugin Tests.run.xml @@ -1,24 +1,24 @@ - - - - - - - true - true - false - - + + + + + + + true + true + false + + \ No newline at end of file diff --git a/.run/Run Plugin Verification.run.xml b/.run/Run Plugin Verification.run.xml index 3a8d688..1c1be17 100644 --- a/.run/Run Plugin Verification.run.xml +++ b/.run/Run Plugin Verification.run.xml @@ -1,26 +1,26 @@ - - - - - - - true - true - false - - - + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/.run/Run Qodana.run.xml b/.run/Run Qodana.run.xml new file mode 100644 index 0000000..17b041a --- /dev/null +++ b/.run/Run Qodana.run.xml @@ -0,0 +1,26 @@ + + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6d85c..059334c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ # easy-i18n Changelog ## [Unreleased] +### Added +- The search function now supports full-text-search +- Automatically reload translation data on file system change +- Sorting of translation keys can now be disabled via configuration +- Key section nesting can be disabled via configuration +- Numbers will be stored as number type whenever possible +- Code signing of plugin source + +### Changed +- Better focus keys in tree-view after edit +- Optimized internal data structure (io, cache, events) +- Adjusted compatibility matrix to 2020.3 - 2021.3 +- Updated dependencies and improved README file + ## [1.5.1] ### Fixed - Exception on key annotation if path-prefix is undefined diff --git a/README.md b/README.md index 5dd8911..d4676bb 100644 --- a/README.md +++ b/README.md @@ -6,29 +6,40 @@ [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://paypal.me/marhalide) -This is an easy plugin to manage internationalization for JSON or Resource-Bundle(Properties) based locale files. -Most common use case is for translating Webapps or simple Java Applications. Translating large scale projects was never that easy with your favourite IDE! +This is a plugin for easier management of translation files of projects that need to be translated into different languages. Translating large projects has never been so easy with your favorite IDE! ## Use Cases -- Webapps: For example [Vue](https://vuejs.org/) with [vue-i18n](https://kazupon.github.io/vue-i18n/) or any other JSON translation file based technology -- Java based Resource-Bundle +- Webapps: [Vue](https://vuejs.org/) with [vue-i18n](https://kazupon.github.io/vue-i18n/), [React](https://reactjs.org/) or any other json based technology +- Java projects based on Resource-Bundle's +- Projects that uses yaml, json or properties as locale file base for internationalization ## Features -- UI Tool Window with Table- and Tree-View representation +- UI Tool Window which supports tree- or table-view - Easily Add / Edit / Delete translations -- Filter / Search function to hide irrelevant keys -- Key completion and annotation inside editor -- Configurable locales directory & preferred locale for ui presentation -- Supports modularized (splitted) json files -- Translation keys with missing definition for any locale will be displayed red -- Quick edit any translation by right-click (IntelliJ Popup Action) -- Quick delete any translation via DEL-Key +- Filter function with full-text-search support +- Editor Assistance: Key completion, annotation and referencing +- Key sorting and nesting can be configured +- Configurable locales directory & preferred locale for ui presentation +- Missing language translations will be indicated red +- Quick actions: right-click or DEL to edit or delete a translation +- Automatically reloads translation data if any locale file was changed ## Screenshots -![Tree View](https://raw.githubusercontent.com/marhali/easy-i18n/main/example/images/TreeView.PNG "Tree View") -![Table View](https://raw.githubusercontent.com/marhali/easy-i18n/main/example/images/TableView.PNG "Table View") -![Key Completion](https://raw.githubusercontent.com/marhali/easy-i18n/main/example/images/Completion.PNG "Key Completion") +![Tree View](https://raw.githubusercontent.com/marhali/easy-i18n/main/example/images/tree-view.PNG) +![TableView](https://raw.githubusercontent.com/marhali/easy-i18n/main/example/images/table-view.PNG) +![KeyCompletion](https://raw.githubusercontent.com/marhali/easy-i18n/main/example/images/key-completion.PNG) +![KeyAnnotation](https://raw.githubusercontent.com/marhali/easy-i18n/main/example/images/key-annotation.PNG) +![KeyEdit](https://raw.githubusercontent.com/marhali/easy-i18n/main/example/images/key-edit.PNG) +![Settings](https://raw.githubusercontent.com/marhali/easy-i18n/main/example/images/settings.PNG) + +## Supported IO Strategies (locale files) +- Json: json files inside locales directory +- Namespaced Json: Multiple json files per locale directory +- Yaml: yml or yaml files inside locales directory +- Properties: properties files inside locales directory + +If there are any files in the locales folder that should not be processed, they can be ignored with the Translation file pattern option. ## Installation - Using IDE built-in plugin system: @@ -45,8 +56,8 @@ Most common use case is for translating Webapps or simple Java Applications. Tra - Install plugin. See **Installation** section - Create a directory which will hold the locale files - Create a file for each required locale (e.g de.json, en.json) Note: Each json file must at least define an empty section (e.g. **{}**) -- Click on the **Settings** Action inside the Easy I18n Tool Window -- Select the created directory (optional: define the preferred locale to view) and press Ok +- Click on the **Settings** Action inside the EasyI18n Tool Window +- Select the created directory (optional: define the preferred locale to view) and press **Ok** - Translations can now be created / edited or deleted Examples for the configuration can be found in the [/example](https://github.com/marhali/easy-i18n/tree/main/example) folder. diff --git a/build.gradle.kts b/build.gradle.kts index e817fcc..800cf61 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,3 @@ -import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.changelog.markdownToHTML import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -8,15 +7,13 @@ plugins { // Java support id("java") // Kotlin support - id("org.jetbrains.kotlin.jvm") version "1.5.10" - // gradle-intellij-plugin - read more: https://github.com/JetBrains/gradle-intellij-plugin - id("org.jetbrains.intellij") version "1.0" - // gradle-changelog-plugin - read more: https://github.com/JetBrains/gradle-changelog-plugin - id("org.jetbrains.changelog") version "1.1.2" - // detekt linter - read more: https://detekt.github.io/detekt/gradle.html - id("io.gitlab.arturbosch.detekt") version "1.17.1" - // ktlint linter - read more: https://github.com/JLLeitschuh/ktlint-gradle - id("org.jlleitschuh.gradle.ktlint") version "10.0.0" + id("org.jetbrains.kotlin.jvm") version "1.5.31" + // Gradle IntelliJ Plugin + id("org.jetbrains.intellij") version "1.2.1" + // Gradle Changelog Plugin + id("org.jetbrains.changelog") version "1.3.1" + // Gradle Qodana Plugin + id("org.jetbrains.qodana") version "0.1.13" } group = properties("pluginGroup") @@ -26,55 +23,45 @@ version = properties("pluginVersion") repositories { mavenCentral() } -dependencies { - detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.17.1") -} -// Configure gradle-intellij-plugin plugin. -// Read more: https://github.com/JetBrains/gradle-intellij-plugin +// Configure Gradle IntelliJ Plugin - read more: https://github.com/JetBrains/gradle-intellij-plugin intellij { pluginName.set(properties("pluginName")) version.set(properties("platformVersion")) type.set(properties("platformType")) - downloadSources.set(properties("platformDownloadSources").toBoolean()) - updateSinceUntilBuild.set(true) // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file. plugins.set(properties("platformPlugins").split(',').map(String::trim).filter(String::isNotEmpty)) } -// Configure gradle-changelog-plugin plugin. -// Read more: https://github.com/JetBrains/gradle-changelog-plugin +// Configure Gradle Changelog Plugin - read more: https://github.com/JetBrains/gradle-changelog-plugin changelog { - version = properties("pluginVersion") - groups = emptyList() + version.set(properties("pluginVersion")) + groups.set(emptyList()) } -// Configure detekt plugin. -// Read more: https://detekt.github.io/detekt/kotlindsl.html -detekt { - config = files("./detekt-config.yml") - buildUponDefaultConfig = true - - reports { - html.enabled = false - xml.enabled = false - txt.enabled = false - } +// Configure Gradle Qodana Plugin - read more: https://github.com/JetBrains/gradle-qodana-plugin +qodana { + cachePath.set(projectDir.resolve(".qodana").canonicalPath) + reportPath.set(projectDir.resolve("build/reports/inspections").canonicalPath) + saveReport.set(true) + showReport.set(System.getenv("QODANA_SHOW_REPORT")?.toBoolean() ?: false) } tasks { - // Set the compatibility versions to 1.8 - withType { - sourceCompatibility = "1.8" - targetCompatibility = "1.8" - } - withType { - kotlinOptions.jvmTarget = "1.8" + // Set the JVM compatibility versions + properties("javaVersion").let { + withType { + sourceCompatibility = it + targetCompatibility = it + } + withType { + kotlinOptions.jvmTarget = it + } } - withType { - jvmTarget = "1.8" + wrapper { + gradleVersion = properties("gradleVersion") } patchPluginXml { @@ -84,7 +71,7 @@ tasks { // Extract the section from README.md and provide for the plugin's manifest pluginDescription.set( - File(projectDir, "README.md").readText().lines().run { + projectDir.resolve("README.md").readText().lines().run { val start = "" val end = "" @@ -96,11 +83,26 @@ tasks { ) // Get the latest available change notes from the changelog file - changeNotes.set(provider { changelog.getLatest().toHTML() }) + changeNotes.set(provider { + changelog.run { + getOrNull(properties("pluginVersion")) ?: getLatest() + }.toHTML() + }) } - runPluginVerifier { - ideVersions.set(properties("pluginVerifierIdeVersions").split(',').map(String::trim).filter(String::isNotEmpty)) + // Configure UI tests plugin + // Read more: https://github.com/JetBrains/intellij-ui-test-robot + runIdeForUiTests { + systemProperty("robot-server.port", "8082") + systemProperty("ide.mac.message.dialogs.as.sheets", "false") + systemProperty("jb.privacy.policy.text", "") + systemProperty("jb.consents.confirmation.enabled", "false") + } + + signPlugin { + certificateChain.set(System.getenv("CERTIFICATE_CHAIN")) + privateKey.set(System.getenv("PRIVATE_KEY")) + password.set(System.getenv("PRIVATE_KEY_PASSWORD")) } publishPlugin { @@ -111,4 +113,4 @@ tasks { // https://plugins.jetbrains.com/docs/intellij/deployment.html#specifying-a-release-channel channels.set(listOf(properties("pluginVersion").split('-').getOrElse(1) { "default" }.split('.').first())) } -} +} \ No newline at end of file diff --git a/detekt-config.yml b/detekt-config.yml deleted file mode 100644 index f9b8d75..0000000 --- a/detekt-config.yml +++ /dev/null @@ -1,8 +0,0 @@ -# Default detekt configuration: -# https://github.com/detekt/detekt/blob/master/detekt-core/src/main/resources/default-detekt-config.yml - -formatting: - Indentation: - continuationIndentSize: 8 - ParameterListWrapping: - indentSize: 8 diff --git a/example/images/Completion.PNG b/example/images/Completion.PNG deleted file mode 100644 index 239fd68..0000000 Binary files a/example/images/Completion.PNG and /dev/null differ diff --git a/example/images/TableView.PNG b/example/images/TableView.PNG deleted file mode 100644 index 878dfce..0000000 Binary files a/example/images/TableView.PNG and /dev/null differ diff --git a/example/images/TreeView.PNG b/example/images/TreeView.PNG deleted file mode 100644 index f0a7247..0000000 Binary files a/example/images/TreeView.PNG and /dev/null differ diff --git a/example/images/key-annotation.PNG b/example/images/key-annotation.PNG new file mode 100644 index 0000000..df0ca9a Binary files /dev/null and b/example/images/key-annotation.PNG differ diff --git a/example/images/key-completion.PNG b/example/images/key-completion.PNG new file mode 100644 index 0000000..3b1bd01 Binary files /dev/null and b/example/images/key-completion.PNG differ diff --git a/example/images/key-edit.PNG b/example/images/key-edit.PNG new file mode 100644 index 0000000..d6ab6ef Binary files /dev/null and b/example/images/key-edit.PNG differ diff --git a/example/images/settings.PNG b/example/images/settings.PNG new file mode 100644 index 0000000..29cf385 Binary files /dev/null and b/example/images/settings.PNG differ diff --git a/example/images/table-view.PNG b/example/images/table-view.PNG new file mode 100644 index 0000000..fc02213 Binary files /dev/null and b/example/images/table-view.PNG differ diff --git a/example/images/tree-view.PNG b/example/images/tree-view.PNG new file mode 100644 index 0000000..7bf34da Binary files /dev/null and b/example/images/tree-view.PNG differ diff --git a/gradle.properties b/gradle.properties index c31f92e..5ddf786 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,24 +3,29 @@ pluginGroup = de.marhali.easyi18n pluginName = easy-i18n -pluginVersion = 1.5.1 +# SemVer format -> https://semver.org +pluginVersion = 1.6.0 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. -pluginSinceBuild = 202 -pluginUntilBuild = 212.* - -# Plugin Verifier integration -> https://github.com/JetBrains/gradle-intellij-plugin#plugin-verifier-dsl -# See https://jb.gg/intellij-platform-builds-list for available build versions -pluginVerifierIdeVersions = 2020.2.4, 2020.3.4, 2021.2 +pluginSinceBuild = 203 +pluginUntilBuild = 213.* +# IntelliJ Platform Properties -> https://github.com/JetBrains/gradle-intellij-plugin#intellij-platform-properties platformType = IC -platformVersion = 2021.2 -platformDownloadSources = true +platformVersion = 2020.3.4 + # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html # Example: platformPlugins = com.intellij.java, com.jetbrains.php:203.4449.22 platformPlugins = org.jetbrains.kotlin +# Java language level used to compile sources and to generate the files for - Java 11 is required since 2020.3 +javaVersion = 11 + +# Gradle Releases -> https://github.com/gradle/gradle/releases +gradleVersion = 7.3 + # Opt-out flag for bundling Kotlin standard library. -# See https://kotlinlang.org/docs/reference/using-gradle.html#dependency-on-the-standard-library for details. +# See https://plugins.jetbrains.com/docs/intellij/kotlin.html#kotlin-standard-library for details. +# suppress inspection "UnusedProperty" kotlin.stdlib.default.dependency = false \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c..7454180 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 442d913..e750102 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0..744e882 100755 --- a/gradlew +++ b/gradlew @@ -72,7 +72,7 @@ case "`uname`" in Darwin* ) darwin=true ;; - MINGW* ) + MSYS* | MINGW* ) msys=true ;; NONSTOP* ) diff --git a/qodana.yml b/qodana.yml new file mode 100644 index 0000000..8b73731 --- /dev/null +++ b/qodana.yml @@ -0,0 +1,6 @@ +# Qodana configuration: +# https://www.jetbrains.com/help/qodana/qodana-yaml.html + +version: 1.0 +profile: + name: qodana.recommended \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/DataBus.java b/src/main/java/de/marhali/easyi18n/DataBus.java new file mode 100644 index 0000000..101a8fd --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/DataBus.java @@ -0,0 +1,55 @@ +package de.marhali.easyi18n; + +import de.marhali.easyi18n.model.bus.BusListener; +import de.marhali.easyi18n.model.TranslationData; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashSet; +import java.util.Set; + +/** + * Data-bus which is used to distribute changes regarding translations or ui tools to the participating components. + * @author marhali + */ +public class DataBus { + + private final Set listener; + + protected DataBus() { + this.listener = new HashSet<>(); + } + + /** + * Adds a participant to the event bus. Every participant needs to be added manually. + * @param listener Bus listener + */ + public void addListener(BusListener listener) { + this.listener.add(listener); + } + + /** + * Fires the called events on the returned prototype. + * The event will be distributed to all participants which were registered at execution time. + * @return Listener prototype + */ + public BusListener propagate() { + return new BusListener() { + @Override + public void onUpdateData(@NotNull TranslationData data) { + listener.forEach(li -> li.onUpdateData(data)); + } + + @Override + public void onFocusKey(@Nullable String key) { + listener.forEach(li -> li.onFocusKey(key)); + } + + @Override + public void onSearchQuery(@Nullable String query) { + listener.forEach(li -> li.onSearchQuery(query)); + } + }; + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/DataStore.java b/src/main/java/de/marhali/easyi18n/DataStore.java new file mode 100644 index 0000000..d8fcd83 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/DataStore.java @@ -0,0 +1,118 @@ +package de.marhali.easyi18n; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Disposer; +import com.intellij.openapi.vfs.*; + +import de.marhali.easyi18n.io.IOStrategy; +import de.marhali.easyi18n.io.json.JsonIOStrategy; +import de.marhali.easyi18n.io.json.ModularizedJsonIOStrategy; +import de.marhali.easyi18n.io.properties.PropertiesIOStrategy; +import de.marhali.easyi18n.io.yaml.YamlIOStrategy; +import de.marhali.easyi18n.model.SettingsState; +import de.marhali.easyi18n.model.TranslationData; +import de.marhali.easyi18n.service.FileChangeListener; +import de.marhali.easyi18n.service.SettingsService; + +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Responsible for loading, saving and updating translation files. + * Provides access to the cached translation data which is used in the whole project. + * @author marhali + */ +public class DataStore { + + private static final Set STRATEGIES = new LinkedHashSet<>(Arrays.asList( + new JsonIOStrategy(), new ModularizedJsonIOStrategy(), + new YamlIOStrategy("yaml"), new YamlIOStrategy("yml"), + new PropertiesIOStrategy() + )); + + private final @NotNull Project project; + private final @NotNull FileChangeListener changeListener; + + private @NotNull TranslationData data; + + protected DataStore(@NotNull Project project) { + this.project = project; + this.data = new TranslationData(true, true); // Initialize with hard-coded configuration + this.changeListener = new FileChangeListener(project); + + VirtualFileManager.getInstance().addAsyncFileListener( + this.changeListener, Disposer.newDisposable("EasyI18n")); + } + + public @NotNull TranslationData getData() { + return data; + } + + /** + * Loads the translation data into cache and overwrites any previous cached data. + * If the configuration does not fit an empty translation instance will be populated. + * @param successResult Consumer will inform if operation was successful + */ + public void loadFromPersistenceLayer(@NotNull Consumer successResult) { + SettingsState state = SettingsService.getInstance(this.project).getState(); + String localesPath = state.getLocalesPath(); + + if(localesPath == null || localesPath.isEmpty()) { // Populate empty instance + this.data = new TranslationData(state.isSortKeys(), state.isNestedKeys()); + return; + } + + this.changeListener.updateLocalesPath(localesPath); + + IOStrategy strategy = this.determineStrategy(state, localesPath); + + strategy.read(this.project, localesPath, state, (data) -> { + this.data = data == null + ? new TranslationData(state.isSortKeys(), state.isNestedKeys()) + : data; + + successResult.accept(data != null); + }); + } + + /** + * Saves the cached translation data to the underlying io system. + * @param successResult Consumer will inform if operation was successful + */ + public void saveToPersistenceLayer(@NotNull Consumer successResult) { + SettingsState state = SettingsService.getInstance(this.project).getState(); + String localesPath = state.getLocalesPath(); + + if(localesPath == null || localesPath.isEmpty()) { // Cannot save without valid path + successResult.accept(false); + return; + } + + IOStrategy strategy = this.determineStrategy(state, localesPath); + + strategy.write(this.project, localesPath, state, this.data, successResult); + } + + /** + * Chooses the right strategy for the opened project. An exception might be thrown on + * runtime if the project configuration (e.g. locale files does not fit in any strategy). + * @param state Plugin configuration + * @param localesPath Locales directory + * @return matching {@link IOStrategy} + */ + public @NotNull IOStrategy determineStrategy(@NotNull SettingsState state, @NotNull String localesPath) { + for(IOStrategy strategy : STRATEGIES) { + if(strategy.canUse(this.project, localesPath, state)) { + return strategy; + } + } + + throw new IllegalArgumentException("Could not determine i18n strategy. " + + "At least one locale file must be defined. " + + "For examples please visit https://github.com/marhali/easy-i18n"); + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/InstanceManager.java b/src/main/java/de/marhali/easyi18n/InstanceManager.java new file mode 100644 index 0000000..129f1be --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/InstanceManager.java @@ -0,0 +1,76 @@ +package de.marhali.easyi18n; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; + +import de.marhali.easyi18n.model.TranslationUpdate; + +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Central singleton component for managing an easy-i18n instance for a specific project. + * @author marhali + */ +public class InstanceManager { + + private static final Map INSTANCES = new WeakHashMap<>(); + + private final DataStore store; + private final DataBus bus; + + public static InstanceManager get(@NotNull Project project) { + InstanceManager instance = INSTANCES.get(project); + + if(instance == null){ + instance = new InstanceManager(project); + INSTANCES.put(project, instance); + } + + return instance; + } + + private InstanceManager(@NotNull Project project) { + this.store = new DataStore(project); + this.bus = new DataBus(); + + // Load data after first initialization + ApplicationManager.getApplication().invokeLater(() -> { + this.store.loadFromPersistenceLayer((success) -> { + this.bus.propagate().onUpdateData(this.store.getData()); + }); + }); + } + + public DataStore store() { + return this.store; + } + + public DataBus bus() { + return this.bus; + } + + public void processUpdate(TranslationUpdate update) { + if(update.isDeletion() || update.isKeyChange()) { // Remove origin translation + this.store.getData().setTranslation(update.getOrigin().getKey(), null); + } + + if(!update.isDeletion()) { // Create or re-create translation with changed data + this.store.getData().setTranslation(update.getChange().getKey(), update.getChange().getTranslation()); + } + + this.store.saveToPersistenceLayer(success -> { + if(success) { + this.bus.propagate().onUpdateData(this.store.getData()); + + if(!update.isDeletion()) { + this.bus.propagate().onFocusKey(update.getChange().getKey()); + } else { + this.bus.propagate().onFocusKey(update.getOrigin().getKey()); + } + } + }); + } +} diff --git a/src/main/java/de/marhali/easyi18n/action/AddAction.java b/src/main/java/de/marhali/easyi18n/action/AddAction.java index 20f9dba..f2c2455 100644 --- a/src/main/java/de/marhali/easyi18n/action/AddAction.java +++ b/src/main/java/de/marhali/easyi18n/action/AddAction.java @@ -6,6 +6,7 @@ import com.intellij.openapi.actionSystem.AnActionEvent; import de.marhali.easyi18n.service.WindowManager; import de.marhali.easyi18n.dialog.AddDialog; +import de.marhali.easyi18n.util.PathUtil; import de.marhali.easyi18n.util.TreeUtil; import org.jetbrains.annotations.NotNull; @@ -42,7 +43,7 @@ public class AddAction extends AnAction { TreePath path = manager.getTreeView().getTree().getSelectionPath(); if(path != null) { - return TreeUtil.getFullPath(path) + "."; + return TreeUtil.getFullPath(path) + PathUtil.DELIMITER; } } else { // Table View diff --git a/src/main/java/de/marhali/easyi18n/action/ReloadAction.java b/src/main/java/de/marhali/easyi18n/action/ReloadAction.java index 5930963..323ba44 100644 --- a/src/main/java/de/marhali/easyi18n/action/ReloadAction.java +++ b/src/main/java/de/marhali/easyi18n/action/ReloadAction.java @@ -4,7 +4,7 @@ import com.intellij.icons.AllIcons; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.actionSystem.AnActionEvent; -import de.marhali.easyi18n.service.DataStore; +import de.marhali.easyi18n.InstanceManager; import org.jetbrains.annotations.NotNull; @@ -23,6 +23,9 @@ public class ReloadAction extends AnAction { @Override public void actionPerformed(@NotNull AnActionEvent e) { - DataStore.getInstance(e.getProject()).reloadFromDisk(); + InstanceManager manager = InstanceManager.get(e.getProject()); + manager.store().loadFromPersistenceLayer((success) -> { + manager.bus().propagate().onUpdateData(manager.store().getData()); + }); } } \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/dialog/AddDialog.java b/src/main/java/de/marhali/easyi18n/dialog/AddDialog.java index 8a50820..7702fba 100644 --- a/src/main/java/de/marhali/easyi18n/dialog/AddDialog.java +++ b/src/main/java/de/marhali/easyi18n/dialog/AddDialog.java @@ -7,8 +7,9 @@ import com.intellij.ui.components.JBLabel; import com.intellij.ui.components.JBScrollPane; import com.intellij.ui.components.JBTextField; -import de.marhali.easyi18n.service.DataStore; +import de.marhali.easyi18n.InstanceManager; import de.marhali.easyi18n.model.KeyedTranslation; +import de.marhali.easyi18n.model.Translation; import de.marhali.easyi18n.model.TranslationCreate; import javax.swing.*; @@ -48,16 +49,16 @@ public class AddDialog { } private void saveTranslation() { - Map messages = new HashMap<>(); + Translation translation = new Translation(); valueTextFields.forEach((k, v) -> { if(!v.getText().isEmpty()) { - messages.put(k, v.getText()); + translation.put(k, v.getText()); } }); - TranslationCreate creation = new TranslationCreate(new KeyedTranslation(keyTextField.getText(), messages)); - DataStore.getInstance(project).processUpdate(creation); + TranslationCreate creation = new TranslationCreate(new KeyedTranslation(keyTextField.getText(), translation)); + InstanceManager.get(project).processUpdate(creation); } private DialogBuilder prepare() { @@ -75,7 +76,8 @@ public class AddDialog { JPanel valuePanel = new JPanel(new GridLayout(0, 1, 2, 2)); valueTextFields = new HashMap<>(); - for(String locale : DataStore.getInstance(project).getTranslations().getLocales()) { + + for(String locale : InstanceManager.get(project).store().getData().getLocales()) { JBLabel localeLabel = new JBLabel(locale); JBTextField localeText = new JBTextField(); localeLabel.setLabelFor(localeText); diff --git a/src/main/java/de/marhali/easyi18n/dialog/EditDialog.java b/src/main/java/de/marhali/easyi18n/dialog/EditDialog.java index 09f3e21..7e788bf 100644 --- a/src/main/java/de/marhali/easyi18n/dialog/EditDialog.java +++ b/src/main/java/de/marhali/easyi18n/dialog/EditDialog.java @@ -6,11 +6,12 @@ import com.intellij.openapi.ui.DialogWrapper; import com.intellij.ui.components.JBLabel; import com.intellij.ui.components.JBScrollPane; import com.intellij.ui.components.JBTextField; -import de.marhali.easyi18n.service.DataStore; +import de.marhali.easyi18n.InstanceManager; import de.marhali.easyi18n.model.KeyedTranslation; +import de.marhali.easyi18n.model.Translation; import de.marhali.easyi18n.model.TranslationDelete; -import de.marhali.easyi18n.model.TranslationUpdate; import de.marhali.easyi18n.dialog.descriptor.DeleteActionDescriptor; +import de.marhali.easyi18n.model.TranslationUpdate; import javax.swing.*; import javax.swing.border.EtchedBorder; @@ -40,23 +41,22 @@ public class EditDialog { int code = prepare().show(); if(code == DialogWrapper.OK_EXIT_CODE) { // Edit - DataStore.getInstance(project).processUpdate(new TranslationUpdate(origin, getChanges())); - + InstanceManager.get(project).processUpdate(new TranslationUpdate(origin, getChanges())); } else if(code == DeleteActionDescriptor.EXIT_CODE) { // Delete - DataStore.getInstance(project).processUpdate(new TranslationDelete(origin)); + InstanceManager.get(project).processUpdate(new TranslationDelete(origin)); } } private KeyedTranslation getChanges() { - Map messages = new HashMap<>(); + Translation translation = new Translation(); valueTextFields.forEach((k, v) -> { if(!v.getText().isEmpty()) { - messages.put(k, v.getText()); + translation.put(k, v.getText()); } }); - return new KeyedTranslation(keyTextField.getText(), messages); + return new KeyedTranslation(keyTextField.getText(), translation); } private DialogBuilder prepare() { @@ -74,9 +74,10 @@ public class EditDialog { JPanel valuePanel = new JPanel(new GridLayout(0, 1, 2, 2)); valueTextFields = new HashMap<>(); - for(String locale : DataStore.getInstance(project).getTranslations().getLocales()) { + + for(String locale : InstanceManager.get(project).store().getData().getLocales()) { JBLabel localeLabel = new JBLabel(locale); - JBTextField localeText = new JBTextField(this.origin.getTranslations().get(locale)); + JBTextField localeText = new JBTextField(this.origin.getTranslation().get(locale)); localeLabel.setLabelFor(localeText); valuePanel.add(localeLabel); diff --git a/src/main/java/de/marhali/easyi18n/dialog/SettingsDialog.java b/src/main/java/de/marhali/easyi18n/dialog/SettingsDialog.java index b774b3d..e4e4c3c 100644 --- a/src/main/java/de/marhali/easyi18n/dialog/SettingsDialog.java +++ b/src/main/java/de/marhali/easyi18n/dialog/SettingsDialog.java @@ -9,8 +9,9 @@ import com.intellij.ui.components.JBCheckBox; import com.intellij.ui.components.JBLabel; import com.intellij.ui.components.JBTextField; +import de.marhali.easyi18n.InstanceManager; +import de.marhali.easyi18n.model.SettingsState; import de.marhali.easyi18n.service.SettingsService; -import de.marhali.easyi18n.service.DataStore; import javax.swing.*; import java.awt.*; @@ -28,6 +29,8 @@ public class SettingsDialog { private JBTextField filePatternText; private JBTextField previewLocaleText; private JBTextField pathPrefixText; + private JBCheckBox sortKeysCheckbox; + private JBCheckBox nestedKeysCheckbox; private JBCheckBox codeAssistanceCheckbox; public SettingsDialog(Project project) { @@ -35,30 +38,31 @@ public class SettingsDialog { } public void showAndHandle() { - String localesPath = SettingsService.getInstance(project).getState().getLocalesPath(); - String filePattern = SettingsService.getInstance(project).getState().getFilePattern(); - String previewLocale = SettingsService.getInstance(project).getState().getPreviewLocale(); - String pathPrefix = SettingsService.getInstance(project).getState().getPathPrefix(); - boolean codeAssistance = SettingsService.getInstance(project).getState().isCodeAssistance(); + SettingsState state = SettingsService.getInstance(project).getState(); - if(prepare(localesPath, filePattern, previewLocale, pathPrefix, codeAssistance).show() == DialogWrapper.OK_EXIT_CODE) { // Save changes - SettingsService.getInstance(project).getState().setLocalesPath(pathText.getText()); - SettingsService.getInstance(project).getState().setFilePattern(filePatternText.getText()); - SettingsService.getInstance(project).getState().setPreviewLocale(previewLocaleText.getText()); - SettingsService.getInstance(project).getState().setCodeAssistance(codeAssistanceCheckbox.isSelected()); - SettingsService.getInstance(project).getState().setPathPrefix(pathPrefixText.getText()); + if(prepare(state).show() == DialogWrapper.OK_EXIT_CODE) { // Save changes + state.setLocalesPath(pathText.getText()); + state.setFilePattern(filePatternText.getText()); + state.setPreviewLocale(previewLocaleText.getText()); + state.setPathPrefix(pathPrefixText.getText()); + state.setSortKeys(sortKeysCheckbox.isSelected()); + state.setNestedKeys(nestedKeysCheckbox.isSelected()); + state.setCodeAssistance(codeAssistanceCheckbox.isSelected()); // Reload instance - DataStore.getInstance(project).reloadFromDisk(); + InstanceManager manager = InstanceManager.get(project); + manager.store().loadFromPersistenceLayer((success) -> { + manager.bus().propagate().onUpdateData(manager.store().getData()); + }); } } - private DialogBuilder prepare(String localesPath, String filePattern, String previewLocale, String pathPrefix, boolean codeAssistance) { + private DialogBuilder prepare(SettingsState state) { JPanel rootPanel = new JPanel(new GridLayout(0, 1, 2, 2)); /* path */ JBLabel pathLabel = new JBLabel(ResourceBundle.getBundle("messages").getString("settings.path.text")); - pathText = new TextFieldWithBrowseButton(new JTextField(localesPath)); + pathText = new TextFieldWithBrowseButton(new JTextField(state.getLocalesPath())); pathLabel.setLabelFor(pathText); pathText.addBrowseFolderListener(ResourceBundle.getBundle("messages").getString("settings.path.title"), null, project, new FileChooserDescriptor( @@ -69,14 +73,14 @@ public class SettingsDialog { /* file pattern */ JBLabel filePatternLabel = new JBLabel(ResourceBundle.getBundle("messages").getString("settings.path.file-pattern")); - filePatternText = new JBTextField(filePattern); + filePatternText = new JBTextField(state.getFilePattern()); rootPanel.add(filePatternLabel); rootPanel.add(filePatternText); /* preview locale */ JBLabel previewLocaleLabel = new JBLabel(ResourceBundle.getBundle("messages").getString("settings.preview")); - previewLocaleText = new JBTextField(previewLocale); + previewLocaleText = new JBTextField(state.getPreviewLocale()); previewLocaleLabel.setLabelFor(previewLocaleText); rootPanel.add(previewLocaleLabel); @@ -84,14 +88,26 @@ public class SettingsDialog { /* path prefix */ JBLabel pathPrefixLabel = new JBLabel(ResourceBundle.getBundle("messages").getString("settings.path.prefix")); - pathPrefixText = new JBTextField(pathPrefix); + pathPrefixText = new JBTextField(state.getPathPrefix()); rootPanel.add(pathPrefixLabel); rootPanel.add(pathPrefixText); + /* sort keys */ + sortKeysCheckbox = new JBCheckBox(ResourceBundle.getBundle("messages").getString("settings.keys.sort")); + sortKeysCheckbox.setSelected(state.isSortKeys()); + + rootPanel.add(sortKeysCheckbox); + + /* nested keys */ + nestedKeysCheckbox = new JBCheckBox(ResourceBundle.getBundle("messages").getString("settings.keys.nested")); + nestedKeysCheckbox.setSelected(state.isNestedKeys()); + + rootPanel.add(nestedKeysCheckbox); + /* code assistance */ codeAssistanceCheckbox = new JBCheckBox(ResourceBundle.getBundle("messages").getString("settings.editor.assistance")); - codeAssistanceCheckbox.setSelected(codeAssistance); + codeAssistanceCheckbox.setSelected(state.isCodeAssistance()); rootPanel.add(codeAssistanceCheckbox); diff --git a/src/main/java/de/marhali/easyi18n/editor/KeyAnnotator.java b/src/main/java/de/marhali/easyi18n/editor/KeyAnnotator.java index baad8bb..6bb9fe7 100644 --- a/src/main/java/de/marhali/easyi18n/editor/KeyAnnotator.java +++ b/src/main/java/de/marhali/easyi18n/editor/KeyAnnotator.java @@ -4,8 +4,8 @@ import com.intellij.lang.annotation.AnnotationHolder; import com.intellij.lang.annotation.HighlightSeverity; import com.intellij.openapi.project.Project; -import de.marhali.easyi18n.model.LocalizedNode; -import de.marhali.easyi18n.service.DataStore; +import de.marhali.easyi18n.InstanceManager; +import de.marhali.easyi18n.model.TranslationNode; import de.marhali.easyi18n.service.SettingsService; import org.jetbrains.annotations.NotNull; @@ -39,7 +39,7 @@ public class KeyAnnotator { searchKey = searchKey.substring(1); } - LocalizedNode node = DataStore.getInstance(project).getTranslations().getNode(searchKey); + TranslationNode node = InstanceManager.get(project).store().getData().getNode(searchKey); if(node == null) { // Unknown translation. Just ignore it return; diff --git a/src/main/java/de/marhali/easyi18n/editor/KeyCompletionProvider.java b/src/main/java/de/marhali/easyi18n/editor/KeyCompletionProvider.java index a3a10b3..d76b936 100644 --- a/src/main/java/de/marhali/easyi18n/editor/KeyCompletionProvider.java +++ b/src/main/java/de/marhali/easyi18n/editor/KeyCompletionProvider.java @@ -5,9 +5,11 @@ import com.intellij.codeInsight.lookup.*; import com.intellij.icons.AllIcons; import com.intellij.openapi.project.*; import com.intellij.util.*; -import de.marhali.easyi18n.model.*; +import de.marhali.easyi18n.DataStore; +import de.marhali.easyi18n.InstanceManager; +import de.marhali.easyi18n.model.Translation; import de.marhali.easyi18n.service.*; -import de.marhali.easyi18n.util.TranslationsUtil; +import de.marhali.easyi18n.util.PathUtil; import org.jetbrains.annotations.*; import java.util.*; @@ -29,7 +31,8 @@ public class KeyCompletionProvider extends CompletionProvider fullKeys = store.getTranslations().getFullKeys(); + Set fullKeys = store.getData().getFullKeys(); int sections = path.split("\\.").length; int maxSectionForwardLookup = 5; @@ -65,19 +68,20 @@ public class KeyCompletionProvider extends CompletionProvider sections + maxSectionForwardLookup) { // Key is too deep nested - String shrinkKey = TranslationsUtil.sectionsToFullPath(Arrays.asList( - Arrays.copyOf(keySections, sections + maxSectionForwardLookup))); + String shrinkKey = pathUtil.concat(Arrays.asList( + Arrays.copyOf(keySections, sections + maxSectionForwardLookup) + )); result.addElement(LookupElementBuilder.create(pathPrefix + shrinkKey) .appendTailText(" I18n([])", true)); } else { - LocalizedNode node = store.getTranslations().getNode(key); - String translation = node != null ? node.getValue().get(previewLocale) : null; + Translation translation = store.getData().getTranslation(key); + String content = translation.get(previewLocale); result.addElement(LookupElementBuilder.create(pathPrefix + key) .withIcon(AllIcons.Actions.PreserveCaseHover) - .appendTailText(" I18n(" + previewLocale + ": " + translation + ")", true) + .appendTailText(" I18n(" + previewLocale + ": " + content + ")", true) ); } } diff --git a/src/main/java/de/marhali/easyi18n/editor/KeyReference.java b/src/main/java/de/marhali/easyi18n/editor/KeyReference.java index 1a8ea02..56d5733 100644 --- a/src/main/java/de/marhali/easyi18n/editor/KeyReference.java +++ b/src/main/java/de/marhali/easyi18n/editor/KeyReference.java @@ -4,12 +4,12 @@ import com.intellij.openapi.util.TextRange; import com.intellij.psi.*; import com.intellij.psi.impl.FakePsiElement; +import de.marhali.easyi18n.InstanceManager; import de.marhali.easyi18n.dialog.AddDialog; import de.marhali.easyi18n.dialog.EditDialog; -import de.marhali.easyi18n.model.KeyedTranslation; -import de.marhali.easyi18n.model.LocalizedNode; -import de.marhali.easyi18n.service.DataStore; +import de.marhali.easyi18n.model.KeyedTranslation; +import de.marhali.easyi18n.model.Translation; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -52,10 +52,10 @@ public class KeyReference extends PsiReferenceBase { @Override public void navigate(boolean requestFocus) { - LocalizedNode node = DataStore.getInstance(getProject()).getTranslations().getNode(getKey()); + Translation translation = InstanceManager.get(getProject()).store().getData().getTranslation(getKey()); - if(node != null) { - new EditDialog(getProject(), new KeyedTranslation(getKey(), node.getValue())).showAndHandle(); + if(translation != null) { + new EditDialog(getProject(), new KeyedTranslation(getKey(), translation)).showAndHandle(); } else { new AddDialog(getProject(), getKey()).showAndHandle(); } diff --git a/src/main/java/de/marhali/easyi18n/editor/generic/GenericKeyReferenceContributor.java b/src/main/java/de/marhali/easyi18n/editor/generic/GenericKeyReferenceContributor.java index 960f6fb..a6b0542 100644 --- a/src/main/java/de/marhali/easyi18n/editor/generic/GenericKeyReferenceContributor.java +++ b/src/main/java/de/marhali/easyi18n/editor/generic/GenericKeyReferenceContributor.java @@ -4,8 +4,8 @@ import com.intellij.patterns.PlatformPatterns; import com.intellij.psi.*; import com.intellij.util.ProcessingContext; +import de.marhali.easyi18n.InstanceManager; import de.marhali.easyi18n.editor.KeyReference; -import de.marhali.easyi18n.service.DataStore; import de.marhali.easyi18n.service.SettingsService; import org.jetbrains.annotations.NotNull; @@ -38,7 +38,7 @@ public class GenericKeyReferenceContributor extends PsiReferenceContributor { return PsiReference.EMPTY_ARRAY; } - if(DataStore.getInstance(element.getProject()).getTranslations().getNode(value) == null) { + if(InstanceManager.get(element.getProject()).store().getData().getTranslation(value) == null) { if(!KeyReference.isReferencable(value)) { // Creation policy return PsiReference.EMPTY_ARRAY; } diff --git a/src/main/java/de/marhali/easyi18n/editor/kotlin/KotlinKeyReferenceContributor.java b/src/main/java/de/marhali/easyi18n/editor/kotlin/KotlinKeyReferenceContributor.java index e871fc9..77bfd20 100644 --- a/src/main/java/de/marhali/easyi18n/editor/kotlin/KotlinKeyReferenceContributor.java +++ b/src/main/java/de/marhali/easyi18n/editor/kotlin/KotlinKeyReferenceContributor.java @@ -5,8 +5,8 @@ import com.intellij.psi.*; import com.intellij.util.ProcessingContext; +import de.marhali.easyi18n.InstanceManager; import de.marhali.easyi18n.editor.KeyReference; -import de.marhali.easyi18n.service.DataStore; import de.marhali.easyi18n.service.SettingsService; import org.jetbrains.annotations.NotNull; @@ -45,7 +45,7 @@ public class KotlinKeyReferenceContributor extends PsiReferenceContributor { return PsiReference.EMPTY_ARRAY; } - if(DataStore.getInstance(element.getProject()).getTranslations().getNode(value) == null) { + if(InstanceManager.get(element.getProject()).store().getData().getNode(value) == null) { return PsiReference.EMPTY_ARRAY; } diff --git a/src/main/java/de/marhali/easyi18n/util/array/ArrayUtil.java b/src/main/java/de/marhali/easyi18n/io/ArrayMapper.java similarity index 77% rename from src/main/java/de/marhali/easyi18n/util/array/ArrayUtil.java rename to src/main/java/de/marhali/easyi18n/io/ArrayMapper.java index 2360e22..bebe612 100644 --- a/src/main/java/de/marhali/easyi18n/util/array/ArrayUtil.java +++ b/src/main/java/de/marhali/easyi18n/io/ArrayMapper.java @@ -1,6 +1,7 @@ -package de.marhali.easyi18n.util.array; +package de.marhali.easyi18n.io; import de.marhali.easyi18n.util.StringUtil; + import org.apache.commons.lang.StringEscapeUtils; import java.text.MessageFormat; @@ -10,11 +11,13 @@ import java.util.function.Function; import java.util.regex.Pattern; /** - * Utility methods for simple array support. + * Simple array support for translation values. + * Some i18n systems allows the user to define array values for some translations. + * We support array values by wrapping them into: '!arr[valueA;valueB]'. + * * @author marhali */ -public abstract class ArrayUtil { - +public abstract class ArrayMapper { static final String PREFIX = "!arr["; static final String SUFFIX = "]"; static final char DELIMITER = ';'; @@ -22,7 +25,7 @@ public abstract class ArrayUtil { static final String SPLITERATOR_REGEX = MessageFormat.format("(? String read(Iterator elements, Function stringFactory) { + protected static String read(Iterator elements, Function stringFactory) { StringBuilder builder = new StringBuilder(PREFIX); int i = 0; @@ -43,7 +46,7 @@ public abstract class ArrayUtil { return builder.toString(); } - static void write(String concat, Consumer writeElement) { + protected static void write(String concat, Consumer writeElement) { concat = concat.substring(PREFIX.length(), concat.length() - SUFFIX.length()); for(String element : concat.split(SPLITERATOR_REGEX)) { @@ -55,4 +58,4 @@ public abstract class ArrayUtil { public static boolean isArray(String concat) { return concat != null && concat.startsWith(PREFIX) && concat.endsWith(SUFFIX); } -} +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/io/IOStrategy.java b/src/main/java/de/marhali/easyi18n/io/IOStrategy.java new file mode 100644 index 0000000..7a9fdc9 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/IOStrategy.java @@ -0,0 +1,62 @@ +package de.marhali.easyi18n.io; + +import com.intellij.openapi.project.Project; + +import com.intellij.openapi.vfs.VirtualFile; +import de.marhali.easyi18n.model.SettingsState; +import de.marhali.easyi18n.model.TranslationData; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Consumer; + +/** + * Primary interface for the exchange of translation data with the underlying IO system. + * The selection of the right IO strategy is done by the @canUse method (first match). + * Every strategy needs to be registered inside {@link de.marhali.easyi18n.DataStore} + * + * @author marhali + */ +public interface IOStrategy { + /** + * Decides whether this strategy should be applied or not. First matching one will be used. + * @param project IntelliJ project context + * @param localesPath Root directory which leads to all i18n files + * @param state Plugin configuration + * @return true if strategy is responsible for the found structure + */ + boolean canUse(@NotNull Project project, @NotNull String localesPath, @NotNull SettingsState state); + + /** + * Loads the translation files and passes them in the result consumer. + * Result payload might be null if operation failed. + * @param project IntelliJ project context + * @param localesPath Root directory which leads to all i18n files + * @param state Plugin configuration + * @param result Passes loaded data + */ + void read(@NotNull Project project, @NotNull String localesPath, @NotNull SettingsState state, + @NotNull Consumer<@Nullable TranslationData> result); + + /** + * Writes the provided translation data to the IO system. + * @param project InteliJ project context + * @param localesPath Root directory which leads to all i18n files + * @param state Plugin configuration + * @param data Translations to save + * @param result Indicates whether the operation was successful + */ + void write(@NotNull Project project, @NotNull String localesPath, @NotNull SettingsState state, + @NotNull TranslationData data, @NotNull Consumer result); + + /** + * Checks if the provided file should be processed for translation data + * @param state Plugin configuration + * @param file File to check + * @return true if file matches pattern + */ + default boolean isFileRelevant(@NotNull SettingsState state, @NotNull VirtualFile file) { + return file.getName().matches(state.getFilePattern()); + } +} diff --git a/src/main/java/de/marhali/easyi18n/io/TranslatorIO.java b/src/main/java/de/marhali/easyi18n/io/TranslatorIO.java deleted file mode 100644 index 91a40d1..0000000 --- a/src/main/java/de/marhali/easyi18n/io/TranslatorIO.java +++ /dev/null @@ -1,35 +0,0 @@ -package de.marhali.easyi18n.io; - -import com.intellij.openapi.project.Project; - -import de.marhali.easyi18n.model.Translations; - -import org.jetbrains.annotations.NotNull; - -import java.util.function.Consumer; - -/** - * Interface to retrieve and save localized messages. - * Can be implemented by various standards. Such as JSON, Properties-Bundle and so on. - * @author marhali - */ -public interface TranslatorIO { - - /** - * Reads localized messages from the persistence layer. - * @param project Opened intellij project - * @param directoryPath The full path for the directory which holds all locale files - * @param callback Contains loaded translations. Will be called after io operation. Content might be null on failure. - */ - void read(@NotNull Project project, @NotNull String directoryPath, @NotNull Consumer callback); - - /** - * Writes the provided messages (translations) to the persistence layer. - * @param project Opened intellij project - * @param translations Translations instance to save - * @param directoryPath The full path for the directory which holds all locale files - * @param callback Will be called after io operation. Can be used to determine if action was successful(true) or not - */ - void save(@NotNull Project project, @NotNull Translations translations, - @NotNull String directoryPath, @NotNull Consumer callback); -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/io/implementation/JsonTranslatorIO.java b/src/main/java/de/marhali/easyi18n/io/implementation/JsonTranslatorIO.java deleted file mode 100644 index 1efbd0e..0000000 --- a/src/main/java/de/marhali/easyi18n/io/implementation/JsonTranslatorIO.java +++ /dev/null @@ -1,100 +0,0 @@ -package de.marhali.easyi18n.io.implementation; - -import com.google.gson.*; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.vfs.LocalFileSystem; -import com.intellij.openapi.vfs.VirtualFile; - -import de.marhali.easyi18n.io.TranslatorIO; -import de.marhali.easyi18n.model.LocalizedNode; -import de.marhali.easyi18n.model.Translations; -import de.marhali.easyi18n.util.IOUtil; -import de.marhali.easyi18n.util.JsonUtil; - -import org.jetbrains.annotations.NotNull; - -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.util.*; -import java.util.function.Consumer; - -/** - * Implementation for JSON translation files. - * @author marhali - */ -public class JsonTranslatorIO implements TranslatorIO { - - private static final String FILE_EXTENSION = "json"; - private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); - - @Override - public void read(@NotNull Project project, @NotNull String directoryPath, @NotNull Consumer callback) { - ApplicationManager.getApplication().saveAll(); // Save opened files (required if new locales were added) - - ApplicationManager.getApplication().runReadAction(() -> { - VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(directoryPath)); - - if(directory == null || directory.getChildren() == null) { - throw new IllegalArgumentException("Specified folder is invalid (" + directoryPath + ")"); - } - - VirtualFile[] files = directory.getChildren(); - - List locales = new ArrayList<>(); - LocalizedNode nodes = new LocalizedNode(LocalizedNode.ROOT_KEY, new ArrayList<>()); - - try { - for(VirtualFile file : files) { - - if(!IOUtil.isFileRelevant(project, file)) { // File does not matches pattern - continue; - } - - locales.add(file.getNameWithoutExtension()); - - JsonObject tree = GSON.fromJson(new InputStreamReader(file.getInputStream(), - file.getCharset()), JsonObject.class); - - JsonUtil.readTree(file.getNameWithoutExtension(), tree, nodes); - } - - callback.accept(new Translations(locales, nodes)); - - } catch(IOException e) { - e.printStackTrace(); - callback.accept(null); - } - }); - } - - @Override - public void save(@NotNull Project project, @NotNull Translations translations, - @NotNull String directoryPath, @NotNull Consumer callback) { - ApplicationManager.getApplication().runWriteAction(() -> { - try { - for(String locale : translations.getLocales()) { - JsonObject content = new JsonObject(); - JsonUtil.writeTree(locale, content, translations.getNodes()); - - String fullPath = directoryPath + "/" + locale + "." + FILE_EXTENSION; - File file = new File(fullPath); - boolean created = file.createNewFile(); - - VirtualFile vf = created ? LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) - : LocalFileSystem.getInstance().findFileByIoFile(file); - - vf.setBinaryContent(GSON.toJson(content).getBytes(vf.getCharset())); - } - - // Successfully saved - callback.accept(true); - - } catch(IOException e) { - e.printStackTrace(); - callback.accept(false); - } - }); - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/io/implementation/ModularizedJsonTranslatorIO.java b/src/main/java/de/marhali/easyi18n/io/implementation/ModularizedJsonTranslatorIO.java deleted file mode 100644 index 0cc70d9..0000000 --- a/src/main/java/de/marhali/easyi18n/io/implementation/ModularizedJsonTranslatorIO.java +++ /dev/null @@ -1,121 +0,0 @@ -package de.marhali.easyi18n.io.implementation; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.vfs.LocalFileSystem; -import com.intellij.openapi.vfs.VirtualFile; - -import de.marhali.easyi18n.io.TranslatorIO; -import de.marhali.easyi18n.model.LocalizedNode; -import de.marhali.easyi18n.model.Translations; -import de.marhali.easyi18n.util.IOUtil; -import de.marhali.easyi18n.util.JsonUtil; - -import org.jetbrains.annotations.NotNull; - -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.List; -import java.util.function.Consumer; - -/** - * IO operations for splitted / modularized json files. Each locale can have multiple translation files. - * @author marhali - */ -public class ModularizedJsonTranslatorIO implements TranslatorIO { - - private static final String FILE_EXTENSION = "json"; - - @Override - public void read(@NotNull Project project, @NotNull String directoryPath, @NotNull Consumer callback) { - ApplicationManager.getApplication().saveAll(); // Save opened files (required if new locales were added) - - ApplicationManager.getApplication().runReadAction(() -> { - VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(directoryPath)); - - if(directory == null || directory.getChildren() == null) { - throw new IllegalArgumentException("Specified folder is invalid (" + directoryPath + ")"); - } - - VirtualFile[] localeDirectories = directory.getChildren(); - - List locales = new ArrayList<>(); - LocalizedNode nodes = new LocalizedNode(LocalizedNode.ROOT_KEY, new ArrayList<>()); - - try { - for(VirtualFile localeDir : localeDirectories) { - String locale = localeDir.getName(); - locales.add(locale); - - // Read all json modules - for(VirtualFile module : localeDir.getChildren()) { - - if(!IOUtil.isFileRelevant(project, module)) { // File does not matches pattern - continue; - } - - JsonObject tree = JsonParser.parseReader(new InputStreamReader(module.getInputStream(), - module.getCharset())).getAsJsonObject(); - - String moduleName = module.getNameWithoutExtension(); - LocalizedNode moduleNode = nodes.getChildren(moduleName); - - if(moduleNode == null) { // Create module / sub node - moduleNode = new LocalizedNode(moduleName, new ArrayList<>()); - nodes.addChildren(moduleNode); - } - - JsonUtil.readTree(locale, tree, moduleNode); - } - } - - callback.accept(new Translations(locales, nodes)); - - } catch(IOException e) { - e.printStackTrace(); - callback.accept(null); - } - }); - } - - @Override - public void save(@NotNull Project project, @NotNull Translations translations, - @NotNull String directoryPath, @NotNull Consumer callback) { - - Gson gson = new GsonBuilder().setPrettyPrinting().create(); - - ApplicationManager.getApplication().runWriteAction(() -> { - try { - for(String locale : translations.getLocales()) { - // Use top level children as modules - for (LocalizedNode module : translations.getNodes().getChildren()) { - JsonObject content = new JsonObject(); - JsonUtil.writeTree(locale, content, module); - - String fullPath = directoryPath + "/" + locale + "/" + module.getKey() + "." + FILE_EXTENSION; - File file = new File(fullPath); - boolean created = file.createNewFile(); - - VirtualFile vf = created ? LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) - : LocalFileSystem.getInstance().findFileByIoFile(file); - - vf.setBinaryContent(gson.toJson(content).getBytes(vf.getCharset())); - } - } - - // Successfully saved - callback.accept(true); - - } catch(IOException e) { - e.printStackTrace(); - callback.accept(false); - } - }); - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/io/implementation/PropertiesTranslatorIO.java b/src/main/java/de/marhali/easyi18n/io/implementation/PropertiesTranslatorIO.java deleted file mode 100644 index 458c49a..0000000 --- a/src/main/java/de/marhali/easyi18n/io/implementation/PropertiesTranslatorIO.java +++ /dev/null @@ -1,136 +0,0 @@ -package de.marhali.easyi18n.io.implementation; - -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.vfs.LocalFileSystem; -import com.intellij.openapi.vfs.VirtualFile; - -import de.marhali.easyi18n.io.TranslatorIO; -import de.marhali.easyi18n.model.LocalizedNode; -import de.marhali.easyi18n.model.Translations; -import de.marhali.easyi18n.util.IOUtil; -import de.marhali.easyi18n.util.SortedProperties; -import de.marhali.easyi18n.util.StringUtil; -import de.marhali.easyi18n.util.TranslationsUtil; - -import org.apache.commons.lang.StringEscapeUtils; -import org.jetbrains.annotations.NotNull; - -import java.io.*; -import java.util.*; -import java.util.function.Consumer; - -/** - * Implementation for properties translation files. - * @author marhali - */ -public class PropertiesTranslatorIO implements TranslatorIO { - - public static final String FILE_EXTENSION = "properties"; - - @Override - public void read(@NotNull Project project, @NotNull String directoryPath, @NotNull Consumer callback) { - ApplicationManager.getApplication().saveAll(); // Save opened files (required if new locales were added) - - ApplicationManager.getApplication().runReadAction(() -> { - VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(directoryPath)); - - if(directory == null || directory.getChildren() == null) { - throw new IllegalArgumentException("Specified folder is invalid (" + directoryPath + ")"); - } - - VirtualFile[] files = directory.getChildren(); - - List locales = new ArrayList<>(); - LocalizedNode nodes = new LocalizedNode(LocalizedNode.ROOT_KEY, new ArrayList<>()); - - try { - for (VirtualFile file : files) { - - if(!IOUtil.isFileRelevant(project, file)) { // File does not matches pattern - continue; - } - - locales.add(file.getNameWithoutExtension()); - SortedProperties properties = new SortedProperties(); - properties.load(new InputStreamReader(file.getInputStream(), file.getCharset())); - readProperties(file.getNameWithoutExtension(), properties, nodes); - } - - callback.accept(new Translations(locales, nodes)); - - } catch(IOException e) { - e.printStackTrace(); - callback.accept(null); - } - }); - } - - @Override - public void save(@NotNull Project project, @NotNull Translations translations, - @NotNull String directoryPath, @NotNull Consumer callback) { - - ApplicationManager.getApplication().runWriteAction(() -> { - try { - for(String locale : translations.getLocales()) { - SortedProperties properties = new SortedProperties(); - writeProperties(locale, properties, translations.getNodes(), ""); - - String fullPath = directoryPath + "/" + locale + "." + FILE_EXTENSION; - VirtualFile file = LocalFileSystem.getInstance().findFileByIoFile(new File(fullPath)); - - StringWriter content = new StringWriter(); - properties.store(content, "I18n " + locale + " keys"); - - file.setBinaryContent(content.toString().getBytes(file.getCharset())); - } - - // Successfully saved - callback.accept(true); - - } catch(IOException e) { - e.printStackTrace(); - callback.accept(false); - } - }); - } - - private void writeProperties(String locale, Properties props, LocalizedNode node, String parentPath) { - if(node.isLeaf() && !node.getKey().equals(LocalizedNode.ROOT_KEY)) { - if(node.getValue().get(locale) != null) { // Translation is defined - track it - String value = StringEscapeUtils.unescapeJava(node.getValue().get(locale)); - props.setProperty(parentPath, value); - } - - } else { - for(LocalizedNode children : node.getChildren()) { - writeProperties(locale, props, children, - parentPath + (parentPath.isEmpty() ? "" : ".") + children.getKey()); - } - } - } - - private void readProperties(String locale, Properties props, LocalizedNode parent) { - props.forEach((key, value) -> { - List sections = TranslationsUtil.getSections(String.valueOf(key)); - - LocalizedNode node = parent; - - for (String section : sections) { - LocalizedNode subNode = node.getChildren(section); - - if(subNode == null) { - subNode = new LocalizedNode(section, new ArrayList<>()); - node.addChildren(subNode); - } - - node = subNode; - } - - Map messages = node.getValue(); - String escapedValue = StringUtil.escapeControls(String.valueOf(value), true); - messages.put(locale, escapedValue); - node.setValue(messages); - }); - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/io/implementation/YamlTranslatorIO.java b/src/main/java/de/marhali/easyi18n/io/implementation/YamlTranslatorIO.java deleted file mode 100644 index 4ed8e97..0000000 --- a/src/main/java/de/marhali/easyi18n/io/implementation/YamlTranslatorIO.java +++ /dev/null @@ -1,125 +0,0 @@ -package de.marhali.easyi18n.io.implementation; - -import com.intellij.openapi.application.*; -import com.intellij.openapi.project.*; -import com.intellij.openapi.vfs.*; - -import de.marhali.easyi18n.io.*; -import de.marhali.easyi18n.model.*; -import de.marhali.easyi18n.util.*; -import de.marhali.easyi18n.util.array.YamlArrayUtil; - -import org.jetbrains.annotations.*; - -import thito.nodeflow.config.*; - -import java.io.*; -import java.nio.charset.*; -import java.util.*; -import java.util.function.*; - -public class YamlTranslatorIO implements TranslatorIO { - @Override - public void read(@NotNull Project project, @NotNull String directoryPath, @NotNull Consumer callback) { - ApplicationManager.getApplication().saveAll(); // Save opened files (required if new locales were added) - - ApplicationManager.getApplication().runReadAction(() -> { - VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(directoryPath)); - - if(directory == null || directory.getChildren() == null) { - throw new IllegalArgumentException("Specified folder is invalid (" + directoryPath + ")"); - } - - VirtualFile[] files = directory.getChildren(); - - List locales = new ArrayList<>(); - LocalizedNode nodes = new LocalizedNode(LocalizedNode.ROOT_KEY, new ArrayList<>()); - - try { - for(VirtualFile file : files) { - - if(!IOUtil.isFileRelevant(project, file)) { // File does not matches pattern - continue; - } - - locales.add(file.getNameWithoutExtension()); - - try (Reader reader = new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8)) { - Section section = Section.parseToMap(reader); - load(file.getNameWithoutExtension(), nodes, section); - } - } - - callback.accept(new Translations(locales, nodes)); - - } catch(IOException e) { - e.printStackTrace(); - callback.accept(null); - } - }); - } - - private void load(String locale, LocalizedNode node, Section section) { - if (section instanceof MapSection) { - for (String key : section.getKeys()) { - LocalizedNode child = node.getChildren(key); - if (child == null) { - node.addChildren(child = new LocalizedNode(key, new ArrayList<>())); - } - LocalizedNode finalChild = child; - MapSection map = section.getMap(key).orElse(null); - if (map != null) { - load(locale, finalChild, map); - } else { - - if(section.isList(key) && section.getList(key).isPresent()) { - child.getValue().put(locale, YamlArrayUtil.read(section.getList(key).get())); - } else { - String value = section.getString(key).orElse(null); - if (value != null) { - child.getValue().put(locale, value); - } - } - } - } - } - } - - private void save(LocalizedNode node, String locale, Section section, String path) { - if (node.isLeaf() && !node.getKey().equals(LocalizedNode.ROOT_KEY)) { - String value = node.getValue().get(locale); - if (value != null) { - section.set(path, YamlArrayUtil.isArray(value) ? YamlArrayUtil.write(value) : value); - } - } else { - for (LocalizedNode child : node.getChildren()) { - save(child, locale, section, path == null ? child.getKey() : path + "." + child.getKey()); - } - } - } - - @Override - public void save(@NotNull Project project, @NotNull Translations translations, @NotNull String directoryPath, @NotNull Consumer callback) { - ApplicationManager.getApplication().runWriteAction(() -> { - try { - for(String locale : translations.getLocales()) { - Section section = new MapSection(); - - save(translations.getNodes(), locale, section, null); - - String fullPath = directoryPath + "/" + locale + ".yml"; - VirtualFile file = LocalFileSystem.getInstance().findFileByIoFile(new File(fullPath)); - - file.setBinaryContent(Section.toString(section).getBytes(file.getCharset())); - } - - // Successfully saved - callback.accept(true); - - } catch(IOException e) { - e.printStackTrace(); - callback.accept(false); - } - }); - } -} diff --git a/src/main/java/de/marhali/easyi18n/util/array/JsonArrayUtil.java b/src/main/java/de/marhali/easyi18n/io/json/JsonArrayMapper.java similarity index 70% rename from src/main/java/de/marhali/easyi18n/util/array/JsonArrayUtil.java rename to src/main/java/de/marhali/easyi18n/io/json/JsonArrayMapper.java index d61969c..aac41ed 100644 --- a/src/main/java/de/marhali/easyi18n/util/array/JsonArrayUtil.java +++ b/src/main/java/de/marhali/easyi18n/io/json/JsonArrayMapper.java @@ -1,13 +1,14 @@ -package de.marhali.easyi18n.util.array; +package de.marhali.easyi18n.io.json; import com.google.gson.JsonArray; import com.google.gson.JsonElement; +import de.marhali.easyi18n.io.ArrayMapper; /** - * Utility methods to read and write json arrays. + * Map json array values. * @author marhali */ -public class JsonArrayUtil extends ArrayUtil { +public class JsonArrayMapper extends ArrayMapper { public static String read(JsonArray array) { return read(array.iterator(), JsonElement::getAsString); } @@ -17,4 +18,4 @@ public class JsonArrayUtil extends ArrayUtil { write(concat, array::add); return array; } -} +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/io/json/JsonIOStrategy.java b/src/main/java/de/marhali/easyi18n/io/json/JsonIOStrategy.java new file mode 100644 index 0000000..40d57af --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/json/JsonIOStrategy.java @@ -0,0 +1,118 @@ +package de.marhali.easyi18n.io.json; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; + +import de.marhali.easyi18n.io.IOStrategy; +import de.marhali.easyi18n.model.SettingsState; +import de.marhali.easyi18n.model.TranslationData; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.function.Consumer; + +/** + * Strategy for simple json locale files. Each locale has its own file. + * For example localesPath/en.json, localesPath/de.json. + * @author marhali + */ +public class JsonIOStrategy implements IOStrategy { + + private static final String FILE_EXTENSION = "json"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + + @Override + public boolean canUse(@NotNull Project project, @NotNull String localesPath, @NotNull SettingsState state) { + VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(localesPath)); + + if(directory == null || directory.getChildren() == null) { + return false; + } + + for(VirtualFile children : directory.getChildren()) { + if(!children.isDirectory() && isFileRelevant(state, children)) { + if(children.getFileType().getDefaultExtension().equalsIgnoreCase(FILE_EXTENSION)) { + return true; + } + } + } + + return false; + } + + @Override + public void read(@NotNull Project project, @NotNull String localesPath, + @NotNull SettingsState state, @NotNull Consumer<@Nullable TranslationData> result) { + ApplicationManager.getApplication().saveAll(); // Save opened files (required if new locales were added) + + ApplicationManager.getApplication().runReadAction(() -> { + VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(localesPath)); + + if(directory == null || directory.getChildren() == null) { + throw new IllegalArgumentException("Specified folder is invalid (" + localesPath + ")"); + } + + TranslationData data = new TranslationData(state.isSortKeys(), state.isNestedKeys()); + + try { + for(VirtualFile file : directory.getChildren()) { + if(file.isDirectory() || !isFileRelevant(state, file)) { + continue; + } + + String locale = file.getNameWithoutExtension(); + data.addLocale(locale); + + JsonObject tree = GSON.fromJson(new InputStreamReader(file.getInputStream(), file.getCharset()), + JsonObject.class); + + JsonMapper.read(locale, tree, data.getRootNode()); + } + + result.accept(data); + + } catch(IOException e) { + e.printStackTrace(); + result.accept(null); + } + }); + } + + @Override + public void write(@NotNull Project project, @NotNull String localesPath, + @NotNull SettingsState state, @NotNull TranslationData data, @NotNull Consumer result) { + ApplicationManager.getApplication().runWriteAction(() -> { + try { + for(String locale : data.getLocales()) { + JsonObject content = new JsonObject(); + JsonMapper.write(locale, content, data.getRootNode()); + + File file = new File(localesPath + "/" + locale + "." + FILE_EXTENSION); + boolean exists = file.createNewFile(); + + VirtualFile vf = exists + ? LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) + : LocalFileSystem.getInstance().findFileByIoFile(file); + + vf.setBinaryContent(GSON.toJson(content).getBytes(vf.getCharset())); + } + + result.accept(true); + + } catch(IOException e) { + e.printStackTrace(); + result.accept(false); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/io/json/JsonMapper.java b/src/main/java/de/marhali/easyi18n/io/json/JsonMapper.java new file mode 100644 index 0000000..55f5c43 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/json/JsonMapper.java @@ -0,0 +1,73 @@ +package de.marhali.easyi18n.io.json; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import de.marhali.easyi18n.model.Translation; +import de.marhali.easyi18n.model.TranslationNode; +import de.marhali.easyi18n.util.StringUtil; + +import org.apache.commons.lang.StringEscapeUtils; +import org.apache.commons.lang.math.NumberUtils; + +import java.util.Map; + +/** + * Mapper for mapping json objects into translation nodes and backwards. + * @author marhali + */ +public class JsonMapper { + + public static void read(String locale, JsonObject json, TranslationNode node) { + for(Map.Entry entry : json.entrySet()) { + String key = entry.getKey(); + JsonElement value = entry.getValue(); + + TranslationNode childNode = node.getOrCreateChildren(key); + + if(value.isJsonObject()) { + // Nested element - run recursively + read(locale, value.getAsJsonObject(), childNode); + } else { + Translation translation = childNode.getValue(); + + String content = entry.getValue().isJsonArray() + ? JsonArrayMapper.read(value.getAsJsonArray()) + : StringUtil.escapeControls(value.getAsString(), true); + + translation.put(locale, content); + childNode.setValue(translation); + } + } + } + + public static void write(String locale, JsonObject json, TranslationNode node) { + for(Map.Entry entry : node.getChildren().entrySet()) { + String key = entry.getKey(); + TranslationNode childNode = entry.getValue(); + + if(!childNode.isLeaf()) { + // Nested node - run recursively + JsonObject childJson = new JsonObject(); + write(locale, childJson, childNode); + if(childJson.size() > 0) { + json.add(key, childJson); + } + } else { + Translation translation = childNode.getValue(); + String content = translation.get(locale); + + if(content != null) { + if(JsonArrayMapper.isArray(content)) { + json.add(key, JsonArrayMapper.write(content)); + } else if(NumberUtils.isNumber(content)) { + json.add(key, new JsonPrimitive(NumberUtils.createNumber(content))); + } else { + json.add(key, new JsonPrimitive(StringEscapeUtils.unescapeJava(content))); + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/io/json/ModularizedJsonIOStrategy.java b/src/main/java/de/marhali/easyi18n/io/json/ModularizedJsonIOStrategy.java new file mode 100644 index 0000000..7e4d877 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/json/ModularizedJsonIOStrategy.java @@ -0,0 +1,149 @@ +package de.marhali.easyi18n.io.json; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; + +import de.marhali.easyi18n.io.IOStrategy; +import de.marhali.easyi18n.model.SettingsState; +import de.marhali.easyi18n.model.TranslationData; +import de.marhali.easyi18n.model.TranslationNode; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; +import java.util.function.Consumer; + +/** + * Strategy for distributed json files per locale. Each locale can have multiple modules. The file name + * of each module will be used as the key for the underlying translations.
+ * Full key example: .. + * + * @author marhali + */ +public class ModularizedJsonIOStrategy implements IOStrategy { + + private static final String FILE_EXTENSION = "json"; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + + @Override + public boolean canUse(@NotNull Project project, @NotNull String localesPath, @NotNull SettingsState state) { + VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(localesPath)); + + if(directory == null || directory.getChildren() == null) { + return false; + } + + // We expect something like this: + // <localesPath>/<localeDir>/<moduleFile> + + for(VirtualFile children : directory.getChildren()) { + if(children.isDirectory()) { // Contains module folders + for(VirtualFile moduleFile : children.getChildren()) { + if(!moduleFile.isDirectory() && isFileRelevant(state, moduleFile)) { + if(moduleFile.getFileType().getDefaultExtension().equalsIgnoreCase(FILE_EXTENSION)) { + return true; + } + } + } + } + } + + return false; + } + + @Override + public void read(@NotNull Project project, @NotNull String localesPath, + @NotNull SettingsState state, @NotNull Consumer<@Nullable TranslationData> result) { + ApplicationManager.getApplication().saveAll(); // Save opened files (required if new locales were added) + + ApplicationManager.getApplication().runReadAction(() -> { + VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(localesPath)); + + if(directory == null || directory.getChildren() == null) { + throw new IllegalArgumentException("Specified folder is invalid (" + localesPath + ")"); + } + + TranslationData data = new TranslationData(state.isSortKeys(), state.isNestedKeys()); + VirtualFile[] localeDirectories = directory.getChildren(); + + try { + for(VirtualFile localeDir : localeDirectories) { + String locale = localeDir.getNameWithoutExtension(); + data.addLocale(locale); + + // Read all underlying module files + for(VirtualFile module : localeDir.getChildren()) { + if(module.isDirectory() || !isFileRelevant(state, module)) { + continue; + } + + String moduleName = module.getNameWithoutExtension(); + + TranslationNode moduleNode = data.getNode(moduleName) != null + ? data.getNode(moduleName) + : new TranslationNode(state.isSortKeys() ? new TreeMap<>() : new LinkedHashMap<>()); + + JsonObject tree = GSON.fromJson(new InputStreamReader(module.getInputStream(), + module.getCharset()), JsonObject.class); + + JsonMapper.read(locale, tree, moduleNode); + + data.getRootNode().setChildren(moduleName, moduleNode); + } + } + + result.accept(data); + + } catch(IOException e) { + e.printStackTrace(); + result.accept(null); + } + }); + } + + // TODO: there will be problems when adding translations via TranslationData with non-nested key mode + + @Override + public void write(@NotNull Project project, @NotNull String localesPath, + @NotNull SettingsState state, @NotNull TranslationData data, @NotNull Consumer<Boolean> result) { + ApplicationManager.getApplication().runWriteAction(() -> { + try { + for(String locale : data.getLocales()) { + for(Map.Entry<String, TranslationNode> entry : data.getRootNode().getChildren().entrySet()) { + String module = entry.getKey(); + + JsonObject content = new JsonObject(); + JsonMapper.write(locale, content, entry.getValue()); + + String fullPath = localesPath + "/" + locale + "/" + module + "." + FILE_EXTENSION; + File file = new File(fullPath); + boolean exists = file.createNewFile(); + + VirtualFile vf = exists + ? LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) + : LocalFileSystem.getInstance().findFileByIoFile(file); + + vf.setBinaryContent(GSON.toJson(content).getBytes(vf.getCharset())); + } + } + + result.accept(true); + + } catch(IOException e) { + e.printStackTrace(); + result.accept(false); + } + }); + } +} diff --git a/src/main/java/de/marhali/easyi18n/io/properties/PropertiesArrayMapper.java b/src/main/java/de/marhali/easyi18n/io/properties/PropertiesArrayMapper.java new file mode 100644 index 0000000..71741ba --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/properties/PropertiesArrayMapper.java @@ -0,0 +1,23 @@ +package de.marhali.easyi18n.io.properties; + +import de.marhali.easyi18n.io.ArrayMapper; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Map for 'properties' array values. + * @author marhali + */ +public class PropertiesArrayMapper extends ArrayMapper { + public static String read(String[] list) { + return read(Arrays.stream(list).iterator(), Object::toString); + } + + public static String[] write(String concat) { + List<String> list = new ArrayList<>(); + write(concat, list::add); + return list.toArray(new String[0]); + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/io/properties/PropertiesIOStrategy.java b/src/main/java/de/marhali/easyi18n/io/properties/PropertiesIOStrategy.java new file mode 100644 index 0000000..0d3c9d4 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/properties/PropertiesIOStrategy.java @@ -0,0 +1,116 @@ +package de.marhali.easyi18n.io.properties; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; + +import de.marhali.easyi18n.io.IOStrategy; +import de.marhali.easyi18n.model.SettingsState; +import de.marhali.easyi18n.model.TranslationData; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.StringWriter; +import java.util.function.Consumer; + +/** + * Strategy for simple 'properties' locale files. Each locale has its own file. + * For example localesPath/en.properties, localesPath/de.properties. + * @author marhali + */ +public class PropertiesIOStrategy implements IOStrategy { + + private static final String FILE_EXTENSION = "properties"; + + @Override + public boolean canUse(@NotNull Project project, @NotNull String localesPath, @NotNull SettingsState state) { + VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(localesPath)); + + if(directory == null || directory.getChildren() == null) { + return false; + } + + for(VirtualFile children : directory.getChildren()) { + if(!children.isDirectory() && isFileRelevant(state, children)) { + if(children.getFileType().getDefaultExtension().equalsIgnoreCase(FILE_EXTENSION)) { + return true; + } + } + } + + return false; + } + + @Override + public void read(@NotNull Project project, @NotNull String localesPath, + @NotNull SettingsState state, @NotNull Consumer<@Nullable TranslationData> result) { + ApplicationManager.getApplication().saveAll(); // Save opened files (required if new locales were added) + + ApplicationManager.getApplication().runReadAction(() -> { + VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(localesPath)); + + if(directory == null || directory.getChildren() == null) { + throw new IllegalArgumentException("Specified folder is invalid (" + localesPath + ")"); + } + + TranslationData data = new TranslationData(state.isSortKeys(), state.isNestedKeys()); + + try { + for(VirtualFile file : directory.getChildren()) { + if(file.isDirectory() || !isFileRelevant(state, file)) { + continue; + } + + String locale = file.getNameWithoutExtension(); + data.addLocale(locale); + + SortableProperties properties = new SortableProperties(state.isSortKeys()); + properties.load(new InputStreamReader(file.getInputStream(), file.getCharset())); + PropertiesMapper.read(locale, properties, data); + } + + result.accept(data); + + } catch(IOException e) { + e.printStackTrace(); + result.accept(null); + } + }); + } + + @Override + public void write(@NotNull Project project, @NotNull String localesPath, + @NotNull SettingsState state, @NotNull TranslationData data, @NotNull Consumer<Boolean> result) { + ApplicationManager.getApplication().runWriteAction(() -> { + try { + for(String locale : data.getLocales()) { + SortableProperties properties = new SortableProperties(state.isSortKeys()); + PropertiesMapper.write(locale, properties, data); + + File file = new File(localesPath + "/" + locale + "." + FILE_EXTENSION); + boolean exists = file.createNewFile(); + + VirtualFile vf = exists + ? LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) + : LocalFileSystem.getInstance().findFileByIoFile(file); + + StringWriter writer = new StringWriter(); + properties.store(writer, null); + + vf.setBinaryContent(writer.toString().getBytes(vf.getCharset())); + } + + result.accept(true); + + } catch(IOException e) { + e.printStackTrace(); + result.accept(false); + } + }); + } +} diff --git a/src/main/java/de/marhali/easyi18n/io/properties/PropertiesMapper.java b/src/main/java/de/marhali/easyi18n/io/properties/PropertiesMapper.java new file mode 100644 index 0000000..ba6aa6a --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/properties/PropertiesMapper.java @@ -0,0 +1,55 @@ +package de.marhali.easyi18n.io.properties; + +import de.marhali.easyi18n.model.Translation; +import de.marhali.easyi18n.model.TranslationData; +import de.marhali.easyi18n.model.TranslationNode; +import de.marhali.easyi18n.util.StringUtil; + +import org.apache.commons.lang.math.NumberUtils; + +import java.util.Map; + +/** + * Mapper for mapping properties files into translation nodes and backwards. + * @author marhali + */ +public class PropertiesMapper { + + public static void read(String locale, SortableProperties properties, TranslationData data) { + for(Map.Entry<Object, Object> entry : properties.entrySet()) { + String key = String.valueOf(entry.getKey()); + Object value = entry.getValue(); + + Translation translation = data.getTranslation(key); + + if(translation == null) { + translation = new Translation(); + } + + String content = value instanceof String[] + ? PropertiesArrayMapper.read((String[]) value) + : StringUtil.escapeControls(String.valueOf(value), true); + + translation.put(locale, content); + data.setTranslation(key, translation); + } + } + + public static void write(String locale, SortableProperties properties, TranslationData data) { + for(String key : data.getFullKeys()) { + Translation translation = data.getTranslation(key); + + if(translation != null && translation.containsKey(locale)) { + String content = translation.get(locale); + + if(PropertiesArrayMapper.isArray(content)) { + properties.put(key, PropertiesArrayMapper.write(content)); + } else if(NumberUtils.isNumber(content)) { + properties.put(key, NumberUtils.createNumber(content)); + } else { + properties.put(key, content); + } + } + } + } +} diff --git a/src/main/java/de/marhali/easyi18n/io/properties/SortableProperties.java b/src/main/java/de/marhali/easyi18n/io/properties/SortableProperties.java new file mode 100644 index 0000000..3b51db0 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/properties/SortableProperties.java @@ -0,0 +1,45 @@ +package de.marhali.easyi18n.io.properties; + +import java.util.*; + +/** + * Extends {@link Properties} class to support sorted or non-sorted keys. + * @author marhali + */ +public class SortableProperties extends Properties { + + private final transient Map<Object, Object> properties; + + public SortableProperties(boolean sort) { + this.properties = sort ? new TreeMap<>() : new LinkedHashMap<>(); + } + + public Map<Object, Object> getProperties() { + return this.properties; + } + + @Override + public Object get(Object key) { + return this.properties.get(key); + } + + @Override + public Set<Object> keySet() { + return Collections.unmodifiableSet(this.properties.keySet()); + } + + @Override + public Set<Map.Entry<Object, Object>> entrySet() { + return this.properties.entrySet(); + } + + @Override + public synchronized Object put(Object key, Object value) { + return this.properties.put(key, value); + } + + @Override + public String toString() { + return this.properties.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/util/array/YamlArrayUtil.java b/src/main/java/de/marhali/easyi18n/io/yaml/YamlArrayMapper.java similarity index 57% rename from src/main/java/de/marhali/easyi18n/util/array/YamlArrayUtil.java rename to src/main/java/de/marhali/easyi18n/io/yaml/YamlArrayMapper.java index 86f4db6..fb2b340 100644 --- a/src/main/java/de/marhali/easyi18n/util/array/YamlArrayUtil.java +++ b/src/main/java/de/marhali/easyi18n/io/yaml/YamlArrayMapper.java @@ -1,15 +1,16 @@ -package de.marhali.easyi18n.util.array; +package de.marhali.easyi18n.io.yaml; + +import de.marhali.easyi18n.io.ArrayMapper; import thito.nodeflow.config.ListSection; /** - * Utility methods to read and write yaml lists. + * Map for yaml array values. * @author marhali */ -public class YamlArrayUtil extends ArrayUtil { - +public class YamlArrayMapper extends ArrayMapper { public static String read(ListSection list) { - return read(list.iterator(), Object::toString); + return read(list.iterator(), Object::toString); } public static ListSection write(String concat) { diff --git a/src/main/java/de/marhali/easyi18n/io/yaml/YamlIOStrategy.java b/src/main/java/de/marhali/easyi18n/io/yaml/YamlIOStrategy.java new file mode 100644 index 0000000..fbb0ced --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/yaml/YamlIOStrategy.java @@ -0,0 +1,121 @@ +package de.marhali.easyi18n.io.yaml; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; + +import de.marhali.easyi18n.io.IOStrategy; +import de.marhali.easyi18n.model.SettingsState; +import de.marhali.easyi18n.model.TranslationData; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import thito.nodeflow.config.MapSection; +import thito.nodeflow.config.Section; + +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.function.Consumer; + +/** + * Strategy for simple yaml locale files. Each locale has its own file. + * For example localesPath/en.y(a)ml, localesPath/de.y(a)ml + * @author marhali + */ +public class YamlIOStrategy implements IOStrategy { + + private final String FILE_EXTENSION; + + public YamlIOStrategy(@NotNull String fileExtension) { + this.FILE_EXTENSION = fileExtension; + } + + @Override + public boolean canUse(@NotNull Project project, @NotNull String localesPath, @NotNull SettingsState state) { + VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(localesPath)); + + if(directory == null || directory.getChildren() == null) { + return false; + } + + for(VirtualFile children : directory.getChildren()) { + if(!children.isDirectory() && isFileRelevant(state, children)) { + if(children.getFileType().getDefaultExtension().equalsIgnoreCase(FILE_EXTENSION)) { + return true; + } + } + } + + return false; + } + + @Override + public void read(@NotNull Project project, @NotNull String localesPath, + @NotNull SettingsState state, @NotNull Consumer<@Nullable TranslationData> result) { + ApplicationManager.getApplication().saveAll(); // Save opened files (required if new locales were added) + + ApplicationManager.getApplication().runReadAction(() -> { + VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(localesPath)); + + if(directory == null || directory.getChildren() == null) { + throw new IllegalArgumentException("Specified folder is invalid (" + localesPath + ")"); + } + + TranslationData data = new TranslationData(state.isSortKeys(), state.isNestedKeys()); + + try { + for(VirtualFile file : directory.getChildren()) { + if(file.isDirectory() || !isFileRelevant(state, file)) { + continue; + } + + String locale = file.getNameWithoutExtension(); + data.addLocale(locale); + + try(Reader reader = new InputStreamReader(file.getInputStream(), file.getCharset())) { + Section section = Section.parseToMap(reader); + YamlMapper.read(locale, section, data.getRootNode()); + } + } + + result.accept(data); + + } catch(IOException e) { + e.printStackTrace(); + result.accept(null); + } + }); + } + + @Override + public void write(@NotNull Project project, @NotNull String localesPath, + @NotNull SettingsState state, @NotNull TranslationData data, @NotNull Consumer<Boolean> result) { + ApplicationManager.getApplication().runWriteAction(() -> { + try { + for(String locale : data.getLocales()) { + Section section = new MapSection(); + YamlMapper.write(locale, section, data.getRootNode()); + + File file = new File(localesPath + "/" + locale + "." + FILE_EXTENSION); + boolean exists = file.createNewFile(); + + VirtualFile vf = exists + ? LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) + : LocalFileSystem.getInstance().findFileByIoFile(file); + + vf.setBinaryContent(Section.toString(section).getBytes(vf.getCharset())); + } + + result.accept(true); + + } catch(IOException e) { + e.printStackTrace(); + result.accept(false); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/io/yaml/YamlMapper.java b/src/main/java/de/marhali/easyi18n/io/yaml/YamlMapper.java new file mode 100644 index 0000000..364ab33 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/io/yaml/YamlMapper.java @@ -0,0 +1,72 @@ +package de.marhali.easyi18n.io.yaml; + +import de.marhali.easyi18n.model.Translation; +import de.marhali.easyi18n.model.TranslationNode; +import de.marhali.easyi18n.util.StringUtil; + +import org.apache.commons.lang.StringEscapeUtils; +import org.apache.commons.lang.math.NumberUtils; + +import thito.nodeflow.config.ListSection; +import thito.nodeflow.config.MapSection; +import thito.nodeflow.config.Section; + +import java.util.Map; + +/** + * Mapper for mapping yaml files into translation nodes and backwards. + * @author marhali + */ +public class YamlMapper { + + public static void read(String locale, Section section, TranslationNode node) { + for(String key : section.getKeys()) { + Object value = section.getInScope(key).get(); + + TranslationNode childNode = node.getOrCreateChildren(key); + + if(value instanceof MapSection) { + // Nested element - run recursively + read(locale, (MapSection) value, childNode); + } else { + Translation translation = childNode.getValue(); + + String content = value instanceof ListSection + ? YamlArrayMapper.read((ListSection) value) + : StringUtil.escapeControls(String.valueOf(value), true); + + translation.put(locale, content); + childNode.setValue(translation); + } + } + } + + public static void write(String locale, Section section, TranslationNode node) { + for(Map.Entry<String, TranslationNode> entry : node.getChildren().entrySet()) { + String key = entry.getKey(); + TranslationNode childNode = entry.getValue(); + + if(!childNode.isLeaf()) { + // Nested node - run recursively + MapSection childSection = new MapSection(); + write(locale, childSection, childNode); + if(childSection.size() > 0) { + section.setInScope(key, childSection); + } + } else { + Translation translation = childNode.getValue(); + String content = translation.get(locale); + + if(content != null) { + if(YamlArrayMapper.isArray(content)) { + section.setInScope(key, YamlArrayMapper.write(content)); + } else if(NumberUtils.isNumber(content)) { + section.setInScope(key, NumberUtils.createNumber(content)); + } else { + section.setInScope(key, StringEscapeUtils.unescapeJava(content)); + } + } + } + } + } +} diff --git a/src/main/java/de/marhali/easyi18n/model/DataSynchronizer.java b/src/main/java/de/marhali/easyi18n/model/DataSynchronizer.java deleted file mode 100644 index 7cc3e71..0000000 --- a/src/main/java/de/marhali/easyi18n/model/DataSynchronizer.java +++ /dev/null @@ -1,19 +0,0 @@ -package de.marhali.easyi18n.model; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * Interface to communicate data changes between data store and ui components. - * @author marhali - */ -public interface DataSynchronizer { - - /** - * Propagates data changes to implementation classes. - * @param translations Updated translations model - * @param searchQuery Can be used to filter visible data. Like a search function for the full key path - * @param scrollToKey Focus specific translation. Can be null to disable this function - */ - void synchronize(@NotNull Translations translations, @Nullable String searchQuery, @Nullable String scrollToKey); -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/KeyedTranslation.java b/src/main/java/de/marhali/easyi18n/model/KeyedTranslation.java index 8e5849b..97a415a 100644 --- a/src/main/java/de/marhali/easyi18n/model/KeyedTranslation.java +++ b/src/main/java/de/marhali/easyi18n/model/KeyedTranslation.java @@ -1,42 +1,43 @@ package de.marhali.easyi18n.model; -import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; /** - * Translated messages for a dedicated key. + * I18n translation with associated key path (full-key). * @author marhali */ public class KeyedTranslation { - private String key; - private Map<String, String> translations; + private @NotNull String key; + private @Nullable Translation translation; - public KeyedTranslation(String key, Map<String, String> translations) { + public KeyedTranslation(@NotNull String key, @Nullable Translation translation) { this.key = key; - this.translations = translations; + this.translation = translation; } - public String getKey() { + public @NotNull String getKey() { return key; } - public void setKey(String key) { + public void setKey(@NotNull String key) { this.key = key; } - public Map<String, String> getTranslations() { - return translations; + public @Nullable Translation getTranslation() { + return translation; } - public void setTranslations(Map<String, String> translations) { - this.translations = translations; + public void setTranslation(@NotNull Translation translation) { + this.translation = translation; } @Override public String toString() { return "KeyedTranslation{" + "key='" + key + '\'' + - ", translations=" + translations + + ", translation=" + translation + '}'; } } \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/LocalizedNode.java b/src/main/java/de/marhali/easyi18n/model/LocalizedNode.java deleted file mode 100644 index 3c7eb2b..0000000 --- a/src/main/java/de/marhali/easyi18n/model/LocalizedNode.java +++ /dev/null @@ -1,77 +0,0 @@ -package de.marhali.easyi18n.model; - -import de.marhali.easyi18n.util.MapUtil; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.*; - -/** - * Represents structured tree view for translated messages. - * @author marhali - */ -public class LocalizedNode { - - public static final String ROOT_KEY = "root"; - - @NotNull - private final String key; - - @NotNull - private TreeMap<String, LocalizedNode> children; - - @NotNull - private Map<String, String> value; - - public LocalizedNode(@NotNull String key, @NotNull List<LocalizedNode> children) { - this.key = key; - this.children = MapUtil.convertToTreeMap(children); - this.value = new HashMap<>(); - } - - public LocalizedNode(@NotNull String key, @NotNull Map<String, String> value) { - this.key = key; - this.children = new TreeMap<>(); - this.value = value; - } - - public @NotNull String getKey() { - return key; - } - - public boolean isLeaf() { - return children.isEmpty(); - } - - public @NotNull Collection<LocalizedNode> getChildren() { - return children.values(); - } - - public @Nullable LocalizedNode getChildren(@NotNull String key) { - return children.get(key); - } - - public void setChildren(@NotNull LocalizedNode... children) { - this.value.clear(); - this.children = MapUtil.convertToTreeMap(Arrays.asList(children)); - } - - public void addChildren(@NotNull LocalizedNode... children) { - this.value.clear(); - Arrays.stream(children).forEach(e -> this.children.put(e.getKey(), e)); - } - - public void removeChildren(@NotNull String key) { - this.children.remove(key); - } - - public @NotNull Map<String, String> getValue() { - return value; - } - - public void setValue(@NotNull Map<String, String> value) { - this.children.clear(); - this.value = value; - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/SettingsState.java b/src/main/java/de/marhali/easyi18n/model/SettingsState.java index 2638c7d..5c322eb 100644 --- a/src/main/java/de/marhali/easyi18n/model/SettingsState.java +++ b/src/main/java/de/marhali/easyi18n/model/SettingsState.java @@ -12,12 +12,16 @@ public class SettingsState { public static final String DEFAULT_PREVIEW_LOCALE = "en"; public static final String DEFAULT_FILE_PATTERN = ".*"; public static final String DEFAULT_PATH_PREFIX = ""; + public static final boolean DEFAULT_SORT_KEYS = true; + public static final boolean DEFAULT_NESTED_KEYS = true; public static final boolean DEFAULT_CODE_ASSISTANCE = true; private String localesPath; private String filePattern; private String previewLocale; private String pathPrefix; + private Boolean sortKeys; + private Boolean nestedKeys; private Boolean codeAssistance; public SettingsState() {} @@ -54,6 +58,22 @@ public class SettingsState { this.pathPrefix = pathPrefix; } + public boolean isSortKeys() { + return sortKeys == null ? DEFAULT_SORT_KEYS : sortKeys; + } + + public void setSortKeys(boolean sortKeys) { + this.sortKeys = sortKeys; + } + + public boolean isNestedKeys() { + return nestedKeys == null ? DEFAULT_NESTED_KEYS : nestedKeys; + } + + public void setNestedKeys(boolean nestedKeys) { + this.nestedKeys = nestedKeys; + } + public boolean isCodeAssistance() { return codeAssistance == null ? DEFAULT_CODE_ASSISTANCE : codeAssistance; } diff --git a/src/main/java/de/marhali/easyi18n/model/Translation.java b/src/main/java/de/marhali/easyi18n/model/Translation.java new file mode 100644 index 0000000..3237813 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/model/Translation.java @@ -0,0 +1,29 @@ +package de.marhali.easyi18n.model; + +import java.util.HashMap; + +/** + * Represents all translations for an element. The assignment to an element is done in the using class. + * This class contains only the translations for this unspecific element. + * @author marhali + */ +public class Translation extends HashMap<String, String> { + public Translation() { + super(); + } + + public Translation(String locale, String content) { + this(); + super.put(locale, content); + } + + public Translation add(String locale, String content) { + super.put(locale, content); + return this; + } + + @Override + public String toString() { + return super.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/TranslationData.java b/src/main/java/de/marhali/easyi18n/model/TranslationData.java new file mode 100644 index 0000000..ba042b2 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/model/TranslationData.java @@ -0,0 +1,186 @@ +package de.marhali.easyi18n.model; + +import de.marhali.easyi18n.util.PathUtil; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +/** + * Cached translation data. The data is stored in a tree structure. + * Tree behaviour (sorted, non-sorted) can be specified via constructor. + * For more please see {@link TranslationNode}. Example tree view: + * <br/> + * user: <br/> + * -- principal: 'Principal' <br/> + * -- username: <br/> + * -- -- title: 'Username' <br/> + * auth: <br/> + * -- logout: 'Logout' <br/> + * -- login: 'Login' <br/> + * + * @author marhali + */ +public class TranslationData { + + private final PathUtil pathUtil; + + @NotNull + private final Set<String> locales; + + @NotNull + private final TranslationNode rootNode; + + /** + * Creates an empty instance. + * @param sort Should the translation keys be sorted alphabetically + */ + public TranslationData(boolean sort, boolean nestKeys) { + this(nestKeys, new HashSet<>(), new TranslationNode(sort ? new TreeMap<>() : new LinkedHashMap<>())); + } + + /** + * @param nestKeys Apply key nesting. See {@link PathUtil} + * @param locales Languages which can be used for translation + * @param rootNode Translation tree structure + */ + public TranslationData(boolean nestKeys, @NotNull Set<String> locales, @NotNull TranslationNode rootNode) { + this.pathUtil = new PathUtil(nestKeys); + this.locales = locales; + this.rootNode = rootNode; + } + + /** + * @return Set of languages which can receive translations + */ + public @NotNull Set<String> getLocales() { + return this.locales; + } + + /** + * @param locale Adds the provided locale to the supported languages list + */ + public void addLocale(@NotNull String locale) { + this.locales.add(locale); + } + + /** + * @return root node which contains all translations + */ + public @NotNull TranslationNode getRootNode() { + return this.rootNode; + } + + /** + * @param fullPath Absolute translation path + * @return Translation node which leads to translations or nested child's + */ + public @Nullable TranslationNode getNode(@NotNull String fullPath) { + List<String> sections = this.pathUtil.split(fullPath); + TranslationNode node = this.rootNode; + + if(fullPath.isEmpty()) { // Return root node if empty path was supplied + return node; + } + + for(String section : sections) { + if(node == null) { + return null; + } + node = node.getChildren().get(section); + } + + return node; + } + + /** + * @param fullPath Absolute translation key path + * @return Found translation. Can be null if path is empty or is not a leaf element + */ + public @Nullable Translation getTranslation(@NotNull String fullPath) { + TranslationNode node = this.getNode(fullPath); + + if(node == null || !node.isLeaf()) { + return null; + } + + return node.getValue(); + } + + /** + * @param fullPath Absolute translation key path + * @param translation Translation to set. Can be null to delete the corresponding node + */ + public void setTranslation(@NotNull String fullPath, @Nullable Translation translation) { + List<String> sections = this.pathUtil.split(fullPath); + String nodeKey = sections.remove(sections.size() - 1); // Edge case last section + TranslationNode node = this.rootNode; + + if(fullPath.isEmpty()) { + throw new IllegalArgumentException("Path cannot be empty"); + } + + for(String section : sections) { // Go to the level of the key (@nodeKey) + TranslationNode childNode = node.getChildren().get(section); + + if(childNode == null) { + if(translation == null) { // Path should not be empty for delete + throw new IllegalArgumentException("Delete action on empty path"); + } + + // Created nested section + childNode = node.setChildren(section); + } + + node = childNode; + } + + if(translation == null) { // Delete + node.removeChildren(nodeKey); + + if(node.getChildren().isEmpty() && !node.isRoot()) { // Parent is empty now. Run delete recursively + this.setTranslation(this.pathUtil.concat(sections), null); + } + + } else { // Create or overwrite + node.setChildren(nodeKey, translation); + } + } + + /** + * @return All translation keys as absolute paths (full-key) + */ + public @NotNull Set<String> getFullKeys() { + return this.getFullKeys("", this.rootNode); // Just use root node + } + + /** + * @param parentPath Parent key path + * @param node Node section to begin with + * @return All translation keys where the path contains the specified @parentPath + */ + public @NotNull Set<String> getFullKeys(String parentPath, TranslationNode node) { + Set<String> keys = new LinkedHashSet<>(); + + if(node.isLeaf()) { // This node does not lead to child's - just add the key + keys.add(parentPath); + } + + for(Map.Entry<String, TranslationNode> children : node.getChildren().entrySet()) { + keys.addAll(this.getFullKeys(this.pathUtil.append(parentPath, children.getKey()), children.getValue())); + } + + return keys; + } + + @Override + public String toString() { + return "TranslationData{" + + "mapClass=" + rootNode.getChildren().getClass().getSimpleName() + + ", pathUtil=" + pathUtil + + ", locales=" + locales + + ", rootNode=" + rootNode + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/TranslationNode.java b/src/main/java/de/marhali/easyi18n/model/TranslationNode.java new file mode 100644 index 0000000..dc91f3b --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/model/TranslationNode.java @@ -0,0 +1,117 @@ +package de.marhali.easyi18n.model; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +/** + * Translation tree node. Manages child nodes which can be translations or also + * nodes which can lead to another translation or node. + * Navigation inside a node can be upward and downward. To construct the full + * translation key (full-key) every parent needs to be resolved recursively. + * - + * Whether the children nodes should be sorted is determined by the parent node. + * For root nodes (empty parent) the {@link java.util.Map}-Type must be specified + * to determine which sorting should be applied. + * + * @author marhali + */ +public class TranslationNode { + + @Nullable + private TranslationNode parent; + + @NotNull + private Map<String, TranslationNode> children; + + @NotNull + private Translation value; + + /** + * Root node initializer. E.g. see {@link java.util.TreeMap} or {@link java.util.HashMap} + * @param children Decide which implementation should be used (sorted, non-sorted) + */ + public TranslationNode(@NotNull Map<String, TranslationNode> children) { + this.parent = null; + this.children = children; + this.value = new Translation(); + } + + /** + * @return true if this node is considered as root node + */ + public boolean isRoot() { + return this.parent == null; + } + + /** + * @return true if this node does not lead to other children nodes (just contains {@link Translation} itself). + * The root node is never treated as a leaf node. + */ + public boolean isLeaf() { + return this.children.isEmpty() && !this.isRoot(); + } + + public void setParent(@Nullable TranslationNode parent) { + this.parent = parent; + } + + public @NotNull Translation getValue() { + return value; + } + + public void setValue(@NotNull Translation value) { + this.children.clear(); + this.value = value; + } + + public @NotNull Map<String, TranslationNode> getChildren() { + return this.children; + } + + public void setChildren(@NotNull String key, @NotNull TranslationNode node) { + node.setParent(this); // Track parent if adding children's + this.value.clear(); + this.children.put(key, node); + } + + @SuppressWarnings("unchecked") + public @NotNull TranslationNode setChildren(@NotNull String key) { + try { + TranslationNode node = new TranslationNode(this.children.getClass().getDeclaredConstructor().newInstance()); + this.setChildren(key, node); + return node; + } catch(Exception e) { + e.printStackTrace(); + throw new RuntimeException("Cannot create children of map type " + this.children.getClass().getSimpleName()); + } + } + + public void setChildren(@NotNull String key, @NotNull Translation translation) { + this.setChildren(key).setValue(translation); + } + + public @NotNull TranslationNode getOrCreateChildren(@NotNull String key) { + TranslationNode node = this.children.get(key); + + if(node == null) { + node = this.setChildren(key); + } + + return node; + } + + public void removeChildren(@NotNull String key) { + this.children.remove(key); + } + + @Override + public String toString() { + return "TranslationNode{" + + "parent=" + parent + + ", children=" + children.keySet() + + ", value=" + value + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/TranslationUpdate.java b/src/main/java/de/marhali/easyi18n/model/TranslationUpdate.java index d4923b3..6b9dac8 100644 --- a/src/main/java/de/marhali/easyi18n/model/TranslationUpdate.java +++ b/src/main/java/de/marhali/easyi18n/model/TranslationUpdate.java @@ -3,7 +3,9 @@ package de.marhali.easyi18n.model; import org.jetbrains.annotations.Nullable; /** - * Represents an update for a translated I18n-Key. Supports key creation, manipulation and deletion. + * Represents an update for a translated i18n key. + * Supports translation creation, manipulation and deletion. + * * @author marhali */ public class TranslationUpdate { @@ -16,24 +18,24 @@ public class TranslationUpdate { this.change = change; } - public KeyedTranslation getOrigin() { + public @Nullable KeyedTranslation getOrigin() { return origin; } - public KeyedTranslation getChange() { + public @Nullable KeyedTranslation getChange() { return change; } public boolean isCreation() { - return origin == null; + return this.origin == null; } public boolean isDeletion() { - return change == null; + return this.change == null; } public boolean isKeyChange() { - return origin != null && change != null && !origin.getKey().equals(change.getKey()); + return this.origin != null && this.change != null && !this.origin.getKey().equals(this.change.getKey()); } @Override diff --git a/src/main/java/de/marhali/easyi18n/model/Translations.java b/src/main/java/de/marhali/easyi18n/model/Translations.java deleted file mode 100644 index 44bd03f..0000000 --- a/src/main/java/de/marhali/easyi18n/model/Translations.java +++ /dev/null @@ -1,108 +0,0 @@ -package de.marhali.easyi18n.model; - -import de.marhali.easyi18n.util.TranslationsUtil; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayList; -import java.util.List; - -/** - * Represents translation state instance. IO operations will be based on this file. - * @author marhali - */ -public class Translations { - - public static Translations empty() { - return new Translations(new ArrayList<>(), new LocalizedNode(LocalizedNode.ROOT_KEY, new ArrayList<>())); - } - - @NotNull - private final List<String> locales; - - @NotNull - private final LocalizedNode nodes; - - /** - * Constructs a new translation state instance. - * @param locales List of all locales which are used for create / edit I18n-Key operations - * @param nodes Represents the translation state. Internally handled as a tree. See {@link LocalizedNode} - */ - public Translations(@NotNull List<String> locales, @NotNull LocalizedNode nodes) { - this.locales = locales; - this.nodes = nodes; - } - - public @NotNull List<String> getLocales() { - return locales; - } - - public @NotNull LocalizedNode getNodes() { - return nodes; - } - - public @Nullable LocalizedNode getNode(@NotNull String fullPath) { - List<String> sections = TranslationsUtil.getSections(fullPath); - - LocalizedNode node = nodes; - - for(String section : sections) { - if(node == null) { - return null; - } - node = node.getChildren(section); - } - - return node; - } - - public @NotNull LocalizedNode getOrCreateNode(@NotNull String fullPath) { - List<String> sections = TranslationsUtil.getSections(fullPath); - - LocalizedNode node = nodes; - - for(String section : sections) { - LocalizedNode subNode = node.getChildren(section); - - if(subNode == null) { - subNode = new LocalizedNode(section, new ArrayList<>()); - node.addChildren(subNode); - } - - node = subNode; - } - - return node; - } - - public @NotNull List<String> getFullKeys() { - List<String> keys = new ArrayList<>(); - - if(nodes.isLeaf()) { // Root has no children - return keys; - } - - for(LocalizedNode children : nodes.getChildren()) { - keys.addAll(getFullKeys("", children)); - } - - return keys; - } - - public @NotNull List<String> getFullKeys(String parentFullPath, LocalizedNode localizedNode) { - List<String> keys = new ArrayList<>(); - - if(localizedNode.isLeaf()) { - keys.add(parentFullPath + (parentFullPath.isEmpty() ? "" : ".") + localizedNode.getKey()); - return keys; - } - - for(LocalizedNode children : localizedNode.getChildren()) { - String childrenPath = parentFullPath + (parentFullPath.isEmpty() ? "" : ".") + localizedNode.getKey(); - keys.addAll(getFullKeys(childrenPath, children)); - } - - return keys; - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/bus/BusListener.java b/src/main/java/de/marhali/easyi18n/model/bus/BusListener.java new file mode 100644 index 0000000..76a7c17 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/model/bus/BusListener.java @@ -0,0 +1,8 @@ +package de.marhali.easyi18n.model.bus; + +/** + * Interface for communication of changes for participants of the data bus. + * Every listener needs to be registered manually via {@link de.marhali.easyi18n.DataBus}. + * @author marhali + */ +public interface BusListener extends UpdateDataListener, FocusKeyListener, SearchQueryListener {} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/bus/FocusKeyListener.java b/src/main/java/de/marhali/easyi18n/model/bus/FocusKeyListener.java new file mode 100644 index 0000000..ce3069f --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/model/bus/FocusKeyListener.java @@ -0,0 +1,15 @@ +package de.marhali.easyi18n.model.bus; + +import org.jetbrains.annotations.Nullable; + +/** + * Single event listener. + * @author marhali + */ +public interface FocusKeyListener { + /** + * Move the specified translation key (full-key) into focus. + * @param key Absolute translation key + */ + void onFocusKey(@Nullable String key); +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/bus/SearchQueryListener.java b/src/main/java/de/marhali/easyi18n/model/bus/SearchQueryListener.java new file mode 100644 index 0000000..293f3ce --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/model/bus/SearchQueryListener.java @@ -0,0 +1,16 @@ +package de.marhali.easyi18n.model.bus; + +import org.jetbrains.annotations.Nullable; + +/** + * Single event listener. + * @author marhali + */ +public interface SearchQueryListener { + /** + * Filter the displayed data according to the search query. Supply 'null' to return to the normal state. + * The keys and the content itself should be considered. + * @param query Filter key or content + */ + void onSearchQuery(@Nullable String query); +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/bus/UpdateDataListener.java b/src/main/java/de/marhali/easyi18n/model/bus/UpdateDataListener.java new file mode 100644 index 0000000..d6c9cb2 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/model/bus/UpdateDataListener.java @@ -0,0 +1,16 @@ +package de.marhali.easyi18n.model.bus; + +import de.marhali.easyi18n.model.TranslationData; +import org.jetbrains.annotations.NotNull; + +/** + * Single event listener. + * @author marhali + */ +public interface UpdateDataListener { + /** + * Update the translations based on the supplied data. + * @param data Updated translations + */ + void onUpdateData(@NotNull TranslationData data); +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/table/TableModelTranslator.java b/src/main/java/de/marhali/easyi18n/model/table/TableModelTranslator.java deleted file mode 100644 index d5011e9..0000000 --- a/src/main/java/de/marhali/easyi18n/model/table/TableModelTranslator.java +++ /dev/null @@ -1,122 +0,0 @@ -package de.marhali.easyi18n.model.table; - -import de.marhali.easyi18n.model.LocalizedNode; -import de.marhali.easyi18n.model.KeyedTranslation; -import de.marhali.easyi18n.model.TranslationUpdate; -import de.marhali.easyi18n.model.Translations; - -import org.jetbrains.annotations.Nls; - -import javax.swing.event.TableModelListener; -import javax.swing.table.TableModel; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - -/** - * Table model to represents localized messages. - * @author marhali - */ -public class TableModelTranslator implements TableModel { - - private final Translations translations; - private final List<String> locales; - private final List<String> fullKeys; - - private final Consumer<TranslationUpdate> updater; - - /** - * @param translations Translations instance - * @param searchQuery Search / filter param - * @param updater Consumer which can be called on cell change / update - */ - public TableModelTranslator(Translations translations, String searchQuery, Consumer<TranslationUpdate> updater) { - this.translations = translations; - this.locales = translations.getLocales(); - this.updater = updater; - - List<String> fullKeys = translations.getFullKeys(); - - if(searchQuery != null && !searchQuery.isEmpty()) { // Filter keys by searchQuery - fullKeys.removeIf(key -> !key.startsWith(searchQuery)); - } - - this.fullKeys = fullKeys; - } - - @Override - public int getRowCount() { - return fullKeys.size(); - } - - @Override - public int getColumnCount() { - return locales.size() + 1; // Number of locales plus 1 for the Key's column - } - - @Nls - @Override - public String getColumnName(int columnIndex) { - if(columnIndex == 0) { - return "<html><b>Key</b></html>"; - } - - return "<html><b>" + locales.get(columnIndex - 1) + "</b></html>"; - } - - @Override - public Class<?> getColumnClass(int columnIndex) { - return String.class; - } - - @Override - public boolean isCellEditable(int rowIndex, int columnIndex) { - return rowIndex > 0; // Everything should be editable except the headline - } - - @Override - public Object getValueAt(int rowIndex, int columnIndex) { - if(columnIndex == 0) { // Keys - return fullKeys.get(rowIndex); - } - - String key = fullKeys.get(rowIndex); - String locale = locales.get(columnIndex - 1); - LocalizedNode node = translations.getNode(key); - - return node == null ? null : node.getValue().get(locale); - } - - @Override - public void setValueAt(Object aValue, int rowIndex, int columnIndex) { - String key = String.valueOf(getValueAt(rowIndex, 0)); - LocalizedNode node = translations.getNode(key); - - if(node == null) { // Unknown cell - return; - } - - String newKey = columnIndex == 0 ? String.valueOf(aValue) : key; - Map<String, String> messages = node.getValue(); - - // Locale message update - if(columnIndex > 0) { - if(aValue == null || ((String) aValue).isEmpty()) { - messages.remove(locales.get(columnIndex - 1)); - } else { - messages.put(locales.get(columnIndex - 1), String.valueOf(aValue)); - } - } - - TranslationUpdate update = new TranslationUpdate(new KeyedTranslation(key, messages), - new KeyedTranslation(newKey, messages)); - - updater.accept(update); - } - - @Override - public void addTableModelListener(TableModelListener l) {} - - @Override - public void removeTableModelListener(TableModelListener l) {} -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/model/tree/TreeModelTranslator.java b/src/main/java/de/marhali/easyi18n/model/tree/TreeModelTranslator.java deleted file mode 100644 index a479deb..0000000 --- a/src/main/java/de/marhali/easyi18n/model/tree/TreeModelTranslator.java +++ /dev/null @@ -1,132 +0,0 @@ -package de.marhali.easyi18n.model.tree; - -import com.intellij.ide.projectView.PresentationData; -import com.intellij.openapi.project.Project; -import com.intellij.ui.JBColor; - -import de.marhali.easyi18n.service.SettingsService; -import de.marhali.easyi18n.model.LocalizedNode; -import de.marhali.easyi18n.model.Translations; -import de.marhali.easyi18n.util.TranslationsUtil; -import de.marhali.easyi18n.util.UiUtil; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import javax.swing.tree.*; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** - * I18n key tree preparation. - * @author marhali - */ -public class TreeModelTranslator extends DefaultTreeModel { - - private final @NotNull Project project; - private final @NotNull Translations translations; - private final @Nullable String searchQuery; - - - public TreeModelTranslator( - @NotNull Project project, @NotNull Translations translations, @Nullable String searchQuery) { - super(null); - - this.project = project; - this.translations = translations; - this.searchQuery = searchQuery; - - setRoot(generateNodes()); - } - - private DefaultMutableTreeNode generateNodes() { - DefaultMutableTreeNode root = new DefaultMutableTreeNode(LocalizedNode.ROOT_KEY); - - if(translations.getNodes().isLeaf()) { // Empty tree - return root; - } - - List<String> searchSections = searchQuery == null ? - Collections.emptyList() : TranslationsUtil.getSections(searchQuery); - - for(LocalizedNode children : translations.getNodes().getChildren()) { - generateSubNodes(root, children, new ArrayList<>(searchSections)); - } - - return root; - } - - private void generateSubNodes(DefaultMutableTreeNode parent, - LocalizedNode localizedNode, List<String> searchSections) { - - String searchKey = searchSections.isEmpty() ? null : searchSections.remove(0); - - if(searchKey != null && !localizedNode.getKey().startsWith(searchKey)) { // Filter node - return; - } - - if(localizedNode.isLeaf()) { - String previewLocale = SettingsService.getInstance(project).getState().getPreviewLocale(); - - String title = localizedNode.getKey(); - String sub = "(" + previewLocale + ": " + localizedNode.getValue().get(previewLocale) + ")"; - String tooltip = UiUtil.generateHtmlTooltip(localizedNode.getValue()); - - PresentationData data = new PresentationData(title, sub, null, null); - data.setTooltip(tooltip); - - if(localizedNode.getValue().size() != translations.getLocales().size()) { - data.setForcedTextForeground(JBColor.RED); - } - - parent.add(new DefaultMutableTreeNode(data)); - - } else { - DefaultMutableTreeNode sub = new DefaultMutableTreeNode(localizedNode.getKey()); - parent.add(sub); - - for(LocalizedNode children : localizedNode.getChildren()) { - generateSubNodes(sub, children, new ArrayList<>(searchSections)); - } - } - } - - public TreePath findTreePath(@NotNull String fullPath) { - List<String> sections = TranslationsUtil.getSections(fullPath); - Object[] nodes = new Object[sections.size() + 1]; - - int pos = 0; - TreeNode currentNode = (TreeNode) this.getRoot(); - nodes[pos] = currentNode; - - for(String section : sections) { - pos++; - currentNode = findNode(currentNode, section); - nodes[pos] = currentNode; - } - - return new TreePath(nodes); - } - - public @Nullable DefaultMutableTreeNode findNode(@NotNull TreeNode parent, @NotNull String key) { - for(int i = 0; i < parent.getChildCount(); i++) { - TreeNode child = parent.getChildAt(i); - - if(child instanceof DefaultMutableTreeNode) { - DefaultMutableTreeNode mutableChild = (DefaultMutableTreeNode) child; - String childKey = mutableChild.getUserObject().toString(); - - if(mutableChild.getUserObject() instanceof PresentationData) { - childKey = ((PresentationData) mutableChild.getUserObject()).getPresentableText(); - } - - if(childKey != null && childKey.equals(key)) { - return mutableChild; - } - } - } - - throw new NullPointerException("Cannot find node by key: " + key); - } -} diff --git a/src/main/java/de/marhali/easyi18n/service/DataStore.java b/src/main/java/de/marhali/easyi18n/service/DataStore.java deleted file mode 100644 index e0bd369..0000000 --- a/src/main/java/de/marhali/easyi18n/service/DataStore.java +++ /dev/null @@ -1,173 +0,0 @@ -package de.marhali.easyi18n.service; - -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.application.ModalityState; -import com.intellij.openapi.project.Project; - -import de.marhali.easyi18n.model.LocalizedNode; -import de.marhali.easyi18n.model.Translations; -import de.marhali.easyi18n.io.TranslatorIO; -import de.marhali.easyi18n.model.DataSynchronizer; -import de.marhali.easyi18n.model.KeyedTranslation; -import de.marhali.easyi18n.model.TranslationDelete; -import de.marhali.easyi18n.model.TranslationUpdate; -import de.marhali.easyi18n.util.IOUtil; -import de.marhali.easyi18n.util.TranslationsUtil; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.WeakHashMap; -import java.util.function.Consumer; - -/** - * Factory service to manage localized messages for multiple projects at once. - * @author marhali - */ -public class DataStore { - - private static final Map<Project, DataStore> INSTANCES = new WeakHashMap<>(); - - private final Project project; - private final List<DataSynchronizer> synchronizer; - - private Translations translations; - private String searchQuery; - - public static DataStore getInstance(@NotNull Project project) { - DataStore store = INSTANCES.get(project); - - if(store == null) { - store = new DataStore(project); - INSTANCES.put(project, store); - } - - return store; - } - - private DataStore(@NotNull Project project) { - this.project = project; - this.synchronizer = new ArrayList<>(); - this.translations = Translations.empty(); - - // Load data after first initialization - ApplicationManager.getApplication().invokeLater(this::reloadFromDisk, ModalityState.NON_MODAL); - } - - /** - * Registers a new synchronizer which will receive {@link #translations} updates. - * @param synchronizer Synchronizer. See {@link DataSynchronizer} - */ - public void addSynchronizer(DataSynchronizer synchronizer) { - this.synchronizer.add(synchronizer); - } - - /** - * Loads all translations from disk and overrides current {@link #translations} state. - */ - public void reloadFromDisk() { - String localesPath = SettingsService.getInstance(project).getState().getLocalesPath(); - - if(localesPath == null || localesPath.isEmpty()) { - // Propagate empty state - this.translations = Translations.empty(); - synchronize(searchQuery, null); - - } else { - TranslatorIO io = IOUtil.determineFormat(project, localesPath); - - io.read(project, localesPath, (loadedTranslations) -> { - this.translations = loadedTranslations == null ? Translations.empty() : loadedTranslations; - synchronize(searchQuery, null); - }); - } - } - - /** - * Saves the current translation state to disk. See {@link TranslatorIO#save(Project, Translations, String, Consumer)} - * @param callback Complete callback. Indicates if operation was successful(true) or not - */ - public void saveToDisk(@NotNull Consumer<Boolean> callback) { - String localesPath = SettingsService.getInstance(project).getState().getLocalesPath(); - - if(localesPath == null || localesPath.isEmpty()) { // Cannot save without valid path - return; - } - - TranslatorIO io = IOUtil.determineFormat(project, localesPath); - io.save(project, translations, localesPath, callback); - } - - /** - * Propagates provided search string to all synchronizer to display only relevant keys - * @param fullPath Full i18n key (e.g. user.username.title). Can be null to display all keys - */ - public void searchBeyKey(@Nullable String fullPath) { - // Use synchronizer to propagate search instance to all views - synchronize(this.searchQuery = fullPath, null); - } - - /** - * Processes the provided update. Updates translation instance and propagates changes. See {@link DataSynchronizer} - * @param update The update to process. For more information see {@link TranslationUpdate} - */ - public void processUpdate(TranslationUpdate update) { - if(update.isDeletion() || update.isKeyChange()) { // Delete origin i18n key - String originKey = update.getOrigin().getKey(); - List<String> sections = TranslationsUtil.getSections(originKey); - String nodeKey = sections.remove(sections.size() - 1); // Remove last node, which needs to be removed by parent - - LocalizedNode node = translations.getNodes(); - for(String section : sections) { - if(node == null) { // Might be possible on multi-delete - break; - } - - node = node.getChildren(section); - } - - if(node != null) { // Only remove if parent exists. Might be already deleted on multi-delete - node.removeChildren(nodeKey); - - // Parent is empty now, we need to remove it as well (except root) - if(node.getChildren().isEmpty() && !node.getKey().equals(LocalizedNode.ROOT_KEY)) { - processUpdate(new TranslationDelete(new KeyedTranslation( - TranslationsUtil.sectionsToFullPath(sections), null))); - } - } - } - - String scrollTo = update.isDeletion() ? null : update.getChange().getKey(); - - if(!update.isDeletion()) { // Recreate with changed val / create - LocalizedNode node = translations.getOrCreateNode(update.getChange().getKey()); - node.setValue(update.getChange().getTranslations()); - } - - // Persist changes and propagate them on success - saveToDisk(success -> { - if(success) { - synchronize(searchQuery, scrollTo); - } - }); - } - - /** - * @return Current translation state - */ - public @NotNull Translations getTranslations() { - return translations; - } - - /** - * Synchronizes current translation's state to all connected subscribers. - * @param searchQuery Optional search by full key filter (ui view) - * @param scrollTo Optional scroll to full key (ui view) - */ - public void synchronize(@Nullable String searchQuery, @Nullable String scrollTo) { - synchronizer.forEach(subscriber -> subscriber.synchronize(this.translations, searchQuery, scrollTo)); - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/service/FileChangeListener.java b/src/main/java/de/marhali/easyi18n/service/FileChangeListener.java new file mode 100644 index 0000000..00ed6e2 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/service/FileChangeListener.java @@ -0,0 +1,67 @@ +package de.marhali.easyi18n.service; + +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.AsyncFileListener; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.openapi.vfs.newvfs.events.VFileEvent; + +import de.marhali.easyi18n.InstanceManager; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.util.List; + +/** + * Listens for file changes inside configured @localesPath. See {@link AsyncFileListener}. + * Will trigger the reload function of the i18n instance if a relevant file was changed. + * @author marhali + */ +public class FileChangeListener implements AsyncFileListener { + + private static final Logger logger = Logger.getInstance(FileChangeListener.class); + + private final @NotNull Project project; + private @Nullable String localesPath; + + public FileChangeListener(@NotNull Project project) { + this.project = project; + this.localesPath = null; // Wait for any update before listening to file changes + } + + public void updateLocalesPath(@Nullable String localesPath) { + if(localesPath != null && !localesPath.isEmpty()) { + VirtualFile file = LocalFileSystem.getInstance().findFileByIoFile(new File(localesPath)); + + if(file != null && file.isDirectory()) { + this.localesPath = file.getPath(); + return; + } + } + + this.localesPath = null; + } + + @Override + public ChangeApplier prepareChange(@NotNull List<? extends @NotNull VFileEvent> events) { + return new ChangeApplier() { + @Override + public void afterVfsChange() { + if(localesPath != null) { + events.forEach((e) -> { + if(e.getPath().contains(localesPath)) { // Perform reload + logger.debug("Detected file change. Reloading instance..."); + InstanceManager manager = InstanceManager.get(project); + manager.store().loadFromPersistenceLayer((success) -> { + manager.bus().propagate().onUpdateData(manager.store().getData()); + }); + } + }); + } + } + }; + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/TranslatorToolWindowFactory.java b/src/main/java/de/marhali/easyi18n/service/TranslatorToolWindowFactory.java similarity index 82% rename from src/main/java/de/marhali/easyi18n/TranslatorToolWindowFactory.java rename to src/main/java/de/marhali/easyi18n/service/TranslatorToolWindowFactory.java index 161d4b4..fb6a6e7 100644 --- a/src/main/java/de/marhali/easyi18n/TranslatorToolWindowFactory.java +++ b/src/main/java/de/marhali/easyi18n/service/TranslatorToolWindowFactory.java @@ -1,4 +1,4 @@ -package de.marhali.easyi18n; +package de.marhali.easyi18n.service; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.project.Project; @@ -7,8 +7,7 @@ import com.intellij.openapi.wm.ToolWindowFactory; import com.intellij.ui.content.Content; import com.intellij.ui.content.ContentFactory; -import de.marhali.easyi18n.service.DataStore; -import de.marhali.easyi18n.service.WindowManager; +import de.marhali.easyi18n.InstanceManager; import de.marhali.easyi18n.action.*; import de.marhali.easyi18n.tabs.TableView; import de.marhali.easyi18n.tabs.TreeView; @@ -48,16 +47,16 @@ public class TranslatorToolWindowFactory implements ToolWindowFactory { actions.add(new AddAction()); actions.add(new ReloadAction()); actions.add(new SettingsAction()); - actions.add(new SearchAction((searchString) -> DataStore.getInstance(project).searchBeyKey(searchString))); + actions.add(new SearchAction((query) -> InstanceManager.get(project).bus().propagate().onSearchQuery(query))); toolWindow.setTitleActions(actions); // Initialize Window Manager WindowManager.getInstance().initialize(toolWindow, treeView, tableView); // Synchronize ui with underlying data - DataStore store = DataStore.getInstance(project); - store.addSynchronizer(treeView); - store.addSynchronizer(tableView); - store.synchronize(null, null); + InstanceManager manager = InstanceManager.get(project); + manager.bus().addListener(treeView); + manager.bus().addListener(tableView); + manager.bus().propagate().onUpdateData(manager.store().getData()); } } \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/service/WindowManager.java b/src/main/java/de/marhali/easyi18n/service/WindowManager.java index c92883e..51d8b01 100644 --- a/src/main/java/de/marhali/easyi18n/service/WindowManager.java +++ b/src/main/java/de/marhali/easyi18n/service/WindowManager.java @@ -5,6 +5,10 @@ import com.intellij.openapi.wm.ToolWindow; import de.marhali.easyi18n.tabs.TableView; import de.marhali.easyi18n.tabs.TreeView; +/** + * Provides access to the plugin's own tool-window. + * @author marhali + */ public class WindowManager { private static WindowManager INSTANCE; diff --git a/src/main/java/de/marhali/easyi18n/tabs/TableView.java b/src/main/java/de/marhali/easyi18n/tabs/TableView.java index 4b7dc31..340d3a5 100644 --- a/src/main/java/de/marhali/easyi18n/tabs/TableView.java +++ b/src/main/java/de/marhali/easyi18n/tabs/TableView.java @@ -4,17 +4,14 @@ import com.intellij.openapi.project.Project; import com.intellij.ui.components.JBScrollPane; import com.intellij.ui.table.JBTable; -import de.marhali.easyi18n.service.DataStore; -import de.marhali.easyi18n.model.LocalizedNode; -import de.marhali.easyi18n.model.DataSynchronizer; -import de.marhali.easyi18n.model.Translations; -import de.marhali.easyi18n.model.KeyedTranslation; -import de.marhali.easyi18n.model.TranslationDelete; -import de.marhali.easyi18n.model.table.TableModelTranslator; +import de.marhali.easyi18n.InstanceManager; +import de.marhali.easyi18n.model.*; import de.marhali.easyi18n.dialog.EditDialog; import de.marhali.easyi18n.listener.DeleteKeyListener; import de.marhali.easyi18n.listener.PopupClickListener; +import de.marhali.easyi18n.model.bus.BusListener; import de.marhali.easyi18n.renderer.TableRenderer; +import de.marhali.easyi18n.tabs.mapper.TableModelMapper; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -28,10 +25,12 @@ import java.util.ResourceBundle; * Shows translation state as table. * @author marhali */ -public class TableView implements DataSynchronizer { +public class TableView implements BusListener { private final Project project; + private TableModelMapper currentMapper; + private JPanel rootPanel; private JPanel containerPanel; @@ -54,10 +53,10 @@ public class TableView implements DataSynchronizer { if(row >= 0) { String fullPath = String.valueOf(table.getValueAt(row, 0)); - LocalizedNode node = DataStore.getInstance(project).getTranslations().getNode(fullPath); + Translation translation = InstanceManager.get(project).store().getData().getTranslation(fullPath); - if(node != null) { - new EditDialog(project, new KeyedTranslation(fullPath, node.getValue())).showAndHandle(); + if(translation != null) { + new EditDialog(project, new KeyedTranslation(fullPath, translation)).showAndHandle(); } } } @@ -67,32 +66,40 @@ public class TableView implements DataSynchronizer { for (int selectedRow : table.getSelectedRows()) { String fullPath = String.valueOf(table.getValueAt(selectedRow, 0)); - DataStore.getInstance(project).processUpdate( - new TranslationDelete(new KeyedTranslation(fullPath, null))); + InstanceManager.get(project).processUpdate( + new TranslationDelete(new KeyedTranslation(fullPath, null)) + ); } }; } @Override - public void synchronize(@NotNull Translations translations, - @Nullable String searchQuery, @Nullable String scrollTo) { + public void onUpdateData(@NotNull TranslationData data) { + table.setModel(this.currentMapper = new TableModelMapper(data, update -> + InstanceManager.get(project).processUpdate(update))); + } - table.setModel(new TableModelTranslator(translations, searchQuery, update -> - DataStore.getInstance(project).processUpdate(update))); + @Override + public void onFocusKey(@Nullable String key) { + int row = -1; - if(scrollTo != null) { - int row = -1; - - for (int i = 0; i < table.getRowCount(); i++) { - if (String.valueOf(table.getValueAt(i, 0)).equals(scrollTo)) { - row = i; - } + for (int i = 0; i < table.getRowCount(); i++) { + if (String.valueOf(table.getValueAt(i, 0)).equals(key)) { + row = i; } + } - if (row > -1) { // Matched @scrollTo - table.scrollRectToVisible( - new Rectangle(0, (row * table.getRowHeight()) + table.getHeight(), 0, 0)); - } + if (row > -1) { // Matched @key + table.scrollRectToVisible( + new Rectangle(0, (row * table.getRowHeight()) + table.getHeight(), 0, 0)); + } + } + + @Override + public void onSearchQuery(@Nullable String query) { + if(this.currentMapper != null) { + this.currentMapper.onSearchQuery(query); + this.table.updateUI(); } } diff --git a/src/main/java/de/marhali/easyi18n/tabs/TreeView.java b/src/main/java/de/marhali/easyi18n/tabs/TreeView.java index 95ddecb..aeda7c1 100644 --- a/src/main/java/de/marhali/easyi18n/tabs/TreeView.java +++ b/src/main/java/de/marhali/easyi18n/tabs/TreeView.java @@ -8,19 +8,20 @@ import com.intellij.openapi.project.Project; import com.intellij.ui.components.JBScrollPane; import com.intellij.ui.treeStructure.Tree; -import de.marhali.easyi18n.service.DataStore; -import de.marhali.easyi18n.model.LocalizedNode; -import de.marhali.easyi18n.model.DataSynchronizer; -import de.marhali.easyi18n.model.Translations; +import de.marhali.easyi18n.InstanceManager; import de.marhali.easyi18n.model.KeyedTranslation; +import de.marhali.easyi18n.model.Translation; +import de.marhali.easyi18n.model.TranslationData; import de.marhali.easyi18n.model.TranslationDelete; -import de.marhali.easyi18n.model.tree.TreeModelTranslator; +import de.marhali.easyi18n.model.bus.BusListener; import de.marhali.easyi18n.action.treeview.CollapseTreeViewAction; import de.marhali.easyi18n.action.treeview.ExpandTreeViewAction; import de.marhali.easyi18n.dialog.EditDialog; import de.marhali.easyi18n.listener.DeleteKeyListener; import de.marhali.easyi18n.listener.PopupClickListener; import de.marhali.easyi18n.renderer.TreeRenderer; +import de.marhali.easyi18n.service.SettingsService; +import de.marhali.easyi18n.tabs.mapper.TreeModelMapper; import de.marhali.easyi18n.util.TreeUtil; import org.jetbrains.annotations.NotNull; @@ -36,10 +37,12 @@ import java.util.ResourceBundle; * Show translation state as tree. * @author marhali */ -public class TreeView implements DataSynchronizer { +public class TreeView implements BusListener { private final Project project; + private TreeModelMapper currentMapper; + private JPanel rootPanel; private JPanel toolBarPanel; private JPanel containerPanel; @@ -77,18 +80,28 @@ public class TreeView implements DataSynchronizer { } @Override - public void synchronize(@NotNull Translations translations, - @Nullable String searchQuery, @Nullable String scrollTo) { + public void onUpdateData(@NotNull TranslationData data) { + tree.setModel(this.currentMapper = new TreeModelMapper(data, SettingsService.getInstance(project).getState())); + } - TreeModelTranslator model = new TreeModelTranslator(project, translations, searchQuery); - tree.setModel(model); + @Override + public void onFocusKey(@Nullable String key) { + if(key != null && currentMapper != null) { + TreePath path = currentMapper.findTreePath(key); + this.tree.scrollPathToVisible(path); - if(searchQuery != null && !searchQuery.isEmpty()) { - expandAll().run(); + if(this.tree.isCollapsed(path)) { + this.tree.expandPath(path); + } } + } - if(scrollTo != null) { - tree.scrollPathToVisible(model.findTreePath(scrollTo)); + @Override + public void onSearchQuery(@Nullable String query) { + if(this.currentMapper != null) { + this.currentMapper.onSearchQuery(query); + this.expandAll().run(); + this.tree.updateUI(); } } @@ -100,10 +113,10 @@ public class TreeView implements DataSynchronizer { if(node.getUserObject() instanceof PresentationData) { String fullPath = TreeUtil.getFullPath(path); - LocalizedNode localizedNode = DataStore.getInstance(project).getTranslations().getNode(fullPath); + Translation translation = InstanceManager.get(project).store().getData().getTranslation(fullPath); - if(localizedNode != null) { - new EditDialog(project,new KeyedTranslation(fullPath, localizedNode.getValue())).showAndHandle(); + if(translation != null) { + new EditDialog(project, new KeyedTranslation(fullPath, translation)).showAndHandle(); } } } @@ -120,8 +133,9 @@ public class TreeView implements DataSynchronizer { for (TreePath path : tree.getSelectionPaths()) { String fullPath = TreeUtil.getFullPath(path); - DataStore.getInstance(project).processUpdate( - new TranslationDelete(new KeyedTranslation(fullPath, null))); + InstanceManager.get(project).processUpdate( + new TranslationDelete(new KeyedTranslation(fullPath, null)) + ); } }; } diff --git a/src/main/java/de/marhali/easyi18n/tabs/mapper/TableModelMapper.java b/src/main/java/de/marhali/easyi18n/tabs/mapper/TableModelMapper.java new file mode 100644 index 0000000..5a79f8e --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/tabs/mapper/TableModelMapper.java @@ -0,0 +1,135 @@ +package de.marhali.easyi18n.tabs.mapper; + +import de.marhali.easyi18n.model.*; +import de.marhali.easyi18n.model.bus.SearchQueryListener; + +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.event.TableModelListener; +import javax.swing.table.TableModel; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Mapping {@link TranslationData} to {@link TableModel}. + * @author marhali + */ +public class TableModelMapper implements TableModel, SearchQueryListener { + + private final @NotNull TranslationData data; + private final @NotNull List<String> locales; + private @NotNull List<String> fullKeys; + + private final @NotNull Consumer<TranslationUpdate> updater; + + public TableModelMapper(@NotNull TranslationData data, @NotNull Consumer<TranslationUpdate> updater) { + this.data = data; + this.locales = new ArrayList<>(data.getLocales()); + this.fullKeys = new ArrayList<>(data.getFullKeys()); + + this.updater = updater; + } + + @Override + public void onSearchQuery(@Nullable String query) { + if(query == null) { // Reset + this.fullKeys = new ArrayList<>(this.data.getFullKeys()); + return; + } + + query = query.toLowerCase(); + List<String> matches = new ArrayList<>(); + + for(String key : this.data.getFullKeys()) { + if(key.toLowerCase().contains(query)) { + matches.add(key); + } else { + for(String content : this.data.getTranslation(key).values()) { + if(content.toLowerCase().contains(query)) { + matches.add(key); + } + } + } + } + + this.fullKeys = matches; + } + + @Override + public int getRowCount() { + return this.fullKeys.size(); + } + + @Override + public int getColumnCount() { + return this.locales.size() + 1; // Number of locales + 1 (key column) + } + + @Nls + @Override + public String getColumnName(int columnIndex) { + if(columnIndex == 0) { + return "<html><b>Key</b></html>"; + } + + return "<html><b>" + this.locales.get(columnIndex - 1) + "</b></html>"; + } + + @Override + public Class<?> getColumnClass(int columnIndex) { + return String.class; + } + + @Override + public boolean isCellEditable(int rowIndex, int columnIndex) { + return rowIndex > 0; // Everything should be editable except the headline + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + if(columnIndex == 0) { // Keys + return this.fullKeys.get(rowIndex); + } + + String key = this.fullKeys.get(rowIndex); + String locale = this.locales.get(columnIndex - 1); + Translation translation = this.data.getTranslation(key); + + return translation == null ? null : translation.get(locale); + } + + @Override + public void setValueAt(Object aValue, int rowIndex, int columnIndex) { + String key = String.valueOf(this.getValueAt(rowIndex, 0)); + Translation translation = this.data.getTranslation(key); + + if(translation == null) { // Unknown cell + return; + } + + String newKey = columnIndex == 0 ? String.valueOf(aValue) : key; + + // Translation content update + if(columnIndex > 0) { + if(aValue == null || ((String) aValue).isEmpty()) { + translation.remove(this.locales.get(columnIndex - 1)); + } else { + translation.put(this.locales.get(columnIndex - 1), String.valueOf(aValue)); + } + } + + TranslationUpdate update = new TranslationUpdate(new KeyedTranslation(key, translation), + new KeyedTranslation(newKey, translation)); + + this.updater.accept(update); + } + + @Override + public void addTableModelListener(TableModelListener l) {} + + @Override + public void removeTableModelListener(TableModelListener l) {} +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/tabs/mapper/TreeModelMapper.java b/src/main/java/de/marhali/easyi18n/tabs/mapper/TreeModelMapper.java new file mode 100644 index 0000000..621bb10 --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/tabs/mapper/TreeModelMapper.java @@ -0,0 +1,143 @@ +package de.marhali.easyi18n.tabs.mapper; + +import com.intellij.ide.projectView.PresentationData; +import com.intellij.ui.JBColor; + +import de.marhali.easyi18n.model.SettingsState; +import de.marhali.easyi18n.model.Translation; +import de.marhali.easyi18n.model.TranslationData; +import de.marhali.easyi18n.model.TranslationNode; +import de.marhali.easyi18n.model.bus.SearchQueryListener; +import de.marhali.easyi18n.util.PathUtil; +import de.marhali.easyi18n.util.UiUtil; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.tree.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Mapping {@link TranslationData} to {@link TreeModel}. + * @author marhali + */ +public class TreeModelMapper extends DefaultTreeModel implements SearchQueryListener { + + private final TranslationData data; + private final SettingsState state; + + public TreeModelMapper(TranslationData data, SettingsState state) { + super(null); + + this.data = data; + this.state = state; + + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(); + this.generateNodes(rootNode, this.data.getRootNode()); + super.setRoot(rootNode); + } + + @Override + public void onSearchQuery(@Nullable String query) { + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(); + TranslationData shadow = new TranslationData(this.state.isSortKeys(), this.state.isNestedKeys()); + + if(query == null) { + this.generateNodes(rootNode, this.data.getRootNode()); + super.setRoot(rootNode); + return; + } + + query = query.toLowerCase(); + + for(String currentKey : this.data.getFullKeys()) { + Translation translation = this.data.getTranslation(currentKey); + String loweredKey = currentKey.toLowerCase(); + + if(query.contains(loweredKey) || loweredKey.contains(query)) { + shadow.setTranslation(currentKey, translation); + continue; + } + + for(String currentContent : translation.values()) { + if(currentContent.toLowerCase().contains(query)) { + shadow.setTranslation(currentKey, translation); + break; + } + } + } + + this.generateNodes(rootNode, shadow.getRootNode()); + super.setRoot(rootNode); + } + + private void generateNodes(@NotNull DefaultMutableTreeNode parent, @NotNull TranslationNode translationNode) { + for(Map.Entry<String, TranslationNode> entry : translationNode.getChildren().entrySet()) { + String key = entry.getKey(); + TranslationNode childTranslationNode = entry.getValue(); + + if(!childTranslationNode.isLeaf()) { + // Nested node - run recursively + DefaultMutableTreeNode childNode = new DefaultMutableTreeNode(key); + this.generateNodes(childNode, childTranslationNode); + parent.add(childNode); + } else { + String previewLocale = this.state.getPreviewLocale(); + String sub = "(" + previewLocale + ": " + childTranslationNode.getValue().get(previewLocale) + ")"; + String tooltip = UiUtil.generateHtmlTooltip(childTranslationNode.getValue()); + + PresentationData data = new PresentationData(key, sub, null, null); + data.setTooltip(tooltip); + + if(childTranslationNode.getValue().size() != this.data.getLocales().size()) { + data.setForcedTextForeground(JBColor.RED); + } + + parent.add(new DefaultMutableTreeNode(data)); + } + } + } + + public @NotNull TreePath findTreePath(@NotNull String fullPath) { + List<String> sections = new PathUtil(this.state.isNestedKeys()).split(fullPath); + List<Object> nodes = new ArrayList<>(); + + TreeNode currentNode = (TreeNode) this.getRoot(); + nodes.add(currentNode); + + for(String section : sections) { + currentNode = this.findNode(currentNode, section); + + if(currentNode == null) { + break; + } + + nodes.add(currentNode); + } + + return new TreePath(nodes.toArray()); + } + + public @Nullable DefaultMutableTreeNode findNode(@NotNull TreeNode parent, @NotNull String key) { + for(int i = 0; i < parent.getChildCount(); i++) { + TreeNode child = parent.getChildAt(i); + + if(child instanceof DefaultMutableTreeNode) { + DefaultMutableTreeNode mutableChild = (DefaultMutableTreeNode) child; + String childKey = mutableChild.getUserObject().toString(); + + if(mutableChild.getUserObject() instanceof PresentationData) { + childKey = ((PresentationData) mutableChild.getUserObject()).getPresentableText(); + } + + if(childKey != null && childKey.equals(key)) { + return mutableChild; + } + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/util/IOUtil.java b/src/main/java/de/marhali/easyi18n/util/IOUtil.java deleted file mode 100644 index ac569b6..0000000 --- a/src/main/java/de/marhali/easyi18n/util/IOUtil.java +++ /dev/null @@ -1,71 +0,0 @@ -package de.marhali.easyi18n.util; - -import com.intellij.openapi.project.Project; -import com.intellij.openapi.vfs.LocalFileSystem; -import com.intellij.openapi.vfs.VirtualFile; -import de.marhali.easyi18n.io.implementation.*; -import de.marhali.easyi18n.io.TranslatorIO; - -import de.marhali.easyi18n.service.SettingsService; -import org.jetbrains.annotations.NotNull; - -import java.io.File; - -/** - * IO operations utility. - * @author marhali - */ -public class IOUtil { - - /** - * Determines the {@link TranslatorIO} which should be used for the specified directoryPath - * @param project Current intellij project - * @param directoryPath The full path to the parent directory which holds the translation files - * @return IO handler to use for file operations - */ - public static TranslatorIO determineFormat(@NotNull Project project, @NotNull String directoryPath) { - VirtualFile directory = LocalFileSystem.getInstance().findFileByIoFile(new File(directoryPath)); - - if(directory == null || directory.getChildren() == null) { - throw new IllegalArgumentException("Specified folder is invalid (" + directoryPath + ")"); - } - - VirtualFile[] children = directory.getChildren(); - - for(VirtualFile file : children) { - if(file.isDirectory()) { // Modularized locale files - // ATM we only support modularized JSON files - return new ModularizedJsonTranslatorIO(); - } - - if(!isFileRelevant(project, file)) { - continue; - } - - switch(file.getFileType().getDefaultExtension().toLowerCase()) { - case "json": - return new JsonTranslatorIO(); - case "properties": - return new PropertiesTranslatorIO(); - case "yml": - return new YamlTranslatorIO(); - default: - System.err.println("Unsupported i18n locale file format: " - + file.getFileType().getDefaultExtension()); - } - } - - throw new IllegalStateException("Could not determine i18n format. At least one locale file must be defined"); - } - - /** - * Checks if the provided file matches the file pattern specified by configuration - * @param project Current intellij project - * @param file File to check - * @return True if relevant otherwise false - */ - public static boolean isFileRelevant(@NotNull Project project, @NotNull VirtualFile file) { - String pattern = SettingsService.getInstance(project).getState().getFilePattern(); - return file.getName().matches(pattern); - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/util/JsonUtil.java b/src/main/java/de/marhali/easyi18n/util/JsonUtil.java deleted file mode 100644 index 48c57cf..0000000 --- a/src/main/java/de/marhali/easyi18n/util/JsonUtil.java +++ /dev/null @@ -1,97 +0,0 @@ -package de.marhali.easyi18n.util; - -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; - -import de.marhali.easyi18n.model.LocalizedNode; - -import de.marhali.easyi18n.util.array.JsonArrayUtil; -import org.apache.commons.lang.StringEscapeUtils; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; - -/** - * Json tree utilities for writing and reading {@link LocalizedNode}'s - * @author marhali - */ -public class JsonUtil { - - /** - * Creates a {@link JsonObject} based from an {@link LocalizedNode} - * @param locale Current locale - * @param parent Parent json. Can be an entire json document - * @param node The node instance - */ - public static void writeTree(String locale, JsonObject parent, LocalizedNode node) { - if(node.isLeaf() && !node.getKey().equals(LocalizedNode.ROOT_KEY)) { - if(node.getValue().get(locale) != null) { - - if(JsonArrayUtil.isArray(node.getValue().get(locale))) { - parent.add(node.getKey(), JsonArrayUtil.write(node.getValue().get(locale))); - } else { - String value = StringEscapeUtils.unescapeJava(node.getValue().get(locale)); - parent.add(node.getKey(), new JsonPrimitive(value)); - } - } - - } else { - for(LocalizedNode children : node.getChildren()) { - if(children.isLeaf()) { - writeTree(locale, parent, children); - } else { - JsonObject childrenJson = new JsonObject(); - writeTree(locale, childrenJson, children); - if(childrenJson.size() > 0) { - parent.add(children.getKey(), childrenJson); - } - } - } - } - } - - /** - * Reads a {@link JsonObject} and writes the tree into the provided {@link LocalizedNode} - * @param locale Current locale - * @param json Json to read - * @param data Node. Can be a root node - */ - public static void readTree(String locale, JsonObject json, LocalizedNode data) { - for(Map.Entry<String, JsonElement> entry : json.entrySet()) { - String key = entry.getKey(); - - try { - // Try to go one level deeper - JsonObject childObject = entry.getValue().getAsJsonObject(); - - LocalizedNode childrenNode = data.getChildren(key); - - if(childrenNode == null) { - childrenNode = new LocalizedNode(key, new ArrayList<>()); - data.addChildren(childrenNode); - } - - readTree(locale, childObject, childrenNode); - - } catch(IllegalStateException e) { // Reached end for this node - LocalizedNode leafNode = data.getChildren(key); - - if(leafNode == null) { - leafNode = new LocalizedNode(key, new HashMap<>()); - data.addChildren(leafNode); - } - - Map<String, String> messages = leafNode.getValue(); - - String value = entry.getValue().isJsonArray() - ? JsonArrayUtil.read(entry.getValue().getAsJsonArray()) - : StringUtil.escapeControls(entry.getValue().getAsString(), true); - - messages.put(locale, value); - leafNode.setValue(messages); - } - } - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/util/MapUtil.java b/src/main/java/de/marhali/easyi18n/util/MapUtil.java deleted file mode 100644 index e7a1c0d..0000000 --- a/src/main/java/de/marhali/easyi18n/util/MapUtil.java +++ /dev/null @@ -1,28 +0,0 @@ -package de.marhali.easyi18n.util; - -import de.marhali.easyi18n.model.LocalizedNode; - -import java.util.List; -import java.util.TreeMap; - -/** - * Map utilities. - * @author marhali - */ -public class MapUtil { - - /** - * Converts the provided list into a tree map. - * @param list List of nodes - * @return TreeMap based on node key and node object - */ - public static TreeMap<String, LocalizedNode> convertToTreeMap(List<LocalizedNode> list) { - TreeMap<String, LocalizedNode> map = new TreeMap<>(); - - for(LocalizedNode item : list) { - map.put(item.getKey(), item); - } - - return map; - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/util/PathUtil.java b/src/main/java/de/marhali/easyi18n/util/PathUtil.java new file mode 100644 index 0000000..64aee8e --- /dev/null +++ b/src/main/java/de/marhali/easyi18n/util/PathUtil.java @@ -0,0 +1,73 @@ +package de.marhali.easyi18n.util; + +import de.marhali.easyi18n.service.SettingsService; + +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Utility tool for split and merge translation key paths. + * Some i18n implementations require to NOT nest the translation keys. + * This util takes care of this and checks the configured setting for this case. + * @author marhali + */ +public class PathUtil { + + public static final char DELIMITER = '.'; + + private final boolean nestKeys; + + public PathUtil(boolean nestKeys) { + this.nestKeys = nestKeys; + } + + public PathUtil(Project project) { + this.nestKeys = SettingsService.getInstance(project).getState().isNestedKeys(); + } + + public @NotNull List<String> split(@NotNull String path) { + // Does not contain any sections or key nesting is disabled + if(!path.contains(String.valueOf(DELIMITER)) || !nestKeys) { + return new ArrayList<>(Collections.singletonList(path)); + } + + return new ArrayList<>(Arrays.asList(path.split("\\" + DELIMITER))); + } + + public @NotNull String concat(@NotNull List<String> sections) { + StringBuilder builder = new StringBuilder(); + + // For disabled key nesting this should be only one section + for(String section : sections) { + if(builder.length() > 0) { + builder.append(DELIMITER); + } + + builder.append(section); + } + + return builder.toString(); + } + + public @NotNull String append(@NotNull String parentPath, @NotNull String children) { + StringBuilder builder = new StringBuilder(parentPath); + + if(builder.length() > 0) { // Only add delimiter between parent and child if parent is NOT empty + builder.append(DELIMITER); + } + + return builder.append(children).toString(); + } + + @Override + public String toString() { + return "PathUtil{" + + "nestKeys=" + nestKeys + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/util/SortedProperties.java b/src/main/java/de/marhali/easyi18n/util/SortedProperties.java deleted file mode 100644 index 5b8f9c7..0000000 --- a/src/main/java/de/marhali/easyi18n/util/SortedProperties.java +++ /dev/null @@ -1,31 +0,0 @@ -package de.marhali.easyi18n.util; - -import java.util.*; - -/** - * Applies sorting to {@link Properties} files. - * @author marhali - */ -public class SortedProperties extends Properties { - - @Override - public Set<Object> keySet() { - return Collections.unmodifiableSet(new TreeSet<>(super.keySet())); - } - - @Override - public Set<Map.Entry<Object, Object>> entrySet() { - TreeMap<Object, Object> sorted = new TreeMap<>(); - - for(Object key : super.keySet()) { - sorted.put(key, get(key)); - } - - return sorted.entrySet(); - } - - @Override - public synchronized Enumeration<Object> keys() { - return Collections.enumeration(new TreeSet<>(super.keySet())); - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/util/TranslationsUtil.java b/src/main/java/de/marhali/easyi18n/util/TranslationsUtil.java deleted file mode 100644 index bbe21a7..0000000 --- a/src/main/java/de/marhali/easyi18n/util/TranslationsUtil.java +++ /dev/null @@ -1,47 +0,0 @@ -package de.marhali.easyi18n.util; - -import org.jetbrains.annotations.NotNull; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -/** - * Utility tool to support the translations instance - * @author marhali - */ -public class TranslationsUtil { - - /** - * Retrieve all sections for the specified path (mostly fullPath) - * @param path The path - * @return Sections. E.g. input user.username.title -> Output: [user, username, title] - */ - public static @NotNull List<String> getSections(@NotNull String path) { - if(!path.contains(".")) { - return new ArrayList<>(Collections.singletonList(path)); - } - - return new ArrayList<>(Arrays.asList(path.split("\\."))); - } - - /** - * Concatenate the given sections to a single string. - * @param sections The sections - * @return Full path. E.g. input [user, username, title] -> Output: user.username.title - */ - public static @NotNull String sectionsToFullPath(@NotNull List<String> sections) { - StringBuilder builder = new StringBuilder(); - - for (String section : sections) { - if(builder.length() > 0) { - builder.append("."); - } - - builder.append(section); - } - - return builder.toString(); - } -} \ No newline at end of file diff --git a/src/main/java/de/marhali/easyi18n/util/TreeUtil.java b/src/main/java/de/marhali/easyi18n/util/TreeUtil.java index 6d9fe91..03e3821 100644 --- a/src/main/java/de/marhali/easyi18n/util/TreeUtil.java +++ b/src/main/java/de/marhali/easyi18n/util/TreeUtil.java @@ -1,7 +1,6 @@ package de.marhali.easyi18n.util; import com.intellij.ide.projectView.PresentationData; -import de.marhali.easyi18n.model.LocalizedNode; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.TreePath; @@ -20,19 +19,18 @@ public class TreeUtil { public static String getFullPath(TreePath path) { StringBuilder builder = new StringBuilder(); - for (Object obj : path.getPath()) { DefaultMutableTreeNode node = (DefaultMutableTreeNode) obj; Object value = node.getUserObject(); String section = value instanceof PresentationData ? ((PresentationData) value).getPresentableText() : String.valueOf(value); - if(section == null || section.equals(LocalizedNode.ROOT_KEY)) { // Skip root node + if(value == null) { // Skip empty sections continue; } if(builder.length() != 0) { - builder.append("."); + builder.append(PathUtil.DELIMITER); } builder.append(section); diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 598db67..9d1fd69 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -1,3 +1,4 @@ +<!-- Plugin Configuration File. Read more: https://plugins.jetbrains.com/docs/intellij/plugin-configuration-file.html --> <idea-plugin url="https://github.com/marhali/easy-i18n"> <id>de.marhali.easyi18n</id> <name>Easy I18n</name> @@ -10,7 +11,7 @@ <depends optional="true" config-file="de.marhali.easyi18n-kotlin.xml">org.jetbrains.kotlin</depends> <extensions defaultExtensionNs="com.intellij"> - <toolWindow id="Easy I18n" anchor="bottom" factoryClass="de.marhali.easyi18n.TranslatorToolWindowFactory" /> + <toolWindow id="Easy I18n" anchor="bottom" factoryClass="de.marhali.easyi18n.service.TranslatorToolWindowFactory" /> <projectService serviceImplementation="de.marhali.easyi18n.service.SettingsService" /> <completion.contributor language="any" diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties index 2be4dcf..7292936 100644 --- a/src/main/resources/messages.properties +++ b/src/main/resources/messages.properties @@ -7,7 +7,7 @@ action.add=Add Translation action.edit=Edit Translation action.reload=Reload From Disk action.settings=Settings -action.search=Search Key... +action.search=Search... action.delete=Delete translation.key=Key translation.locales=Locales @@ -16,4 +16,6 @@ settings.path.text=Locales directory settings.path.file-pattern=Translation file pattern settings.path.prefix=Path prefix settings.preview=Preview locale +settings.keys.sort=Sort translation keys alphabetically +settings.keys.nested=Nest translation keys if possible settings.editor.assistance=I18n key completion, annotation and reference inside editor \ No newline at end of file diff --git a/src/test/java/de/marhali/easyi18n/TranslationDataTest.java b/src/test/java/de/marhali/easyi18n/TranslationDataTest.java new file mode 100644 index 0000000..7e09eed --- /dev/null +++ b/src/test/java/de/marhali/easyi18n/TranslationDataTest.java @@ -0,0 +1,270 @@ +package de.marhali.easyi18n; + +import de.marhali.easyi18n.model.Translation; +import de.marhali.easyi18n.model.TranslationData; +import de.marhali.easyi18n.model.TranslationNode; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.*; + +/** + * Unit tests for {@link TranslationData} in combination with {@link TranslationNode} + * @author marhali + */ +public class TranslationDataTest { + + private final int numOfTranslations = 18; + + private void addTranslations(TranslationData data) { + data.setTranslation("zulu", new Translation("en", "test")); + data.setTranslation("gamma", new Translation("en", "test")); + + data.setTranslation("foxtrot.super.long.key", new Translation("en", "test")); + + data.setTranslation("bravo.b", new Translation("en", "test")); + data.setTranslation("bravo.c", new Translation("en", "test")); + data.setTranslation("bravo.a", new Translation("en", "test")); + data.setTranslation("bravo.d", new Translation("en", "test")); + data.setTranslation("bravo.long.bravo", new Translation("en", "test")); + data.setTranslation("bravo.long.charlie.a", new Translation("en", "test")); + data.setTranslation("bravo.long.alpha", new Translation("en", "test")); + + data.setTranslation("alpha.b", new Translation("en", "test")); + data.setTranslation("alpha.c", new Translation("en", "test")); + data.setTranslation("alpha.a", new Translation("en", "test")); + data.setTranslation("alpha.d", new Translation("en", "test")); + + data.setTranslation("charlie.b", new Translation("en", "test")); + data.setTranslation("charlie.c", new Translation("en", "test")); + data.setTranslation("charlie.a", new Translation("en", "test")); + data.setTranslation("charlie.d", new Translation("en", "test")); + } + + @Test + public void testKeySorting() { + TranslationData data = new TranslationData(true, true); + this.addTranslations(data); + + Set<String> expectation = new LinkedHashSet<>(Arrays.asList( + "alpha.a", "alpha.b", "alpha.c", "alpha.d", + "bravo.a", "bravo.b", "bravo.c", "bravo.d", + "bravo.long.alpha", "bravo.long.bravo", "bravo.long.charlie.a", + "charlie.a", "charlie.b", "charlie.c", "charlie.d", + "foxtrot.super.long.key", + "gamma", + "zulu" + )); + + Assert.assertEquals(data.getFullKeys(), expectation); + } + + @Test + public void testKeyUnordered() { + TranslationData data = new TranslationData(false, true); + this.addTranslations(data); + + Set<String> expectation = new LinkedHashSet<>(Arrays.asList( + "zulu", + "gamma", + "foxtrot.super.long.key", + "bravo.b", "bravo.c", "bravo.a", "bravo.d", + "bravo.long.bravo", "bravo.long.charlie.a", "bravo.long.alpha", + "alpha.b", "alpha.c", "alpha.a", "alpha.d", + "charlie.b", "charlie.c", "charlie.a", "charlie.d" + )); + + Assert.assertEquals(data.getFullKeys(), expectation); + } + + @Test + public void testKeyNesting() { + TranslationData data = new TranslationData(true, true); + + data.setTranslation("nested.alpha", new Translation("en", "test")); + data.setTranslation("nested.bravo", new Translation("en", "test")); + data.setTranslation("other.alpha", new Translation("en", "test")); + data.setTranslation("other.bravo", new Translation("en", "test")); + + Assert.assertEquals(data.getRootNode().getChildren().size(), 2); + + for(TranslationNode node : data.getRootNode().getChildren().values()) { + Assert.assertFalse(node.isLeaf()); + } + } + + @Test + public void testKeyNonNested() { + TranslationData data = new TranslationData(true, false); + this.addTranslations(data); + + Assert.assertEquals(data.getRootNode().getChildren().size(), this.numOfTranslations); + + for(TranslationNode node : data.getRootNode().getChildren().values()) { + Assert.assertTrue(node.isLeaf()); + } + } + + @Test + public void testDeleteNested() { + TranslationData data = new TranslationData(true, true); + + Translation value = new Translation("en", "test"); + + data.setTranslation("alpha", value); + data.setTranslation("nested.alpha", value); + data.setTranslation("nested.long.bravo", value); + + Assert.assertEquals(data.getFullKeys().size(), 3); + + data.setTranslation("alpha", null); + data.setTranslation("nested.alpha", null); + data.setTranslation("nested.long.bravo", null); + + Assert.assertEquals(data.getFullKeys().size(), 0); + Assert.assertNull(data.getTranslation("alpha")); + Assert.assertNull(data.getTranslation("nested.alpha")); + Assert.assertNull(data.getTranslation("nested.long.bravo")); + } + + @Test + public void testDeleteNonNested() { + TranslationData data = new TranslationData(true, false); + + Translation value = new Translation("en", "test"); + + data.setTranslation("alpha", value); + data.setTranslation("nested.alpha", value); + data.setTranslation("nested.long.bravo", value); + + Assert.assertEquals(data.getFullKeys().size(), 3); + + data.setTranslation("alpha", null); + data.setTranslation("nested.alpha", null); + data.setTranslation("nested.long.bravo", null); + + Assert.assertEquals(data.getFullKeys().size(), 0); + Assert.assertNull(data.getTranslation("alpha")); + Assert.assertNull(data.getTranslation("nested.alpha")); + Assert.assertNull(data.getTranslation("nested.long.bravo")); + } + + @Test + public void testRecurseDeleteNonNested() { + TranslationData data = new TranslationData(true, false); + this.addTranslations(data); + + data.setTranslation("foxtrot.super.long.key", null); + + Assert.assertNull(data.getTranslation("foxtrot.super.long.key")); + Assert.assertNull(data.getRootNode().getChildren().get("foxtrot")); + } + + @Test + public void testRecurseDeleteNested() { + TranslationData data = new TranslationData(true, true); + this.addTranslations(data); + + data.setTranslation("foxtrot.super.long.key", null); + + Assert.assertNull(data.getTranslation("foxtrot.super.long.key")); + Assert.assertNull(data.getRootNode().getChildren().get("foxtrot")); + } + + @Test + public void testOverwriteNonNested() { + TranslationData data = new TranslationData(true, false); + + Translation before = new Translation("en", "before"); + Translation after = new Translation("en", "after"); + + data.setTranslation("alpha", before); + data.setTranslation("nested.alpha", before); + data.setTranslation("nested.long.bravo", before); + + Assert.assertEquals(data.getTranslation("alpha"), before); + Assert.assertEquals(data.getTranslation("alpha"), before); + Assert.assertEquals(data.getTranslation("alpha"), before); + + data.setTranslation("alpha", after); + data.setTranslation("nested.alpha", after); + data.setTranslation("nested.long.bravo", after); + + Assert.assertEquals(data.getTranslation("alpha"), after); + Assert.assertEquals(data.getTranslation("alpha"), after); + Assert.assertEquals(data.getTranslation("alpha"), after); + } + + @Test + public void testOverwriteNested() { + TranslationData data = new TranslationData(true, true); + + Translation before = new Translation("en", "before"); + Translation after = new Translation("en", "after"); + + data.setTranslation("alpha", before); + data.setTranslation("nested.alpha", before); + data.setTranslation("nested.long.bravo", before); + + Assert.assertEquals(data.getTranslation("alpha"), before); + Assert.assertEquals(data.getTranslation("alpha"), before); + Assert.assertEquals(data.getTranslation("alpha"), before); + + data.setTranslation("alpha", after); + data.setTranslation("nested.alpha", after); + data.setTranslation("nested.long.bravo", after); + + Assert.assertEquals(data.getTranslation("alpha"), after); + Assert.assertEquals(data.getTranslation("alpha"), after); + Assert.assertEquals(data.getTranslation("alpha"), after); + } + + @Test + public void testRecurseTransformNested() { + TranslationData data = new TranslationData(true, true); + + Translation value = new Translation("en", "test"); + + data.setTranslation("alpha.nested.key", value); + data.setTranslation("alpha.other", value); + data.setTranslation("bravo", value); + + Assert.assertEquals(data.getFullKeys().size(), 3); + + data.setTranslation("alpha.nested", value); + data.setTranslation("alpha.other.new", value); + data.setTranslation("bravo", null); + + Assert.assertEquals(data.getFullKeys().size(), 2); + Assert.assertNull(data.getTranslation("alpha.nested.key")); + Assert.assertNull(data.getTranslation("alpha.other")); + Assert.assertNull(data.getTranslation("bravo")); + Assert.assertEquals(data.getTranslation("alpha.nested"), value); + Assert.assertEquals(data.getTranslation("alpha.other.new"), value); + } + + @Test + public void testRecurseTransformNonNested() { + TranslationData data = new TranslationData(true, false); + + Translation value = new Translation("en", "test"); + + data.setTranslation("alpha.nested.key", value); + data.setTranslation("alpha.other", value); + data.setTranslation("bravo", value); + + Assert.assertEquals(data.getFullKeys().size(), 3); + + data.setTranslation("alpha.nested", value); + data.setTranslation("alpha.other.new", value); + data.setTranslation("bravo", null); + + Assert.assertEquals(data.getFullKeys().size(), 4); + Assert.assertNull(data.getTranslation("bravo")); + Assert.assertEquals(data.getTranslation("alpha.nested.key"), value); + Assert.assertEquals(data.getTranslation("alpha.other"), value); + Assert.assertEquals(data.getTranslation("alpha.nested"), value); + Assert.assertEquals(data.getTranslation("alpha.other.new"), value); + } +} \ No newline at end of file diff --git a/src/test/java/de/marhali/easyi18n/mapper/AbstractMapperTest.java b/src/test/java/de/marhali/easyi18n/mapper/AbstractMapperTest.java new file mode 100644 index 0000000..9771660 --- /dev/null +++ b/src/test/java/de/marhali/easyi18n/mapper/AbstractMapperTest.java @@ -0,0 +1,45 @@ +package de.marhali.easyi18n.mapper; + +import de.marhali.easyi18n.model.Translation; + +import org.junit.Test; + +/** + * Defines test cases for {@link de.marhali.easyi18n.model.TranslationNode} mapping. + * @author marhali + */ +public abstract class AbstractMapperTest { + + protected final String specialCharacters = "Special characters: äü@Öä€/$§;.-?+~#```'' end"; + protected final String arraySimple = "!arr[first;second]"; + protected final String arrayEscaped = "!arr[first\\;element;second element;third\\;element]"; + protected final String leadingSpace = " leading space"; + + @Test + public abstract void testNonSorting(); + + @Test + public abstract void testSorting(); + + @Test + public abstract void testArrays(); + + @Test + public abstract void testSpecialCharacters(); + + @Test + public abstract void testNestedKeys(); + + @Test + public abstract void testNonNestedKeys(); + + @Test + public abstract void testLeadingSpace(); + + @Test + public abstract void testNumbers(); + + protected Translation create(String content) { + return new Translation("en", content); + } +} \ No newline at end of file diff --git a/src/test/java/de/marhali/easyi18n/mapper/JsonMapperTest.java b/src/test/java/de/marhali/easyi18n/mapper/JsonMapperTest.java new file mode 100644 index 0000000..656244d --- /dev/null +++ b/src/test/java/de/marhali/easyi18n/mapper/JsonMapperTest.java @@ -0,0 +1,159 @@ +package de.marhali.easyi18n.mapper; + +import com.google.gson.JsonObject; + +import com.google.gson.JsonPrimitive; + +import de.marhali.easyi18n.io.json.JsonArrayMapper; +import de.marhali.easyi18n.io.json.JsonMapper; +import de.marhali.easyi18n.model.TranslationData; + +import org.apache.commons.lang.StringEscapeUtils; +import org.junit.Assert; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Unit tests for {@link de.marhali.easyi18n.io.json.JsonMapper} + * @author marhali + */ +public class JsonMapperTest extends AbstractMapperTest { + + @Override + public void testNonSorting() { + JsonObject input = new JsonObject(); + input.add("zulu", new JsonPrimitive("test")); + input.add("alpha", new JsonPrimitive("test")); + input.add("bravo", new JsonPrimitive("test")); + + TranslationData data = new TranslationData(false, true); + JsonMapper.read("en", input, data.getRootNode()); + + JsonObject output = new JsonObject(); + JsonMapper.write("en", output, data.getRootNode()); + + Set<String> expect = new LinkedHashSet<>(Arrays.asList("zulu", "alpha", "bravo")); + Assert.assertEquals(expect, output.keySet()); + } + + @Override + public void testSorting() { + JsonObject input = new JsonObject(); + input.add("zulu", new JsonPrimitive("test")); + input.add("alpha", new JsonPrimitive("test")); + input.add("bravo", new JsonPrimitive("test")); + + TranslationData data = new TranslationData(true, true); + JsonMapper.read("en", input, data.getRootNode()); + + JsonObject output = new JsonObject(); + JsonMapper.write("en", output, data.getRootNode()); + + Set<String> expect = new LinkedHashSet<>(Arrays.asList("alpha", "bravo", "zulu")); + Assert.assertEquals(expect, output.keySet()); + } + + @Override + public void testArrays() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("simple", create(arraySimple)); + data.setTranslation("escaped", create(arrayEscaped)); + + JsonObject output = new JsonObject(); + JsonMapper.write("en", output, data.getRootNode()); + + Assert.assertTrue(output.get("simple").isJsonArray()); + Assert.assertEquals(arraySimple, JsonArrayMapper.read(output.get("simple").getAsJsonArray())); + Assert.assertTrue(output.get("escaped").isJsonArray()); + Assert.assertEquals(arrayEscaped, StringEscapeUtils.unescapeJava(JsonArrayMapper.read(output.get("escaped").getAsJsonArray()))); + + TranslationData input = new TranslationData(true, true); + JsonMapper.read("en", output, input.getRootNode()); + + Assert.assertTrue(JsonArrayMapper.isArray(input.getTranslation("simple").get("en"))); + Assert.assertTrue(JsonArrayMapper.isArray(input.getTranslation("escaped").get("en"))); + } + + @Override + public void testSpecialCharacters() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("chars", create(specialCharacters)); + + JsonObject output = new JsonObject(); + JsonMapper.write("en", output, data.getRootNode()); + + Assert.assertEquals(specialCharacters, output.get("chars").getAsString()); + + TranslationData input = new TranslationData(true, true); + JsonMapper.read("en", output, input.getRootNode()); + + Assert.assertEquals(specialCharacters, StringEscapeUtils.unescapeJava(input.getTranslation("chars").get("en"))); + } + + @Override + public void testNestedKeys() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("nested.key.section", create("test")); + + JsonObject output = new JsonObject(); + JsonMapper.write("en", output, data.getRootNode()); + + Assert.assertEquals("test", output.getAsJsonObject("nested").getAsJsonObject("key").get("section").getAsString()); + + TranslationData input = new TranslationData(true, true); + JsonMapper.read("en", output, input.getRootNode()); + + Assert.assertEquals("test", input.getTranslation("nested.key.section").get("en")); + } + + @Override + public void testNonNestedKeys() { + TranslationData data = new TranslationData(true, false); + data.setTranslation("long.key.with.many.sections", create("test")); + + JsonObject output = new JsonObject(); + JsonMapper.write("en", output, data.getRootNode()); + + Assert.assertTrue(output.has("long.key.with.many.sections")); + + TranslationData input = new TranslationData(true, false); + JsonMapper.read("en", output, input.getRootNode()); + + Assert.assertEquals("test", input.getTranslation("long.key.with.many.sections").get("en")); + } + + @Override + public void testLeadingSpace() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("space", create(leadingSpace)); + + JsonObject output = new JsonObject(); + JsonMapper.write("en", output, data.getRootNode()); + + Assert.assertEquals(leadingSpace, output.get("space").getAsString()); + + TranslationData input = new TranslationData(true, true); + JsonMapper.read("en", output, input.getRootNode()); + + Assert.assertEquals(leadingSpace, input.getTranslation("space").get("en")); + } + + @Override + public void testNumbers() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("numbered", create("15000")); + + JsonObject output = new JsonObject(); + JsonMapper.write("en", output, data.getRootNode()); + + Assert.assertEquals(15000, output.get("numbered").getAsNumber()); + + JsonObject input = new JsonObject(); + input.addProperty("numbered", 143.23); + JsonMapper.read("en", input, data.getRootNode()); + + Assert.assertEquals("143.23", data.getTranslation("numbered").get("en")); + } +} \ No newline at end of file diff --git a/src/test/java/de/marhali/easyi18n/mapper/PropertiesMapperTest.java b/src/test/java/de/marhali/easyi18n/mapper/PropertiesMapperTest.java new file mode 100644 index 0000000..e883b3e --- /dev/null +++ b/src/test/java/de/marhali/easyi18n/mapper/PropertiesMapperTest.java @@ -0,0 +1,156 @@ +package de.marhali.easyi18n.mapper; + +import de.marhali.easyi18n.io.properties.PropertiesArrayMapper; +import de.marhali.easyi18n.io.properties.PropertiesMapper; +import de.marhali.easyi18n.io.properties.SortableProperties; +import de.marhali.easyi18n.model.TranslationData; + +import org.apache.commons.lang.StringEscapeUtils; +import org.junit.Assert; + +import java.util.*; + +/** + * Unit tests for {@link de.marhali.easyi18n.io.properties.PropertiesMapper} + * @author marhali + */ +public class PropertiesMapperTest extends AbstractMapperTest { + + @Override + public void testNonSorting() { + SortableProperties input = new SortableProperties(false); + input.setProperty("zulu", "test"); + input.setProperty("alpha", "test"); + input.setProperty("bravo", "test"); + + TranslationData data = new TranslationData(false, true); + PropertiesMapper.read("en", input, data); + + SortableProperties output = new SortableProperties(false); + PropertiesMapper.write("en", output, data); + + List<String> expect = Arrays.asList("zulu", "alpha", "bravo"); + Assert.assertEquals(expect, new ArrayList<>(output.keySet())); + } + + @Override + public void testSorting() { + SortableProperties input = new SortableProperties(true); + input.setProperty("zulu", "test"); + input.setProperty("alpha", "test"); + input.setProperty("bravo", "test"); + + TranslationData data = new TranslationData(true, true); + PropertiesMapper.read("en", input, data); + + SortableProperties output = new SortableProperties(true); + PropertiesMapper.write("en", output, data); + + List<String> expect = Arrays.asList("alpha", "bravo", "zulu"); + Assert.assertEquals(expect, new ArrayList<>(output.keySet())); + } + + @Override + public void testArrays() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("simple", create(arraySimple)); + data.setTranslation("escaped", create(arrayEscaped)); + + SortableProperties output = new SortableProperties(true); + PropertiesMapper.write("en", output, data); + + Assert.assertTrue(output.get("simple") instanceof String[]); + Assert.assertEquals(arraySimple, PropertiesArrayMapper.read((String[]) output.get("simple"))); + Assert.assertTrue(output.get("escaped") instanceof String[]); + Assert.assertEquals(arrayEscaped, StringEscapeUtils.unescapeJava(PropertiesArrayMapper.read((String[]) output.get("escaped")))); + + TranslationData input = new TranslationData(true, true); + PropertiesMapper.read("en", output, input); + + Assert.assertTrue(PropertiesArrayMapper.isArray(input.getTranslation("simple").get("en"))); + Assert.assertTrue(PropertiesArrayMapper.isArray(input.getTranslation("escaped").get("en"))); + } + + @Override + public void testSpecialCharacters() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("chars", create(specialCharacters)); + + SortableProperties output = new SortableProperties(true); + PropertiesMapper.write("en", output, data); + + Assert.assertEquals(specialCharacters, output.get("chars")); + + TranslationData input = new TranslationData(true, true); + PropertiesMapper.read("en", output, input); + + Assert.assertEquals(specialCharacters, StringEscapeUtils.unescapeJava(input.getTranslation("chars").get("en"))); + } + + @Override + public void testNestedKeys() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("nested.key.sections", create("test")); + + SortableProperties output = new SortableProperties(true); + PropertiesMapper.write("en", output, data); + + Assert.assertEquals("test", output.get("nested.key.sections")); + + TranslationData input = new TranslationData(true, true); + PropertiesMapper.read("en", output, input); + + Assert.assertTrue(input.getRootNode().getChildren().containsKey("nested")); + Assert.assertEquals("test", input.getTranslation("nested.key.sections").get("en")); + } + + @Override + public void testNonNestedKeys() { + TranslationData data = new TranslationData(true, false); + data.setTranslation("long.key.with.many.sections", create("test")); + + SortableProperties output = new SortableProperties(true); + PropertiesMapper.write("en", output, data); + + Assert.assertNotNull(output.get("long.key.with.many.sections")); + + TranslationData input = new TranslationData(true, false); + PropertiesMapper.read("en", output, input); + + Assert.assertEquals("test", input.getRootNode().getChildren() + .get("long.key.with.many.sections").getValue().get("en")); + } + + @Override + public void testLeadingSpace() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("space", create(leadingSpace)); + + SortableProperties output = new SortableProperties(true); + PropertiesMapper.write("en", output, data); + + Assert.assertEquals(leadingSpace, output.get("space")); + + TranslationData input = new TranslationData(true, true); + PropertiesMapper.read("en", output, input); + + Assert.assertEquals(leadingSpace, input.getTranslation("space").get("en")); + } + + @Override + public void testNumbers() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("numbered", create("15000")); + + SortableProperties output = new SortableProperties(true); + PropertiesMapper.write("en", output, data); + + Assert.assertEquals(15000, output.get("numbered")); + + SortableProperties input = new SortableProperties(true); + input.put("numbered", 143.23); + PropertiesMapper.read("en", input, data); + + Assert.assertEquals("143.23", data.getTranslation("numbered").get("en")); + } +} \ No newline at end of file diff --git a/src/test/java/de/marhali/easyi18n/mapper/YamlMapperTest.java b/src/test/java/de/marhali/easyi18n/mapper/YamlMapperTest.java new file mode 100644 index 0000000..77d1bcd --- /dev/null +++ b/src/test/java/de/marhali/easyi18n/mapper/YamlMapperTest.java @@ -0,0 +1,158 @@ +package de.marhali.easyi18n.mapper; + +import de.marhali.easyi18n.io.yaml.YamlArrayMapper; +import de.marhali.easyi18n.io.yaml.YamlMapper; +import de.marhali.easyi18n.model.TranslationData; +import org.apache.commons.lang.StringEscapeUtils; + +import org.junit.Assert; + +import thito.nodeflow.config.MapSection; +import thito.nodeflow.config.Section; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Unit tests for {@link de.marhali.easyi18n.io.yaml.YamlMapper} + * @author marhali + */ +public class YamlMapperTest extends AbstractMapperTest { + + @Override + public void testNonSorting() { + Section input = new MapSection(); + input.set("zulu", "test"); + input.set("alpha", "test"); + input.set("bravo", "test"); + + TranslationData data = new TranslationData(false, true); + YamlMapper.read("en", input, data.getRootNode()); + + Section output = new MapSection(); + YamlMapper.write("en", output, data.getRootNode()); + + Set<String> expect = new LinkedHashSet<>(Arrays.asList("zulu", "alpha", "bravo")); + Assert.assertEquals(expect, output.getKeys()); + } + + @Override + public void testSorting() { + Section input = new MapSection(); + input.set("zulu", "test"); + input.set("alpha", "test"); + input.set("bravo", "test"); + + TranslationData data = new TranslationData(true, true); + YamlMapper.read("en", input, data.getRootNode()); + + Section output = new MapSection(); + YamlMapper.write("en", output, data.getRootNode()); + + Set<String> expect = new LinkedHashSet<>(Arrays.asList("alpha", "bravo", "zulu")); + Assert.assertEquals(expect, output.getKeys()); + } + + @Override + public void testArrays() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("simple", create(arraySimple)); + data.setTranslation("escaped", create(arrayEscaped)); + + Section output = new MapSection(); + YamlMapper.write("en", output, data.getRootNode()); + + Assert.assertTrue(output.isList("simple")); + Assert.assertEquals(arraySimple, YamlArrayMapper.read(output.getList("simple").get())); + Assert.assertTrue(output.isList("escaped")); + Assert.assertEquals(arrayEscaped, StringEscapeUtils.unescapeJava(YamlArrayMapper.read(output.getList("escaped").get()))); + + TranslationData input = new TranslationData(true, true); + YamlMapper.read("en", output, input.getRootNode()); + + Assert.assertTrue(YamlArrayMapper.isArray(input.getTranslation("simple").get("en"))); + Assert.assertTrue(YamlArrayMapper.isArray(input.getTranslation("escaped").get("en"))); + } + + @Override + public void testSpecialCharacters() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("chars", create(specialCharacters)); + + Section output = new MapSection(); + YamlMapper.write("en", output, data.getRootNode()); + + Assert.assertEquals(specialCharacters, output.getString("chars").get()); + + TranslationData input = new TranslationData(true, true); + YamlMapper.read("en", output, input.getRootNode()); + + Assert.assertEquals(specialCharacters, StringEscapeUtils.unescapeJava(input.getTranslation("chars").get("en"))); + } + + @Override + public void testNestedKeys() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("nested.key.section", create("test")); + + Section output = new MapSection(); + YamlMapper.write("en", output, data.getRootNode()); + + Assert.assertEquals("test", output.getString("nested.key.section").get()); + + TranslationData input = new TranslationData(true, true); + YamlMapper.read("en", output, input.getRootNode()); + + Assert.assertEquals("test", input.getTranslation("nested.key.section").get("en")); + } + + @Override + public void testNonNestedKeys() { + TranslationData data = new TranslationData(true, false); + data.setTranslation("long.key.with.many.sections", create("test")); + + Section output = new MapSection(); + YamlMapper.write("en", output, data.getRootNode()); + + Assert.assertTrue(output.getKeys().contains("long.key.with.many.sections")); + + TranslationData input = new TranslationData(true, false); + YamlMapper.read("en", output, input.getRootNode()); + + Assert.assertEquals("test", input.getTranslation("long.key.with.many.sections").get("en")); + } + + @Override + public void testLeadingSpace() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("space", create(leadingSpace)); + + Section output = new MapSection(); + YamlMapper.write("en", output, data.getRootNode()); + + Assert.assertEquals(leadingSpace, output.getString("space").get()); + + TranslationData input = new TranslationData(true, true); + YamlMapper.read("en", output, input.getRootNode()); + + Assert.assertEquals(leadingSpace, input.getTranslation("space").get("en")); + } + + @Override + public void testNumbers() { + TranslationData data = new TranslationData(true, true); + data.setTranslation("numbered", create("15000")); + + Section output = new MapSection(); + YamlMapper.write("en", output, data.getRootNode()); + + Assert.assertEquals(15000, output.getInteger("numbered").get().intValue()); + + Section input = new MapSection(); + input.set("numbered", 143.23); + YamlMapper.read("en", input, data.getRootNode()); + + Assert.assertEquals("143.23", data.getTranslation("numbered").get("en")); + } +} \ No newline at end of file