Explorar o código

Merge pull request #12511 from logseq/enhance/i18n

Improve i18n key naming, tooling, and translation coverage
Tienson Qin hai 5 días
pai
achega
7ee00cb79c
Modificáronse 100 ficheiros con 5510 adicións e 1638 borrados
  1. 167 0
      .agents/skills/logseq-i18n/SKILL.md
  2. 1 1
      .github/workflows/build-desktop-release.yml
  3. 4 1
      .github/workflows/build.yml
  4. 231 0
      .github/workflows/update-i18n-lint.yml
  5. 371 0
      .i18n-lint.toml
  6. 3 0
      AGENTS.md
  7. 1 1
      README.md
  8. 9 0
      bb.edn
  9. 64 0
      bin/logseq-i18n-lint
  10. BIN=BIN
      bin/logseq-i18n-lint-aarch64-linux
  11. BIN=BIN
      bin/logseq-i18n-lint-aarch64-macos
  12. BIN=BIN
      bin/logseq-i18n-lint-aarch64-windows.exe
  13. BIN=BIN
      bin/logseq-i18n-lint-x86_64-linux
  14. BIN=BIN
      bin/logseq-i18n-lint-x86_64-macos
  15. BIN=BIN
      bin/logseq-i18n-lint-x86_64-windows.exe
  16. 9 10
      clj-e2e/src/logseq/e2e/graph.clj
  17. 2 2
      clj-e2e/src/logseq/e2e/page.clj
  18. 44 9
      clj-e2e/src/logseq/e2e/settings.clj
  19. 7 3
      clj-e2e/src/logseq/e2e/util.clj
  20. 6 6
      clj-e2e/test/logseq/e2e/fixtures.clj
  21. 1 1
      clj-e2e/test/logseq/e2e/plugins_marketplace_test.clj
  22. 3 3
      clj-e2e/test/logseq/e2e/property_basic_test.clj
  23. 1 1
      deps/cli/README.md
  24. 4 1
      deps/common/src/logseq/common/config.cljs
  25. 1 0
      deps/common/src/logseq/common/date.cljs
  26. 8 0
      deps/db/src/logseq/db.cljs
  27. 210 188
      deps/db/src/logseq/db/frontend/property.cljs
  28. 10 0
      deps/db/test/logseq/db_test.cljs
  29. 4 4
      deps/graph-parser/test/resources/exporter-test-graph/ignored/about.org
  30. 2 1
      deps/outliner/src/logseq/outliner/core.cljs
  31. 4 2
      deps/outliner/src/logseq/outliner/page.cljs
  32. 23 8
      deps/outliner/src/logseq/outliner/property.cljs
  33. 42 9
      deps/outliner/src/logseq/outliner/validate.cljs
  34. 13 10
      deps/shui/src/logseq/shui/dialog/core.cljs
  35. 6 5
      deps/shui/src/logseq/shui/select/multi.cljs
  36. 108 79
      docs/contributing-to-translations.md
  37. 87 19
      docs/dev-practices.md
  38. 710 0
      docs/i18n-key-naming.md
  39. 2 1
      packages/ui/package.json
  40. 42 0
      packages/ui/src/amplify/errors.ts
  41. 1207 20
      packages/ui/src/amplify/lang.ts
  42. 38 18
      packages/ui/src/amplify/ui.tsx
  43. 48 0
      packages/ui/src/i18n.test.mts
  44. 5 5
      packages/ui/src/i18n.ts
  45. 39 2
      prompts/review.md
  46. 3 0
      scripts/package.json
  47. 0 0
      scripts/resources/schemaorg-current-https.json
  48. 226 135
      scripts/src/logseq/tasks/lang.clj
  49. 149 0
      scripts/src/logseq/tasks/lang_lint.cljc
  50. 0 21
      scripts/src/logseq/tasks/util.clj
  51. 123 0
      scripts/src/logseq/tasks/util.cljc
  52. 2 2
      scripts/test/logseq/tasks/db_graph/create_graph_with_large_sizes_test.cljs
  53. 127 0
      scripts/test/logseq/tasks/lang_test.cljs
  54. 6 2
      scripts/test/logseq/tasks/test_runner.cljs
  55. 19 0
      scripts/test/logseq/tasks/util_test.cljs
  56. 14 14
      src/electron/electron/context_menu.cljs
  57. 7 5
      src/electron/electron/core.cljs
  58. 6 9
      src/electron/electron/exceptions.cljs
  59. 20 12
      src/electron/electron/handler.cljs
  60. 40 0
      src/electron/electron/i18n.cljs
  61. 2 2
      src/electron/electron/plugin.cljs
  62. 11 4
      src/electron/electron/url.cljs
  63. 3 2
      src/electron/electron/window.cljs
  64. 11 4
      src/main/electron/listener.cljs
  65. 7 0
      src/main/electron/locale.cljs
  66. 102 86
      src/main/frontend/commands.cljs
  67. 2 3
      src/main/frontend/components/all_pages.cljs
  68. 17 17
      src/main/frontend/components/assets.cljs
  69. 80 74
      src/main/frontend/components/block.cljs
  70. 28 23
      src/main/frontend/components/bug_report.cljs
  71. 2 1
      src/main/frontend/components/class.cljs
  72. 135 114
      src/main/frontend/components/cmdk/core.cljs
  73. 39 30
      src/main/frontend/components/container.cljs
  74. 35 29
      src/main/frontend/components/content.cljs
  75. 2 1
      src/main/frontend/components/db_based/page.cljs
  76. 11 17
      src/main/frontend/components/e2ee.cljs
  77. 21 20
      src/main/frontend/components/editor.cljs
  78. 54 53
      src/main/frontend/components/export.cljs
  79. 1 1
      src/main/frontend/components/file.cljs
  80. 5 4
      src/main/frontend/components/filepicker.cljs
  81. 7 6
      src/main/frontend/components/find_in_page.cljs
  82. 68 63
      src/main/frontend/components/header.cljs
  83. 16 10
      src/main/frontend/components/icon.cljs
  84. 58 66
      src/main/frontend/components/imports.cljs
  85. 78 41
      src/main/frontend/components/left_sidebar.cljs
  86. 3 2
      src/main/frontend/components/library.cljs
  87. 3 2
      src/main/frontend/components/objects.cljs
  88. 6 6
      src/main/frontend/components/onboarding.cljs
  89. 4 4
      src/main/frontend/components/onboarding/setups.cljs
  90. 56 60
      src/main/frontend/components/page.cljs
  91. 18 15
      src/main/frontend/components/page_menu.cljs
  92. 73 65
      src/main/frontend/components/plugins.cljs
  93. 7 6
      src/main/frontend/components/plugins_settings.cljs
  94. 20 16
      src/main/frontend/components/profiler.cljs
  95. 15 14
      src/main/frontend/components/property.cljs
  96. 86 73
      src/main/frontend/components/property/config.cljs
  97. 51 51
      src/main/frontend/components/property/value.cljs
  98. 42 18
      src/main/frontend/components/query.cljs
  99. 51 24
      src/main/frontend/components/query/builder.cljs
  100. 1 1
      src/main/frontend/components/query/view.cljs

+ 167 - 0
.agents/skills/logseq-i18n/SKILL.md

@@ -0,0 +1,167 @@
+---
+name: logseq-i18n
+description: "Logseq i18n workflow for adding, renaming, reviewing, or editing translation keys and user-facing strings. Use when: writing UI code with hardcoded text, adding new user-facing strings, editing translation dict files, reviewing i18n compliance, working with notification/show!, adding translatable UI attributes, or any task involving src/resources/dicts/. Also use when the user mentions i18n, translation, localization, or hardcoded strings."
+---
+
+# Logseq i18n Skill
+
+## When This Skill Applies
+
+- Adding or editing user-facing strings in shipped UI
+- Replacing hardcoded UI text with translations
+- Adding, renaming, deduplicating, or removing keys in `src/resources/dicts/`
+- Reviewing code for i18n compliance
+- Editing `notification/show!` calls or translatable UI attributes
+- Updating i18n tooling, docs, or lint configuration
+
+## Read These First
+
+1. `docs/i18n-key-naming.md` for key ownership, reuse, and naming
+2. `.i18n-lint.toml` for lint scope, covered helpers/attributes, exclusions,
+   and allowlists
+3. `src/main/frontend/context/i18n.cljs` for the translation helper APIs
+
+Use `docs/contributing-to-translations.md` only when the task is specifically
+about locale contribution workflow.
+
+## Scope Rules
+
+- `.i18n-lint.toml` is the source of truth for which files and APIs are checked
+  for hardcoded UI text.
+- Inside that scope, all shipped user-facing UI text must be internationalized.
+- Console output does not need i18n. Keep out-of-scope developer-only `(Dev)`
+  labels inline in code/config, not in translation dictionaries.
+- If you introduce a new UI helper, alert API, translatable attribute, UI
+  namespace, or shipped surface, update `.i18n-lint.toml` so lint coverage
+  stays accurate.
+
+## Use These Helpers
+
+All translation helpers live in `frontend.context.i18n`.
+
+| Helper | Use for |
+|---|---|
+| `t` | Standard translation with preferred locale |
+| `tt` | Try multiple keys and return the first existing translation |
+| `t-en` | Force English text when UI output also needs English console/debug output |
+| `interpolate-rich-text` / `interpolate-rich-text-node` | Replace placeholders with rich-text or hiccup fragments |
+| `interpolate-sentence` | Keep a full sentence in one key while inserting placeholders and inline links |
+| `replace-newlines-with-br` | Render translated newline characters as `[:br]` nodes |
+| `locale-join-rich-text` / `locale-join-rich-text-node` | Join rich fragments with locale-aware separators |
+| `locale-format-number` / `locale-format-date` / `locale-format-time` | Locale-aware formatting for dynamic values before translation |
+
+Do not introduce parallel i18n helpers elsewhere unless the change also updates
+the shared i18n API deliberately.
+
+## Core Rules
+
+### Rule 1: No hardcoded shipped UI text
+
+If the text is user-facing and in `.i18n-lint.toml` scope, hardcoded literals in
+buttons, labels, placeholders, tooltips, dialogs, notifications, empty states,
+and similar UI are a bug.
+
+### Rule 2: Reuse keys by meaning, not by English text
+
+Search `src/resources/dicts/en.edn` first. Reuse a key only when both match:
+
+- semantic owner
+- textual role
+
+If the English text matches but the meaning differs, create a new key and follow
+`docs/i18n-key-naming.md`.
+
+### Rule 3: English source lives in `en.edn`
+
+- Add new English source text to `src/resources/dicts/en.edn`.
+- Add non-English entries only when you are also providing actual translations.
+- When renaming or removing keys, update affected locale files so stale keys do
+  not remain behind.
+- Do not copy English into non-English locale files just to fill gaps. Tongue
+  falls back to `:en`.
+
+### Rule 4: Keep complete sentences together
+
+- Prefer one translation entry per complete sentence or message.
+- Do not split rich text or linked text across multiple keys.
+- Use `interpolate-sentence` or `interpolate-rich-text*` when markup and word
+  order must stay together.
+
+### Rule 5: Prefer placeholders for plain dynamic text
+
+Use placeholder strings like `{1}` and `{2}` for plain dynamic text. Format
+arguments in the caller before passing them to `t`.
+
+### Rule 6: Function-valued translations are restricted
+
+Use function values only when:
+
+- the locale needs real logic such as conditional/plural behavior, or
+- the translation must return hiccup rich text
+
+When function values are necessary, only these are allowed inside the function
+body:
+
+- `str`
+- `when`
+- `if`
+- `=`
+
+### Rule 7: Locale details matter
+
+- Preserve emoji and icon glyphs from `en.edn` exactly.
+- Use punctuation natural to the locale.
+- Pluralization is locale-specific. Do not force English singular/plural logic
+  onto every language.
+
+## Workflow
+
+When adding or changing user-facing text:
+
+1. Use `.i18n-lint.toml` to confirm the text is in i18n scope.
+2. Search `src/resources/dicts/en.edn` for an exact semantic match.
+3. If no exact match exists, name the key with `docs/i18n-key-naming.md`.
+4. If the naming guide still does not yield one clear key, stop and ask for
+   human guidance instead of guessing.
+5. Add or update the English source text in `en.edn`.
+6. Replace the literal with the appropriate helper from
+   `frontend.context.i18n`.
+7. Add/update locale translations only where actual translations are being
+   supplied.
+8. If you introduced a new linted helper/attribute/surface, update
+   `.i18n-lint.toml`.
+
+## Validation
+
+After changing keys:
+
+```bash
+bb lang:validate-translations
+```
+
+After changing shipped UI text:
+
+```bash
+bb lang:lint-hardcoded --git-changed
+```
+
+After editing dictionary files:
+
+```bash
+bb lang:format-dicts
+```
+
+`bb lang:format-dicts` is the canonical repo formatter for dictionary key
+ordering and namespace spacing.
+
+## Common Mistakes
+
+| Mistake | Fix |
+|---|---|
+| Hardcoded UI string in a linted UI surface | Move it into `en.edn` and use a helper from `frontend.context.i18n` |
+| Reusing a key only because the English text matches | Reuse only on exact semantic owner + role match |
+| Copying English into non-English locale files | Leave the key missing unless you are adding a real translation |
+| Using `(fn ...)` for plain placeholder text | Use `"..."` with `{1}`, `{2}`, ... |
+| Splitting one sentence across multiple keys | Keep a single translation entry and interpolate into it |
+| Adding a new linted helper but not updating `.i18n-lint.toml` | Extend the TOML config in the same change |
+| Editing dict files without running `bb lang:format-dicts` | Run the formatter before finishing |

+ 1 - 1
.github/workflows/build-desktop-release.yml

@@ -666,7 +666,7 @@ jobs:
             ./*.apk
 
   release:
-    # NOTE: For now, we only have beta channel to be released on Github
+    # NOTE: For now, we only have beta channel to be released on GitHub
     if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build-target == 'beta' }}
     needs: [ build-macos-x64, build-macos-arm64, build-linux-x64, build-linux-arm64, build-windows-x64, build-windows-arm64 ]
     runs-on: ubuntu-22.04

+ 4 - 1
.github/workflows/build.yml

@@ -118,6 +118,9 @@ jobs:
       - name: Lint invalid translation entries
         run: bb lang:validate-translations
 
+      - name: Lint hardcoded user-facing strings
+        run: bb lang:lint-hardcoded
+
       - name: Lint to keep worker independent of frontend
         run: bb lint:worker-and-frontend-separate
 
@@ -171,4 +174,4 @@ jobs:
         run: cd deps/db && yarn nbb-logseq -cp src:../cli/src -m logseq.cli validate -g ../../scripts/properties-graph ../../scripts/schema-graph
 
       - name: Export a created DB graph and confirm the export is idempotent
-        run: cd deps/db && yarn nbb-logseq -cp src:../cli/src -m logseq.cli export-edn -g ../../scripts/properties-graph -f properties.edn --roundtrip
+        run: cd deps/db && yarn nbb-logseq -cp src:../cli/src -m logseq.cli export-edn -g ../../scripts/properties-graph -f properties.edn --roundtrip

+ 231 - 0
.github/workflows/update-i18n-lint.yml

@@ -0,0 +1,231 @@
+name: Update logseq-i18n-lint binaries
+
+on:
+  workflow_dispatch:
+
+permissions:
+  contents: write
+  pull-requests: write
+
+env:
+  CARGO_TERM_COLOR: always
+  LINT_REPO: ${{ github.repository_owner }}/logseq-i18n-lint
+  LINT_REF: master
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout logseq-i18n-lint source
+        uses: actions/checkout@v4
+        with:
+          repository: ${{ env.LINT_REPO }}
+          ref: ${{ env.LINT_REF }}
+          path: lint-src
+
+      - name: Install Rust stable
+        uses: dtolnay/rust-toolchain@stable
+
+      - name: Cache cargo registry and build artifacts
+        uses: actions/cache@v4
+        with:
+          path: |
+            ~/.cargo/registry/index/
+            ~/.cargo/registry/cache/
+            ~/.cargo/git/db/
+            lint-src/target/
+          key: test-cargo-${{ hashFiles('lint-src/Cargo.lock') }}
+          restore-keys: |
+            test-cargo-
+
+      - name: Run tests
+        working-directory: lint-src
+        run: cargo test --locked
+
+  build:
+    needs: test
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - target: x86_64-pc-windows-msvc
+            os: windows-latest
+            artifact: logseq-i18n-lint-x86_64-windows.exe
+          - target: aarch64-pc-windows-msvc
+            os: windows-latest
+            artifact: logseq-i18n-lint-aarch64-windows.exe
+          - target: x86_64-apple-darwin
+            os: macos-latest
+            artifact: logseq-i18n-lint-x86_64-macos
+          - target: aarch64-apple-darwin
+            os: macos-latest
+            artifact: logseq-i18n-lint-aarch64-macos
+          - target: x86_64-unknown-linux-musl
+            os: ubuntu-latest
+            artifact: logseq-i18n-lint-x86_64-linux
+          - target: aarch64-unknown-linux-musl
+            os: ubuntu-latest
+            artifact: logseq-i18n-lint-aarch64-linux
+
+    runs-on: ${{ matrix.os }}
+
+    steps:
+      - name: Checkout logseq-i18n-lint source
+        uses: actions/checkout@v4
+        with:
+          repository: ${{ env.LINT_REPO }}
+          ref: ${{ env.LINT_REF }}
+          path: lint-src
+
+      - name: Install Rust stable
+        uses: dtolnay/rust-toolchain@stable
+        with:
+          targets: ${{ matrix.target }}
+
+      - name: Cache cargo registry and build artifacts
+        uses: actions/cache@v4
+        with:
+          path: |
+            ~/.cargo/registry/index/
+            ~/.cargo/registry/cache/
+            ~/.cargo/git/db/
+            lint-src/target/
+          key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('lint-src/Cargo.lock') }}
+          restore-keys: |
+            ${{ runner.os }}-${{ matrix.target }}-cargo-
+            ${{ runner.os }}-cargo-
+
+      - name: Install cross (Linux ARM64)
+        if: matrix.target == 'aarch64-unknown-linux-musl'
+        run: cargo install cross --git https://github.com/cross-rs/cross
+
+      - name: Install musl tools (Linux x64)
+        if: matrix.target == 'x86_64-unknown-linux-musl'
+        run: sudo apt-get update && sudo apt-get install -y musl-tools
+
+      - name: Build with cross
+        if: matrix.target == 'aarch64-unknown-linux-musl'
+        working-directory: lint-src
+        run: cross build --release --target ${{ matrix.target }}
+
+      - name: Build with cargo
+        if: matrix.target != 'aarch64-unknown-linux-musl'
+        working-directory: lint-src
+        run: cargo build --release --target ${{ matrix.target }}
+
+      - name: Stage artifact (Unix)
+        if: runner.os != 'Windows'
+        run: cp lint-src/target/${{ matrix.target }}/release/logseq-i18n-lint ${{ matrix.artifact }}
+
+      - name: Stage artifact (Windows)
+        if: runner.os == 'Windows'
+        shell: bash
+        run: cp lint-src/target/${{ matrix.target }}/release/logseq-i18n-lint.exe ${{ matrix.artifact }}
+
+      - name: Upload artifact
+        uses: actions/upload-artifact@v4
+        with:
+          name: ${{ matrix.artifact }}
+          path: ${{ matrix.artifact }}
+
+  open-pr:
+    needs: build
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout this repository
+        uses: actions/checkout@v4
+
+      - name: Checkout logseq-i18n-lint source
+        uses: actions/checkout@v4
+        with:
+          repository: ${{ env.LINT_REPO }}
+          ref: ${{ env.LINT_REF }}
+          fetch-depth: 0
+          path: lint-src
+
+      - name: Download built artifacts
+        uses: actions/download-artifact@v4
+        with:
+          path: artifacts
+          merge-multiple: true
+
+      - name: Resolve source commit and stage launcher
+        id: source
+        run: |
+          cd lint-src
+          COMMIT_SHA="$(git rev-parse HEAD)"
+          SHORT_SHA="$(git rev-parse --short=12 HEAD)"
+          echo "commit_sha=${COMMIT_SHA}" >> "$GITHUB_OUTPUT"
+          echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
+          cd ..
+          cp lint-src/scripts/logseq-i18n-lint artifacts/logseq-i18n-lint
+
+      - name: Replace bin artifacts
+        run: |
+          cp artifacts/logseq-i18n-lint bin/logseq-i18n-lint
+          cp artifacts/logseq-i18n-lint-aarch64-linux bin/logseq-i18n-lint-aarch64-linux
+          cp artifacts/logseq-i18n-lint-aarch64-macos bin/logseq-i18n-lint-aarch64-macos
+          cp artifacts/logseq-i18n-lint-aarch64-windows.exe bin/logseq-i18n-lint-aarch64-windows.exe
+          cp artifacts/logseq-i18n-lint-x86_64-linux bin/logseq-i18n-lint-x86_64-linux
+          cp artifacts/logseq-i18n-lint-x86_64-macos bin/logseq-i18n-lint-x86_64-macos
+          cp artifacts/logseq-i18n-lint-x86_64-windows.exe bin/logseq-i18n-lint-x86_64-windows.exe
+          chmod +x bin/logseq-i18n-lint bin/logseq-i18n-lint-*-linux bin/logseq-i18n-lint-*-macos 2>/dev/null || true
+
+      - name: Commit updated binaries
+        id: commit
+        env:
+          SHORT_SHA: ${{ steps.source.outputs.short_sha }}
+        run: |
+          git config user.name "github-actions[bot]"
+          git config user.email "github-actions[bot]@users.noreply.github.com"
+
+          BRANCH="chore/update-i18n-lint-${SHORT_SHA}"
+          git switch -C "${BRANCH}"
+          git add -A -- \
+            bin/logseq-i18n-lint \
+            bin/logseq-i18n-lint-aarch64-linux \
+            bin/logseq-i18n-lint-aarch64-macos \
+            bin/logseq-i18n-lint-aarch64-windows.exe \
+            bin/logseq-i18n-lint-x86_64-linux \
+            bin/logseq-i18n-lint-x86_64-macos \
+            bin/logseq-i18n-lint-x86_64-windows.exe
+
+          if git diff --cached --quiet; then
+            echo "No changes to commit"
+            exit 0
+          fi
+
+          git commit -m "chore: update logseq-i18n-lint binaries to ${SHORT_SHA}"
+          git push origin "${BRANCH}"
+          echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT"
+
+      - name: Open pull request
+        if: steps.commit.outputs.branch != ''
+        env:
+          GH_TOKEN: ${{ github.token }}
+          COMMIT_SHA: ${{ steps.source.outputs.commit_sha }}
+          SHORT_SHA: ${{ steps.source.outputs.short_sha }}
+          BRANCH: ${{ steps.commit.outputs.branch }}
+        run: |
+          gh pr create \
+            --base develop \
+            --head "${BRANCH}" \
+            --title "chore: update logseq-i18n-lint binaries to ${SHORT_SHA}" \
+            --body "$(cat <<EOF
+          ## Update logseq-i18n-lint binaries
+
+          This PR was created automatically by the [update-i18n-lint](.github/workflows/update-i18n-lint.yml) workflow.
+
+          | Field | Value |
+          |-------|-------|
+          | Source repo | [${LINT_REPO}](https://github.com/${LINT_REPO}) |
+          | Source ref | \`${LINT_REF}\` |
+          | Source commit | \`${COMMIT_SHA}\` |
+          | Trigger | Manual (\`workflow_dispatch\`) |
+
+          ### Changed files
+          This PR rebuilds and replaces exactly 7 files in \`bin/\` from the latest \`${LINT_REF}\` source of \`${LINT_REPO}\`.
+          EOF
+          )"

+ 371 - 0
.i18n-lint.toml

@@ -0,0 +1,371 @@
+##########################################################################################
+# CAUTION: Do not modify this file without a clear understanding of its logic.           #
+# Check [https://github.com/logseq/logseq-i18n-lint] before proceeding with any changes. #
+##########################################################################################
+
+# Logseq-specific configuration for logseq-i18n-lint.
+
+# ── Shared settings ────────────────────────────────────────────────────────────
+
+# Path from the executable's directory to the Logseq repo root.
+# Behaviour is independent of the working directory.
+project_root = ".."
+
+# Directories to scan (relative to project_root).
+include_dirs = [
+  "src/main/frontend",
+  "src/main/electron",
+  "src/main/mobile",
+  "src/electron",
+  "deps",
+]
+
+# File extensions to scan.
+file_extensions = ["clj", "cljs", "cljc"]
+
+# Translation functions — calls to these provide translation keys for both subcommands.
+i18n_functions = [
+  "t",
+  "tt",
+  "i18n/t",
+  "i18n/tt",
+]
+
+# Alert/notification functions.
+# lint:       the FIRST argument is user-visible text; analyzed in UI context so
+#             str-concat, conditional-text, and format-string rules apply inside it.
+# check-keys: the FIRST keyword argument is a translation key reference.
+alert_functions = [
+  "notification/show!",
+]
+
+# UI component functions.
+# lint:       string arguments are user-visible text.
+# check-keys: keyword arguments are translation key references.
+ui_functions = [
+  "ui/button",
+  "ui/tooltip",
+  "ui/tooltip-content",
+  "ui/badge",
+  "ui/dropdown-menu-item",
+  "ui/dropdown-menu-sub-trigger",
+  "ui/loading",
+  "ui/select-item",
+  "ui/tabs-trigger",
+  "ui/form-label",
+  "ui/form-description",
+  "ui/card-title",
+  "ui/alert-title",
+  "ui/alert-description",
+  "ui/table-cell",
+  "ui/table-header",
+  "ui/link",
+]
+
+# Namespace prefixes where every function is treated as a UI component.
+ui_namespaces = [
+  "shui",
+]
+
+# HTML/hiccup attributes.
+# lint:       string values are flagged as user-visible text.
+# check-keys: keyword values are treated as translation key references.
+ui_attributes = [
+  "placeholder",
+  "title",
+  "aria-label",
+  "alt",
+  "label",
+]
+
+# ── [lint] settings ────────────────────────────────────────────────────────────
+
+[lint]
+
+# Glob patterns for files to skip during lint.
+# These patterns are also applied when using --git-changed.
+exclude_patterns = [
+  "**/test/**",
+  "**/node_modules/**",
+  "**/static/**",
+  "**/target/**",
+  "**/tmp/**",
+  "**/cljs-test-runner-out/**",
+  "**/.nbb/**",
+  "deps/cli/**",
+  "deps/publish/**",
+  "deps/publishing/**",
+  "deps/db-sync/src/logseq/db_sync/malli_schema.cljs",           # Malli protocol schema — wire-protocol message type names
+  "deps/graph-parser/src/logseq/graph_parser/schema/mldoc.cljc", # mldoc schema — AST node type names (Label, Paragraph, etc.)
+  "deps/shui/src/logseq/shui/demo*.cljs",                        # storybook demo UI — intentionally hardcoded
+  "src/main/frontend/components/profiler.cljs",                  # Developer profiling tool
+  "src/main/frontend/db/rtc/debug_ui.cljs",                      # Developer RTC tool
+  "src/main/frontend/handler/export/html.cljs",                  # Raw HTML export — intentional
+  "src/main/frontend/handler/shell.cljs",                        # Run shell command
+  "src/main/frontend/undo_redo/debug_ui.cljs",                   # Developer undo/redo tool
+  "src/main/frontend/worker/commands.cljs",                      # Internal command identifier strings
+]
+
+# Maximum character length of the text preview in output.
+text_preview_length = 60
+
+# Pure (non-UI) functions — string arguments inside are not reported even in UI context.
+pure_functions = [
+  # String utilities whose arguments are data, not UI text.
+  "text-util/cut-by",
+  "text-util/split-by",
+  # mldoc/markdown dispatch functions — args are AST node type names, not UI text.
+  "markup-element-cp",
+  "markup-elements-cp",
+  # mldoc inline renderer — first arg is config, second is an AST node vector.
+  "inline",
+  # Rum component key wrapper — second arg is a key string, not UI text.
+  "rum/with-key",
+  # Shortcut wrapper — positional args are shortcut IDs and positions, not UI text.
+  "ui/with-shortcut",
+  # Macro type identifier dispatch.
+  "macro->text",
+]
+
+# Format/printf functions — ONLY the FIRST argument (the template string) is flagged,
+# and ONLY when the call site is inside a UI context (hiccup or UI function call).
+format_functions = [
+  "format",
+  "goog.string/format",
+  "gstring/format",
+  "util/format",
+]
+
+# Strings to allow (exact match — also matches after trimming whitespace).
+# Keep this list SHORT. Add only strings that:
+#   1. Are NOT covered by any allow_pattern
+#   2. Have a clear, Logseq-specific reason for appearing in ui context
+#   3. Are truly non-translatable (brand names, internal IDs, technical constants)
+allow_strings = [
+  # Brand name — displayed literally in UI, intentionally not translated.
+  "Logseq",
+  "Logseq Sync",
+  "GitHub",
+  # Typography test string — rendered as a glyph sample, not translatable.
+  "Ag",
+  # Column header abbreviation for row index — shown as-is in table view.
+  "ID:",
+  # org-mode structural keywords — shown literally in drawer/block syntax.
+  ":END:",
+  # Common non-translatable UI labels.
+  "URL",
+  "OPML",
+  "EDN",
+  "HTML",
+  "PNG",
+  "SQLite",
+  "HTTP",
+  "SOCKS5",
+  # Config directory path shown literally.
+  "~/.logseq",
+]
+
+# Regex patterns to allow.
+allow_patterns = [
+  # Developer-only English labels intentionally outside i18n scope.
+  # e.g., "(Dev) RTC", "(Dev) Profiler"
+  "^\\(Dev\\)\\s",
+
+  # Logseq macro syntax.
+  # e.g., {{query ...}}, {{video ...}}
+  "^\\{\\{",
+
+  # Email addresses.
+  # e.g., [email protected], [email protected]
+  "^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$",
+
+  # URLs and URI schemes.
+  # e.g., https://google.com, sfsymbols://icon, file:///path/to/res
+  "^[a-z]+://[^\\s]*$",
+
+  # Git commands.
+  # e.g., git commit -m "feat", git push origin main
+  "^git\\s+[a-z]+(\\s+.*)?$",
+
+  # Tailwind CSS color / shade utility classes.
+  # e.g., bg-red-500, text-gray-300, border-blue-100
+  "^(bg|text|border|ring|shadow|fill|from|via|to|outline|divide|accent|caret|decoration)-[a-z]+-[0-9]+(/[0-9]+)?$",
+
+  # CSS color functions.
+  # e.g., rgb(255, 255, 255), rgba(0, 0, 0, 0.5)
+  "^rgba?\\(",
+
+  # CSS custom property access.
+  # e.g., var(--primary-color), var(--spacing-unit)
+  "^var\\(--",
+
+  # CSS BEM modifier classes (double-hyphen notation).
+  # e.g., shortcut-feedback--error, block__title--active
+  "^[a-z][a-z0-9-]*--[a-z][a-z0-9-]*$",
+
+  # Numeric base prefixes.
+  # e.g., 0b (binary), 0o (octal), 0x (hex)
+  "^0[box]$",
+
+  # Regex anchor notation.
+  # e.g., ^starting-with
+  "^\\^",
+
+  # Web resource references with specific extensions.
+  # e.g., script.js, styles.css, module.mjs
+  "^[a-z][a-z0-9/._-]+\\.(mjs|js|css|wasm)$",
+
+  # MIME types.
+  # e.g., image/png, application/json, text/html
+  "^[a-z][a-z0-9+.-]+/[a-z0-9.+*-]+$",
+
+  # Hex colors (3, 4, 6, or 8 digits).
+  # e.g., #fff, #1a2b3c, #ff00ffaa
+  "^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
+
+  # DOM element IDs.
+  # e.g., #main-container, #submit-btn
+  "^#[a-z][a-z0-9-]+$",
+
+  # Dot-notation identifiers (icon library names, SF Symbols, CSS dot-joined classes).
+  # e.g., person.fill, cloud.sun.rain.fill, bg-red-600.top-1.absolute
+  "^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)*$",
+
+  # CSS unit values (supports decimals).
+  # e.g., 10px, 1.5rem, 100%, 500ms
+  "^[0-9]+(\\.[0-9]+)?(px|em|rem|vh|vw|%|pt|s|ms)$",
+
+  # DOM element ID / React key fragments (start or end with hyphen).
+  # e.g., tag-, -refs, sidebar-block-, -custom-query-, -add-property
+  "^-[a-z0-9]+(-[a-z0-9]+)*$",
+  "^[a-z0-9]+(-[a-z0-9]+)*-$",
+
+  # Strings starting with a dot (file fragments or class selectors).
+  # e.g., .hidden, .tmp-file
+  "^\\.",
+
+  # printf-style format templates.
+  # e.g., %s, [%d%%], #%x
+  "^[^A-Za-z ]*%",
+]
+
+# Exception/error constructor functions — arguments are developer-facing, not UI text.
+exception_functions = [
+  "ex-info",
+  "throw",
+]
+
+# Functions whose arguments are NOT checked.
+ignore_context_functions = [
+  "js/console.log",
+  "js/console.error",
+  "js/console.warn",
+  "prn",
+  "println",
+  "log/debug",
+  "log/info",
+  "log/warn",
+  "log/error",
+  "re-pattern",
+  "re-find",
+  "re-matches",
+  "require",
+  "ns",
+  # shui utilities that take CSS IDs / class utility strings, not user-visible text.
+  "shui/cn",
+  "shui/popup-show",
+  "shui/popup-show!",
+  "shui/popup-hide",
+  "shui/popup-hide!",
+  "shui/popup-hide-all",
+  "shui/dialog-open",
+  "shui/dialog-close",
+  "shui/dialog-close-all",
+  "shui/dialog-confirm",
+  "shui/table-get-selection-rows",
+  "shui/trigger-as",
+  # CSS class-joining utilities — string arguments are class names, not UI text.
+  "util/classnames",
+  "classnames",
+  # Icon functions — arguments are icon library identifiers (e.g. "trash",
+  # "arrow-right"), never user-visible text that needs translation.
+  "ui/icon",
+  "shui/tabler-icon",
+  "icon-v2/root",
+  # Ghost-icon button — the only positional argument is an icon name.
+  "button-ghost-icon",
+  # Shortcut display/trigger functions — arguments are key identifiers
+  # (e.g. "mod+enter", "backspace"), not translatable text.
+  "shui/shortcut",
+  "shui/shortcut-press!",
+]
+
+# ── [check-keys] settings ──────────────────────────────────────────────────────
+
+[check-keys]
+
+# Glob patterns for files to skip during check-keys.
+# NOTE: **/profiler.cljs is intentionally NOT excluded here so that translation
+# key references inside profiler.cljs are detected and not reported as unused.
+exclude_patterns = [
+  "**/test/**",
+  "**/tests/**",
+  "**/dev/**",
+  "**/node_modules/**",
+  "**/target/**",
+  "**/static/**",
+  "**/cljs-test-runner-out/**",
+  "**/.nbb/**",
+  "deps/cli/**",
+  "deps/publish/**",
+  "deps/publishing/**",
+]
+
+# Directory containing dictionary EDN files (relative to project_root).
+dicts_dir = "src/resources/dicts"
+
+# Primary dictionary file (relative to project_root).
+primary_dict = "src/resources/dicts/en.edn"
+
+# Key patterns always considered "used" — for dynamically generated keys
+# that cannot be detected via static analysis.
+always_used_key_patterns = [
+  # Table view keys used dynamically via (for [[option-key _] options] (t option-key)).
+  "^:view\\.table/group-journal-date",
+  "^:view\\.table/group-page",
+]
+
+# Key namespace prefixes excluded from unused-key checking.
+ignore_key_namespaces = [
+  # Shortcut keys are dynamically assembled via (keyword "command.ns" name).
+  "command",
+  # Shortcut category labels.
+  "shortcut.category",
+  # Shortcut handler group keys.
+  "shortcut.handler",
+  # Color theme keys derived from built-in-colors vector.
+  "color",
+  # Date NLP labels derived from nlp-pages vector.
+  "date.nlp",
+  # Flashcard FSRS rating keys derived via (keyword "flashcard.rating" ...).
+  "flashcard.rating",
+  # Graph validation keys derived from deprecated config keys.
+  "graph.validation",
+  # Left sidebar nav keys derived from tag nav entries.
+  "nav",
+]
+
+# Map attribute keys whose keyword values are translation key references.
+# Combined with ui_attributes during check-keys analysis.
+translation_key_attributes = ["i18n-key", "prompt-key", "title-key"]
+
+# Built-in db-ident definition sources.
+# Each entry scopes keyword extraction to a specific named def/defonce form,
+# preventing false positives from other keyword literals in the same file.
+[[check-keys.db_ident_defs]]
+file = "deps/db/src/logseq/db/frontend/property.cljs"
+def  = "built-in-properties"
+
+[[check-keys.db_ident_defs]]
+file = "deps/db/src/logseq/db/frontend/class.cljs"
+def  = "built-in-classes"

+ 3 - 0
AGENTS.md

@@ -21,6 +21,8 @@
 - Follow existing namespace and file layout; keep related workers and RTC code in their dedicated directories.
 - Prefer concise, imperative commit subjects aligned with existing history (examples: `fix: download`, `enhance(rtc): ...`).
 - Clojure map keyword name should prefer `-` instead of `_`, e.g. `:user-id` instead of `:user_id`.
+- For i18n work, use `.i18n-lint.toml` as the source of truth for lint scope and exceptions. Inside that scope, shipped UI text must use helpers from `frontend.context.i18n`; console text is exempt. Keep out-of-scope developer-only `(Dev)` labels inline in code/config, not in translation dictionaries.
+- Reuse `src/resources/dicts/en.edn` keys only on exact semantic owner + textual role match. Follow `docs/i18n-key-naming.md` for new or renamed keys. Add English source text in `en.edn`; add non-English entries only when providing real translations; keep complete sentences whole; use placeholders for plain dynamic text; run `bb lang:validate-translations`, `bb lang:lint-hardcoded`, and `bb lang:format-dicts` as needed.
 
 ## Testing Guidelines
 - Unit tests live in `src/test/` and should be runnable via `bb dev:lint-and-test`.
@@ -32,6 +34,7 @@
 - PRs should describe the behavior change, link relevant issues, and note any test coverage added or skipped.
 
 ## Agent-Specific Notes
+- Project-specific skills live under `.agents/skills/`; load `.agents/skills/logseq-i18n/SKILL.md` for i18n/localization/hardcoded UI text tasks.
 - Review notes live in `prompts/review.md`; check them when preparing changes.
 - DB-sync feature guide for AI agents: `docs/agent-guide/db-sync/db-sync-guide.md`.
 - DB-sync protocol reference: `docs/agent-guide/db-sync/protocol.md`.

+ 1 - 1
README.md

@@ -74,7 +74,7 @@ The DB version is in beta status while the new mobile app and RTC is in alpha. T
 
 To get started with the DB version:
 * To try the latest web version, go to https://test.logseq.com/.
-* To try the latest desktop version, login to Github and go to https://github.com/logseq/logseq/actions/workflows/build-desktop-release.yml and click on the latest release. Scroll to the bottom and under the `Artifacts` section download the artifact for your operating system.
+* To try the latest desktop version, login to GitHub and go to https://github.com/logseq/logseq/actions/workflows/build-desktop-release.yml and click on the latest release. Scroll to the bottom and under the `Artifacts` section download the artifact for your operating system.
 * To try the latest by building from the source code
     * Use `test/db` for stable releases. Fewer bugs and slower updates. Update frequency: days or weeks.
     * Use `master` for the latest updates as they are developed. Expect more bugs and faster changes. Update frequency: hours or days.

+ 9 - 0
bb.edn

@@ -222,9 +222,18 @@
   lang:missing
   logseq.tasks.lang/list-missing
 
+  lang:pseudo
+  logseq.tasks.lang/list-pseudo
+
+  lang:format-dicts
+  logseq.tasks.lang/format-dicts
+
   lang:validate-translations
   logseq.tasks.lang/validate-translations
 
+  lang:lint-hardcoded
+  logseq.tasks.lang/lint-hardcoded
+
   ai:check-common-errors
   logseq.tasks.common-errors/check-common-errors}
 

+ 64 - 0
bin/logseq-i18n-lint

@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# logseq-i18n-lint launcher
+# Detects the current OS/arch and runs the prebuilt binary in the same directory.
+# All arguments are forwarded to the binary.
+#
+# Supported platforms:
+#   Linux   x86_64  -> logseq-i18n-lint-x86_64-linux
+#   Linux   aarch64 -> logseq-i18n-lint-aarch64-linux
+#   macOS   x86_64  -> logseq-i18n-lint-x86_64-macos
+#   macOS   arm64   -> logseq-i18n-lint-aarch64-macos
+#   Windows x86_64  -> logseq-i18n-lint-x86_64-windows.exe  (via Git Bash / MSYS2)
+#   Windows aarch64 -> logseq-i18n-lint-aarch64-windows.exe
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# ── Detect OS ────────────────────────────────────────────────────────────────
+
+OS="$(uname -s)"
+case "${OS}" in
+    Linux*)   platform="linux" ;;
+    Darwin*)  platform="macos" ;;
+    MINGW*|MSYS*|CYGWIN*|Windows_NT)
+              platform="windows" ;;
+    *)
+        echo "error: unsupported OS: ${OS}" >&2
+        exit 1
+        ;;
+esac
+
+# ── Detect architecture ───────────────────────────────────────────────────────
+
+ARCH="$(uname -m)"
+case "${ARCH}" in
+    x86_64|amd64)   arch="x86_64" ;;
+    aarch64|arm64)  arch="aarch64" ;;
+    *)
+        echo "error: unsupported architecture: ${ARCH}" >&2
+        exit 1
+        ;;
+esac
+
+# ── Resolve binary path ────────────────────────────────────────────────────────
+
+if [[ "${platform}" == "windows" ]]; then
+    bin="${SCRIPT_DIR}/logseq-i18n-lint-${arch}-${platform}.exe"
+else
+    bin="${SCRIPT_DIR}/logseq-i18n-lint-${arch}-${platform}"
+fi
+
+if [[ ! -f "${bin}" ]]; then
+    echo "error: binary not found: ${bin}" >&2
+    echo "  Download it from: https://github.com/logseq/logseq-i18n-lint/releases/latest" >&2
+    exit 1
+fi
+
+if [[ ! -x "${bin}" ]]; then
+    chmod +x "${bin}"
+fi
+
+# ── Run ───────────────────────────────────────────────────────────────────────
+
+exec "${bin}" "$@"

BIN=BIN
bin/logseq-i18n-lint-aarch64-linux


BIN=BIN
bin/logseq-i18n-lint-aarch64-macos


BIN=BIN
bin/logseq-i18n-lint-aarch64-windows.exe


BIN=BIN
bin/logseq-i18n-lint-x86_64-linux


BIN=BIN
bin/logseq-i18n-lint-x86_64-macos


BIN=BIN
bin/logseq-i18n-lint-x86_64-windows.exe


+ 9 - 10
clj-e2e/src/logseq/e2e/graph.clj

@@ -1,7 +1,5 @@
 (ns logseq.e2e.graph
-  (:require [clojure.edn :as edn]
-            [clojure.string :as string]
-            [logseq.e2e.assert :as assert]
+  (:require [logseq.e2e.assert :as assert]
             [logseq.e2e.keyboard :as k]
             [logseq.e2e.locator :as loc]
             [logseq.e2e.util :as util]
@@ -111,7 +109,7 @@
         (.first (w/-query (format "div[data-testid='logseq_db_%s'] .graph-action-btn" graph-name)))]
     (w/click action-btn)
     (w/click ".delete-local-graph-menu-item")
-    (w/click "div[role='alertdialog'] button:text('ok')")))
+    (w/click "div[role='alertdialog'] button:text('Confirm')")))
 
 (defn remove-remote-graph
   [graph-name]
@@ -120,7 +118,7 @@
         (.first (w/-query (format "div[data-testid='logseq_db_%s'] .graph-action-btn" graph-name)))]
     (w/click action-btn)
     (w/click ".delete-remote-graph-menu-item")
-    (w/click "div[role='alertdialog'] button:text('ok')")))
+    (w/click "div[role='alertdialog'] button:text('Confirm')")))
 
 (defn switch-graph
   [to-graph-name wait-sync? need-input-password?]
@@ -136,8 +134,9 @@
   (k/esc)
   (k/esc)
   (util/search-and-click "(Dev) Validate current graph")
-  (assert/assert-is-visible (loc/and ".notifications div.notification-success div" (w/get-by-text "Your graph is valid")))
-  (let [content (.textContent (loc/and ".notifications div.notification-success div" (w/get-by-text "Your graph is valid")))
-        summary (edn/read-string (subs content (string/index-of content "{")))]
-    (w/click ".notifications div.notification-success .ls-icon-x")
-    summary))
+  (assert/assert-is-visible
+   (loc/and ".notifications div.notification-success div"
+            (w/get-by-text "Your graph is valid")))
+  (when (w/visible? ".notifications div.notification-success .ls-icon-x")
+    (w/click ".notifications div.notification-success .ls-icon-x"))
+  {:valid? true})

+ 2 - 2
clj-e2e/src/logseq/e2e/page.clj

@@ -36,9 +36,9 @@
 (defn delete-page
   [page-name]
   (goto-page page-name)
-  (w/click "button[title='More']")
+  (w/click ".toolbar-dots-btn")
   (w/click "[role='menuitem'] div:text('Delete page')")
-  (w/click "div[role='alertdialog'] button:text('ok')"))
+  (w/click "div[role='alertdialog'] button:text('Confirm')"))
 
 (defn rename-page
   [old-page-name new-page-name]

+ 44 - 9
clj-e2e/src/logseq/e2e/settings.clj

@@ -1,16 +1,51 @@
 (ns logseq.e2e.settings
   (:require [logseq.e2e.assert :as assert]
-            [logseq.e2e.keyboard :as k]
+            [logseq.e2e.util :as util]
             [wally.main :as w]))
 
+(def ^:private e2e-init-script
+  "localStorage.setItem('preferred-language', '\"en\"'); localStorage.setItem('developer-mode', '\"true\"');")
+
+(def ^:private refresh-ready-script
+  "(() => document.documentElement.lang === 'en'
+           && localStorage.getItem('preferred-language') === '\"en\"'
+           && localStorage.getItem('developer-mode') === '\"true\"')()")
+
+(defn install-init-script!
+  [ctx]
+  (.addInitScript ctx e2e-init-script))
+
+(defn wait-test-env-ready!
+  []
+  (loop [remaining 20]
+    (if (w/eval-js refresh-ready-script)
+      true
+      (if (zero? remaining)
+        (throw (ex-info "test env not ready after refresh" {}))
+        (do
+          (util/wait-timeout 250)
+          (recur (dec remaining)))))))
+
+(defn- test-env-ready?
+  []
+  (try
+    (wait-test-env-ready!)
+    true
+    (catch Throwable _e
+      false)))
+
+(defn refresh-test-env!
+  []
+  (loop [attempt 0]
+    (w/refresh)
+    (assert/assert-graph-loaded?)
+    (if (test-env-ready?)
+      true
+      (if (< attempt 2)
+        (recur (inc attempt))
+        (wait-test-env-ready!)))))
+
 (defn developer-mode
   []
-  (w/eval-js "localStorage.setItem('preferred-language', '\"en\"')")
-  (w/click "button[title='More'] .ls-icon-dots")
-  (w/click ".ls-icon-settings")
-  (w/click "[data-id='advanced']")
-  (let [q (.last (w/-query ".ui__toggle [aria-checked='false']"))]
-    (when (.isVisible q)
-      (w/click q)))
-  (k/esc)
+  (w/eval-js e2e-init-script)
   (assert/assert-in-normal-mode?))

+ 7 - 3
clj-e2e/src/logseq/e2e/util.clj

@@ -96,7 +96,11 @@
 (defn search-and-click
   [search-text]
   (search search-text)
-  (w/click (.first (w/get-by-test-id search-text))))
+  (let [result (.first (w/get-by-test-id search-text))]
+    (repeat-until-visible 5 result #(do
+                                      (search search-text)
+                                      (wait-timeout 300)))
+    (w/click result)))
 
 (defn wait-editor-gone
   ([]
@@ -153,12 +157,12 @@
       :or {username "e2etest"
            password "Logseq-e2e"}}]
   (w/eval-js "localStorage.setItem(\"login-enabled\",true);")
-  (w/click "button[title=\"More\"]")
+  (w/click ".toolbar-dots-btn")
   (w/click "div:text(\"Login\")")
   (input username)
   (k/tab)
   (input password)
-  (w/click "button[type=\"submit\"]:text(\"Sign in\")")
+  (w/click ".cp__user-login button[type=\"submit\"]")
   (w/wait-for-not-visible ".cp__user-login"))
 
 (defn goto-journals

+ 6 - 6
clj-e2e/test/logseq/e2e/fixtures.clj

@@ -23,11 +23,11 @@
     (w/grant-permissions :clipboard-write :clipboard-read)
     (binding [custom-report/*pw-contexts* #{(.context (w/get-page))}
               custom-report/*pw-page->console-logs* (atom {})]
+      (settings/install-init-script! (.context (w/get-page)))
       (w/grant-permissions :clipboard-write :clipboard-read)
       (w/navigate (pw-page/get-test-url port))
       (settings/developer-mode)
-      (w/refresh)
-      (assert/assert-graph-loaded?)
+      (settings/refresh-test-env!)
       (let [p (w/get-page)]
         (.onConsoleMessage p (fn [msg]
                                (when custom-report/*pw-page->console-logs*
@@ -43,6 +43,7 @@
                    :slow-mo @config/*slow-mo}
         p1 (w/make-page page-opts)
         p2 (w/make-page page-opts)]
+    (run! #(settings/install-init-script! (.context @%)) [p1 p2])
     (reset! *page1 p1)
     (reset! *page2 p2)
     (binding [custom-report/*pw-contexts* (set [(.context @p1) (.context @p2)])
@@ -53,8 +54,7 @@
           (w/grant-permissions :clipboard-write :clipboard-read)
           (w/navigate (pw-page/get-test-url (or port @config/*port)))
           (settings/developer-mode)
-          (w/refresh)
-          (assert/assert-graph-loaded?)
+          (settings/refresh-test-env!)
           (let [p (w/get-page)]
             (.onConsoleMessage
              p
@@ -84,7 +84,7 @@
     (w/with-page-open p)              ; use with-page-open to close playwright instance
     (binding [custom-report/*pw-contexts* #{ctx}
               *pw-ctx* ctx]
-      (.addInitScript ctx "localStorage.setItem('preferred-language', '\"en\"')")
+      (settings/install-init-script! ctx)
       (f)
       (.close (.browser *pw-ctx*)))))
 
@@ -141,7 +141,7 @@
      2
      #(w/with-page %
         (settings/developer-mode)
-        (w/refresh)
+        (settings/refresh-test-env!)
         (util/login-test-account))
      [@*page1 @*page2])
     (w/with-page @*page1

+ 1 - 1
clj-e2e/test/logseq/e2e/plugins_marketplace_test.clj

@@ -13,7 +13,7 @@
   "Opens the plugins dialog via the More menu"
   []
   (util/double-esc)
-  (w/click "button[title='More'] .ls-icon-dots")
+  (w/click ".toolbar-dots-btn")
   (w/click ".ui__dropdown-menu-item:has-text('Plugins')")
   (w/wait-for ".cp__plugins-page"))
 

+ 3 - 3
clj-e2e/test/logseq/e2e/property_basic_test.clj

@@ -14,7 +14,7 @@
   fixtures/new-logseq-page
   fixtures/validate-graph)
 
-(def ^:private property-types ["Text" "Number" "Date" "DateTime" "Checkbox" "Url" "Node"])
+(def ^:private property-types ["Text" "Number" "Date" "DateTime" "Checkbox" "URL" "Node"])
 
 (defn add-new-properties
   [title-prefix]
@@ -23,7 +23,7 @@
     (let [property-name (str "p-" title-prefix "-" property-type)]
       (w/click (util/get-by-text (str title-prefix "-" property-type) true))
       (k/press "Control+e")
-      (util/input-command "Add new property")
+      (util/input-command "Add property")
       (w/click "input[placeholder]")
       (util/input property-name)
       (w/click (util/get-by-text "New option:" false))
@@ -41,7 +41,7 @@
                               (k/enter)
                               (k/esc))
         "Checkbox" nil
-        "Url" nil
+        "URL" nil
         "Node" (do
                  (w/click (w/get-by-text "Skip choosing tag"))
                  (util/input (str title-prefix "-Node-value"))

+ 1 - 1
deps/cli/README.md

@@ -1,6 +1,6 @@
 ## Description
 
-This library provides a `logseq` CLI for DB graphs created using the [database-version](/README.md#-database-version). By default, the CLI works offline with local graphs. This allows for running commands automatically on CI/CD platforms like Github Actions. Most CLI commands also connect to the current DB graph in a desktop app (a.k.a. in-app graph) if the [HTTP API Server](https://docs.logseq.com/#/page/local%20http%20server) is turned on.
+This library provides a `logseq` CLI for DB graphs created using the [database-version](/README.md#-database-version). By default, the CLI works offline with local graphs. This allows for running commands automatically on CI/CD platforms like GitHub Actions. Most CLI commands also connect to the current DB graph in a desktop app (a.k.a. in-app graph) if the [HTTP API Server](https://docs.logseq.com/#/page/local%20http%20server) is turned on.
 
 ## Install
 

+ 4 - 1
deps/common/src/logseq/common/config.cljs

@@ -78,6 +78,9 @@
 
 (defonce block-pattern "-")
 
+(def unused-in-db-graphs-deprecation
+  "is not used in DB graphs")
+
 (def file-only-config
   "File only config keys that are deprecated in DB graphs along with
   descriptions for their deprecation."
@@ -100,7 +103,7 @@
      :srs/initial-interval
      :whiteboards-directory
      :feature/enable-whiteboards?]
-    (repeat "is not used in DB graphs"))
+    (repeat unused-in-db-graphs-deprecation))
    {:preferred-format
     "is not used in DB graphs as there is only markdown mode."
     :property-pages/enabled?

+ 1 - 0
deps/common/src/logseq/common/date.cljs

@@ -31,6 +31,7 @@
    "MM_dd_yyyy"
    "yyyy/MM/dd"
    "yyyy-MM-dd"
+   "yyyy-MM-dd EEE"
    "yyyy-MM-dd EEEE"
    "yyyy_MM_dd"
    "yyyyMMdd"

+ 8 - 0
deps/db/src/logseq/db.cljs

@@ -522,6 +522,14 @@
   (when db
     (d/entity db (get-first-page-by-name db page-name))))
 
+(defn get-journal-page-by-day
+  "Get a journal page given its :block/journal-day value."
+  [db journal-day]
+  (when (and db journal-day)
+    (when-let [eid (some-> (first (d/datoms db :avet :block/journal-day journal-day))
+                           :e)]
+      (d/entity db eid))))
+
 (def get-built-in-page db-db/get-built-in-page)
 
 (def library? db-db/library?)

+ 210 - 188
deps/db/src/logseq/db/frontend/property.cljs

@@ -55,17 +55,15 @@
      :logseq.property/ui-position {:title "Property position"
                                    :schema {:type :keyword
                                             :hide? true}}
-     :logseq.property/classes
-     {:title "Property classes"
-      :schema {:type :entity
-               :cardinality :many
-               :public? false
-               :hide? true}}
-     :logseq.property/value
-     {:title "Property value"
-      :schema {:type :any
-               :public? false
-               :hide? true}}
+     :logseq.property/classes {:title "Property classes"
+                               :schema {:type :entity
+                                        :cardinality :many
+                                        :public? false
+                                        :hide? true}}
+     :logseq.property/value {:title "Property value"
+                             :schema {:type :any
+                                      :public? false
+                                      :hide? true}}
 
      :block/alias          {:title "Alias"
                             :attribute :block/alias
@@ -230,19 +228,18 @@
 
      :logseq.property.pdf/hl-type {:title "Annotation type"
                                    :schema {:type :keyword :hide? true}}
-     :logseq.property.pdf/hl-color
-     {:title "Annotation color"
-      :schema {:type :default :hide? true}
-      :closed-values
-      (mapv (fn [[db-ident value]]
-              {:db-ident db-ident
-               :value value
-               :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)})
-            [[:logseq.property/color.yellow "yellow"]
-             [:logseq.property/color.red "red"]
-             [:logseq.property/color.green "green"]
-             [:logseq.property/color.blue "blue"]
-             [:logseq.property/color.purple "purple"]])}
+     :logseq.property.pdf/hl-color {:title "Annotation color"
+                                    :schema {:type :default :hide? true}
+                                    :closed-values
+                                    (mapv (fn [[db-ident value]]
+                                            {:db-ident db-ident
+                                             :value value
+                                             :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)})
+                                          [[:logseq.property/color.yellow "yellow"]
+                                           [:logseq.property/color.red "red"]
+                                           [:logseq.property/color.green "green"]
+                                           [:logseq.property/color.blue "blue"]
+                                           [:logseq.property/color.purple "purple"]])}
      :logseq.property.pdf/hl-page {:title "Annotation page"
                                    :schema {:type :raw-number :hide? true}}
      :logseq.property.pdf/hl-image {:title "Annotation image"
@@ -262,14 +259,6 @@
                                                   :schema {:type :node
                                                            :cardinality :many
                                                            :hide? true}}
-     ;; TODO: Remove deprecated
-     :logseq.property.tldraw/page {:title "Tldraw Page"
-                                   :schema {:type :map
-                                            :hide? true}}
-     ;; TODO: Remove deprecated
-     :logseq.property.tldraw/shape {:title "Tldraw Shape"
-                                    :schema {:type :map
-                                             :hide? true}}
 
      ;; Journal props
      :logseq.property.journal/title-format {:title "Title Format"
@@ -277,132 +266,119 @@
                                             {:type :string
                                              :public? false}}
 
-     :logseq.property/choice-checkbox-state
-     {:title "Choice checkbox state"
-      :schema {:type :checkbox
-               :hide? true}
-      :queryable? false}
+     :logseq.property/choice-checkbox-state {:title "Choice checkbox state"
+                                             :schema {:type :checkbox
+                                                      :hide? true}
+                                             :queryable? false}
      ;; tag-scoped choice, a choice can be specified locally for specified tags
-     :logseq.property/choice-classes
-     {:title "Choice classes"
-      :schema {:type :class
-               :cardinality :many
-               :public? false
-               :hide? true
-               :view-context :never}
-      :queryable? false}
+     :logseq.property/choice-classes {:title "Choice classes"
+                                      :schema {:type :class
+                                               :cardinality :many
+                                               :public? false
+                                               :hide? true
+                                               :view-context :never}
+                                      :queryable? false}
      ;; tag can define which global choices are hidden for its objects
-     :logseq.property/choice-exclusions
-     {:title "Choice exclusions"
-      :schema {:type :node
-               :cardinality :many
-               :public? false
-               :hide? true
-               :view-context :never}
-      :queryable? false}
-     :logseq.property/checkbox-display-properties
-     {:title "Properties displayed as checkbox"
-      :schema {:type :property
-               :cardinality :many
-               :hide? true}
-      :queryable? false}
+     :logseq.property/choice-exclusions {:title "Choice exclusions"
+                                         :schema {:type :node
+                                                  :cardinality :many
+                                                  :public? false
+                                                  :hide? true
+                                                  :view-context :never}
+                                         :queryable? false}
+     :logseq.property/checkbox-display-properties {:title "Properties displayed as checkbox"
+                                                   :schema {:type :property
+                                                            :cardinality :many
+                                                            :hide? true}
+                                                   :queryable? false}
      ;; Task props
-     :logseq.property/status
-     {:title "Status"
-      :schema
-      {:type :default
-       :public? true
-       :ui-position :block-left}
-      :closed-values
-      (mapv (fn [[db-ident value icon checkbox-state]]
-              {:db-ident db-ident
-               :value value
-               :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)
-               :icon {:type :tabler-icon :id icon}
-               :properties (when (some? checkbox-state)
-                             {:logseq.property/choice-checkbox-state checkbox-state})})
-            [[:logseq.property/status.backlog "Backlog" "Backlog"]
-             [:logseq.property/status.todo "Todo" "Todo" false]
-             [:logseq.property/status.doing "Doing" "InProgress50"]
-             [:logseq.property/status.in-review "In Review" "InReview"]
-             [:logseq.property/status.done "Done" "Done" true]
-             [:logseq.property/status.canceled "Canceled" "Cancelled"]])
-      :properties {:logseq.property/hide-empty-value true
-                   :logseq.property/default-value :logseq.property/status.todo
-                   :logseq.property/enable-history? true}
-      :queryable? true}
-     :logseq.property/priority
-     {:title "Priority"
-      :schema
-      {:type :default
-       :public? true
-       :ui-position :block-left}
-      :closed-values
-      (mapv (fn [[db-ident value icon]]
-              {:db-ident db-ident
-               :value value
-               :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)
-               :icon {:type :tabler-icon :id icon}})
-            [[:logseq.property/priority.low "Low" "priorityLvlLow"]
-             [:logseq.property/priority.medium "Medium" "priorityLvlMedium"]
-             [:logseq.property/priority.high "High" "priorityLvlHigh"]
-             [:logseq.property/priority.urgent "Urgent" "priorityLvlUrgent"]])
-      :properties {:logseq.property/hide-empty-value true
-                   :logseq.property/enable-history? true}}
-     :logseq.property/deadline
-     {:title "Deadline"
-      :schema {:type :datetime
-               :public? true
-               :ui-position :block-below}
-      :properties {:logseq.property/hide-empty-value true
-                   :logseq.property/description "Use it to finish something at a specific date(time)."}
-      :queryable? true}
-     :logseq.property/scheduled
-     {:title "Scheduled"
-      :schema {:type :datetime
-               :public? true
-               :ui-position :block-below}
-      :properties {:logseq.property/hide-empty-value true
-                   :logseq.property/description "Use it to plan something to start at a specific date(time)."}
-      :queryable? true}
-     :logseq.property.repeat/recur-frequency
-     (let [schema {:type :number
-                   :public? false}]
-       {:title "Repeating recur frequency"
-        :schema schema
-        :properties {:logseq.property/hide-empty-value true
-                     :logseq.property/default-value 1}
-        :queryable? true})
-     :logseq.property.repeat/recur-unit
-     {:title "Repeating recur unit"
-      :schema {:type :default
-               :public? false}
-      :closed-values (mapv (fn [[db-ident value]]
-                             {:db-ident db-ident
-                              :value value
-                              :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)})
-                           [[:logseq.property.repeat/recur-unit.minute "Minute"]
-                            [:logseq.property.repeat/recur-unit.hour "Hour"]
-                            [:logseq.property.repeat/recur-unit.day "Day"]
-                            [:logseq.property.repeat/recur-unit.week "Week"]
-                            [:logseq.property.repeat/recur-unit.month "Month"]
-                            [:logseq.property.repeat/recur-unit.year "Year"]])
-      :properties {:logseq.property/hide-empty-value true
-                   :logseq.property/default-value :logseq.property.repeat/recur-unit.day}
-      :queryable? true}
-     :logseq.property.repeat/repeated?
-     {:title "Node Repeats?"
-      :schema {:type :checkbox
-               :hide? true}
-      :queryable? true}
-     :logseq.property.repeat/temporal-property
-     {:title "Repeating Temporal Property"
-      :schema {:type :property
-               :hide? true}}
-     :logseq.property.repeat/checked-property
-     {:title "Repeating Checked Property"
-      :schema {:type :property
-               :hide? true}}
+     :logseq.property/status {:title "Status"
+                              :schema
+                              {:type :default
+                               :public? true
+                               :ui-position :block-left}
+                              :closed-values
+                              (mapv (fn [[db-ident value icon checkbox-state]]
+                                      {:db-ident db-ident
+                                       :value value
+                                       :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)
+                                       :icon {:type :tabler-icon :id icon}
+                                       :properties (when (some? checkbox-state)
+                                                     {:logseq.property/choice-checkbox-state checkbox-state})})
+                                    [[:logseq.property/status.backlog "Backlog" "Backlog"]
+                                     [:logseq.property/status.todo "Todo" "Todo" false]
+                                     [:logseq.property/status.doing "Doing" "InProgress50"]
+                                     [:logseq.property/status.in-review "In Review" "InReview"]
+                                     [:logseq.property/status.done "Done" "Done" true]
+                                     [:logseq.property/status.canceled "Canceled" "Cancelled"]])
+                              :properties {:logseq.property/hide-empty-value true
+                                           :logseq.property/default-value :logseq.property/status.todo
+                                           :logseq.property/enable-history? true}
+                              :queryable? true}
+     :logseq.property/priority {:title "Priority"
+                                :schema
+                                {:type :default
+                                 :public? true
+                                 :ui-position :block-left}
+                                :closed-values
+                                (mapv (fn [[db-ident value icon]]
+                                        {:db-ident db-ident
+                                         :value value
+                                         :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)
+                                         :icon {:type :tabler-icon :id icon}})
+                                      [[:logseq.property/priority.low "Low" "priorityLvlLow"]
+                                       [:logseq.property/priority.medium "Medium" "priorityLvlMedium"]
+                                       [:logseq.property/priority.high "High" "priorityLvlHigh"]
+                                       [:logseq.property/priority.urgent "Urgent" "priorityLvlUrgent"]])
+                                :properties {:logseq.property/hide-empty-value true
+                                             :logseq.property/enable-history? true}}
+     :logseq.property/deadline {:title "Deadline"
+                                :schema {:type :datetime
+                                         :public? true
+                                         :ui-position :block-below}
+                                :properties {:logseq.property/hide-empty-value true
+                                             :logseq.property/description "Use it to finish something at a specific date(time)."}
+                                :queryable? true}
+     :logseq.property/scheduled {:title "Scheduled"
+                                 :schema {:type :datetime
+                                          :public? true
+                                          :ui-position :block-below}
+                                 :properties {:logseq.property/hide-empty-value true
+                                              :logseq.property/description "Use it to plan something to start at a specific date(time)."}
+                                 :queryable? true}
+     :logseq.property.repeat/recur-frequency (let [schema {:type :number
+                                                           :public? false}]
+                                               {:title "Repeating recur frequency"
+                                                :schema schema
+                                                :properties {:logseq.property/hide-empty-value true
+                                                             :logseq.property/default-value 1}
+                                                :queryable? true})
+     :logseq.property.repeat/recur-unit {:title "Repeating recur unit"
+                                         :schema {:type :default
+                                                  :public? false}
+                                         :closed-values (mapv (fn [[db-ident value]]
+                                                                {:db-ident db-ident
+                                                                 :value value
+                                                                 :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)})
+                                                              [[:logseq.property.repeat/recur-unit.minute "Minute"]
+                                                               [:logseq.property.repeat/recur-unit.hour "Hour"]
+                                                               [:logseq.property.repeat/recur-unit.day "Day"]
+                                                               [:logseq.property.repeat/recur-unit.week "Week"]
+                                                               [:logseq.property.repeat/recur-unit.month "Month"]
+                                                               [:logseq.property.repeat/recur-unit.year "Year"]])
+                                         :properties {:logseq.property/hide-empty-value true
+                                                      :logseq.property/default-value :logseq.property.repeat/recur-unit.day}
+                                         :queryable? true}
+     :logseq.property.repeat/repeated? {:title "Node Repeats?"
+                                        :schema {:type :checkbox
+                                                 :hide? true}
+                                        :queryable? true}
+     :logseq.property.repeat/temporal-property {:title "Repeating Temporal Property"
+                                                :schema {:type :property
+                                                         :hide? true}}
+     :logseq.property.repeat/checked-property {:title "Repeating Checked Property"
+                                               :schema {:type :property
+                                                        :hide? true}}
 
      ;; TODO: Add more props :Assignee, :Estimate, :Cycle, :Project
 
@@ -426,39 +402,36 @@
                                                 :view-context :page
                                                 :public? true}}
 
-     :logseq.property.view/type
-     {:title "View Type"
-      :schema
-      {:type :default
-       :public? false
-       :hide? true}
-      :closed-values
-      (mapv (fn [[db-ident value icon]]
-              {:db-ident db-ident
-               :value value
-               :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)
-               :icon {:type :tabler-icon :id icon}})
-            [[:logseq.property.view/type.table "Table View" "table"]
-             [:logseq.property.view/type.list "List View" "list"]
-             [:logseq.property.view/type.gallery "Gallery View" "layout-grid"]])
-      :properties {:logseq.property/default-value :logseq.property.view/type.table}
-      :queryable? true}
-
-     :logseq.property.view/feature-type
-     {:title "View Feature Type"
-      :schema
-      {:type :keyword
-       :public? false
-       :hide? true}
-      :queryable? false}
-
-     :logseq.property.view/group-by-property
-     {:title "View group by property"
-      :schema
-      {:type :property
-       :public? false
-       :hide? true}
-      :queryable? true}
+     :logseq.property.view/type {:title "View Type"
+                                 :schema
+                                 {:type :default
+                                  :public? false
+                                  :hide? true}
+                                 :closed-values
+                                 (mapv (fn [[db-ident value icon]]
+                                         {:db-ident db-ident
+                                          :value value
+                                          :uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)
+                                          :icon {:type :tabler-icon :id icon}})
+                                       [[:logseq.property.view/type.table "Table View" "table"]
+                                        [:logseq.property.view/type.list "List View" "list"]
+                                        [:logseq.property.view/type.gallery "Gallery View" "layout-grid"]])
+                                 :properties {:logseq.property/default-value :logseq.property.view/type.table}
+                                 :queryable? true}
+
+     :logseq.property.view/feature-type {:title "View Feature Type"
+                                         :schema
+                                         {:type :keyword
+                                          :public? false
+                                          :hide? true}
+                                         :queryable? false}
+
+     :logseq.property.view/group-by-property {:title "View group by property"
+                                              :schema
+                                              {:type :property
+                                               :public? false
+                                               :hide? true}
+                                              :queryable? true}
 
      :logseq.property.view/sort-groups-by-property {:title "View sort groups by"
                                                     :schema
@@ -922,3 +895,52 @@
   (when db
     (let [block (or (d/entity db (:db/id block)) block)]
       (lookup block db-ident))))
+
+(defn built-in-ident->i18n-key
+  "Derives an i18n key from a built-in db-ident.
+   Returns nil for non-built-in idents.
+   Examples:
+     :block/alias -> :property.built-in/alias
+     :logseq.property/status -> :property.built-in/status
+     :logseq.property.code/lang -> :property.built-in/code-lang
+     :logseq.class/Task -> :class.built-in/task
+     :logseq.property/status.backlog -> :property.status/backlog"
+  [db-ident]
+  (let [ns-str (namespace db-ident)
+        n (name db-ident)]
+    (cond
+      (= ns-str "logseq.class")
+      (keyword "class.built-in" (string/lower-case n))
+
+      (or (= ns-str "logseq.property")
+          (string/starts-with? ns-str "logseq.property."))
+      (let [sub-ns (when (not= ns-str "logseq.property")
+                     (subs ns-str (count "logseq.property.")))
+            dot-idx (string/index-of n ".")
+            clean-n (string/replace n #"\?$" "")]
+        (if dot-idx
+          ;; Closed value: logseq.property/status.backlog -> :property.status/backlog
+          (let [prop-part (subs clean-n 0 dot-idx)
+                choice-part (subs clean-n (inc dot-idx))
+                subdomain (if sub-ns (str sub-ns "-" prop-part) prop-part)]
+            (keyword (str "property." subdomain) choice-part))
+          ;; Property definition
+          (if sub-ns
+            (keyword "property.built-in" (str sub-ns "-" clean-n))
+            (keyword "property.built-in" clean-n))))
+
+      (= ns-str "block")
+      (keyword "property.built-in" (string/replace n #"\?$" ""))
+
+      :else nil)))
+
+(defn built-in-display-title
+  "Returns the display title for a built-in entity (property or class).
+   `translate-fn` takes an i18n keyword and returns the translated string.
+   Falls back to (:block/title entity) when no translation is available."
+  [entity translate-fn]
+  (or (when-let [i18n-key (some-> (:db/ident entity) built-in-ident->i18n-key)]
+        (let [s (translate-fn i18n-key)]
+          (when-not (string/starts-with? (str s) "{Missing")
+            s)))
+      (:block/title entity)))

+ 10 - 0
deps/db/test/logseq/db_test.cljs

@@ -58,6 +58,16 @@
     (is (= "movie" (:block/title (ldb/get-case-page @conn "movie"))))
     (is (= "Movie" (:block/title (ldb/get-case-page @conn "Movie"))))))
 
+(deftest get-journal-page-by-day
+  (let [conn (db-test/create-conn-with-blocks
+              {:pages-and-blocks
+               [{:page {:build/journal 20260410}}
+                {:page {:build/journal 20260411}}]})]
+    (is (= "Apr 10th, 2026"
+           (:block/title (ldb/get-journal-page-by-day @conn 20260410))))
+    (is (= "Apr 11th, 2026"
+           (:block/title (ldb/get-journal-page-by-day @conn 20260411))))))
+
 (deftest page-exists
   (let [conn (db-test/create-conn-with-blocks
               {:properties

+ 4 - 4
deps/graph-parser/test/resources/exporter-test-graph/ignored/about.org

@@ -17,14 +17,14 @@
    Your notes will be stored in the local browser storage. We are using IndexedDB.
 ** How do I use it?
 *** 1. Sync between multiple devices
-    Currently, we only support syncing through Github, more options (e.g.
+    Currently, we only support syncing through GitHub, more options (e.g.
     Gitlab, Dropbox, Google Drive, WebDAV, etc.) will be added soon.
 
     We are using an excellent web git client called [[https://isomorphic-git.org/][isomorphic-git]].
 **** Step 1
-     Click the button /Login with Github/.
+     Click the button /Login with GitHub/.
 **** Step 2
-     Set your Github personal access token, the token will be encrypted and
+     Set your GitHub personal access token, the token will be encrypted and
      stored in the browser local storage, our server will never store it.
 
      If you know nothing about either Git or the personal access token, no worries,
@@ -50,7 +50,7 @@
    - Twitter: https://twitter.com/logseq
    - Discord: https://discord.gg/KpN4eHY where we ask questions and share tips
    - Website: https://logseq.com/
-   - Github: https://github.com/logseq/logseq everyone is encouraged to report issues!
+   - GitHub: https://github.com/logseq/logseq everyone is encouraged to report issues!
    - Our blog: https://logseq.com/blog
 ** Credits to
    - [[https://roamresearch.com/][Roam Research]]

+ 2 - 1
deps/outliner/src/logseq/outliner/core.cljs

@@ -842,7 +842,8 @@
     (when (seq (filter :logseq.property/built-in? top-level-blocks*))
       (throw (ex-info "Built-in nodes can't be deleted"
                       {:type :notification
-                       :payload {:message "Built-in nodes can't be deleted"
+                       :payload {:message "Built-in nodes can't be deleted."
+                                 :i18n-key :node/built-in-cant-delete-error
                                  :type :error}})))
     (when (seq top-level-blocks)
       (let [from-property (:logseq.property/created-from-property start-block)

+ 4 - 2
deps/outliner/src/logseq/outliner/page.cljs

@@ -257,13 +257,15 @@
            (and (not class?) (not (every? ldb/internal-page? pages)))
            (throw (ex-info "Cannot create this page unless all parents are pages"
                            {:type :notification
-                            :payload {:message "Cannot create this page unless all parents are pages"
+                            :payload {:message "Cannot create this page unless all parents are pages."
+                                      :i18n-key :page.validation/parents-must-be-pages
                                       :type :warning}}))
 
            (and class? (not (every? ldb/class? pages)))
            (throw (ex-info "Cannot create this tag unless all parents are tags"
                            {:type :notification
-                            :payload {:message "Cannot create this tag unless all parents are tags"
+                            :payload {:message "Cannot create this tag unless all parents are tags."
+                                      :i18n-key :class.validation/parents-must-be-tags
                                       :type :warning}}))
 
            :else

+ 23 - 8
deps/outliner/src/logseq/outliner/property.cljs

@@ -61,7 +61,8 @@
     (throw (ex-info "Property is protected and can't be deleted"
                     {:type :notification
                      :payload {:type :error
-                               :message "Property is protected and can't be deleted"
+                               :message "Property is protected and can't be deleted."
+                               :i18n-key :property.validation/protected
                                :entity-idents entity-idents
                                :property property-ident}}))))
 
@@ -72,7 +73,9 @@
                                     ldb/private-tags))]
     (throw (ex-info "Can't remove private tags"
                     {:type :notification
-                     :payload {:message (str "Can't remove private tags: " (string/join ", " private-tags))
+                     :payload {:message (str "Can't remove private tags: " (string/join ", " private-tags) ".")
+                               :i18n-key :class.validation/cant-remove-private-tags
+                               :i18n-args [(string/join ", " private-tags)]
                                :type :error}
                      :property-id :block/tags}))))
 
@@ -81,7 +84,8 @@
   (when (contains? db-malli-schema/required-properties property-ident)
     (throw (ex-info "Can't remove required property"
                     {:type :notification
-                     :payload {:message "Can't remove required property"
+                     :payload {:message "Can't remove required property."
+                               :i18n-key :property.validation/cant-remove-required
                                :type :error}
                      :property-id property-ident}))))
 
@@ -144,7 +148,9 @@
     (or result
         (throw (ex-info (str "Can't convert \"" v-str "\" to a number")
                         {:type :notification
-                         :payload {:message (str "Can't convert \"" v-str "\" to a number")
+                         :payload {:message (str "Can't convert \"" v-str "\" to a number.")
+                                   :i18n-key :property.validation/cant-convert-to-number
+                                   :i18n-args [v-str]
                                    :type :error}})))))
 
 (defn ^:api convert-property-input-string
@@ -218,6 +224,7 @@
       (throw (ex-info "Disallowed many to one conversion"
                       {:type :notification
                        :payload {:message "This property can't change from multiple values to one value because it has existing data."
+                                 :i18n-key :property.validation/many-to-one
                                  :type :warning}})))
     (when (seq tx-data)
       (ldb/transact! conn tx-data {:outliner-op :update-property
@@ -249,11 +256,13 @@
     (when-not (m/validate schema value)
       (let [errors (-> (m/explain schema value)
                        (me/humanize))
-            error-msg (str "\"" (:block/title property) "\"" " " (if (coll? errors) (first errors) errors))]
+            error-msg (str "Property validation failed: \"" (:block/title property) "\" " (if (coll? errors) (first errors) errors))]
         (throw
          (ex-info "Schema validation failed"
                   {:type :notification
                    :payload {:message error-msg
+                             :i18n-key :property.validation/invalid-value
+                             :i18n-args [(:block/title property) (if (coll? errors) (first errors) errors)]
                              :type :warning}
                    :property (:db/ident property)
                    :value value
@@ -403,7 +412,8 @@
   (when (and ref? (= value (:db/id block)))
     (throw (ex-info "Can't set this block itself as own property value"
                     {:type :notification
-                     :payload {:message "Can't set this block itself as own property value"
+                     :payload {:message "Can't set this block itself as own property value."
+                               :i18n-key :property.validation/cant-set-self-value
                                :type :error}}))))
 
 (defn batch-remove-property!
@@ -638,6 +648,7 @@
                             (throw (ex-info (str e)
                                             {:type :notification
                                              :payload {:message "Property failed to create. Please try a different property name."
+                                                       :i18n-key :property/create-error
                                                        :type :error}})))))]
     (assert (qualified-keyword? db-ident))
     (when (and (contains? #{:checkbox} (:logseq.property/type schema))
@@ -831,14 +842,17 @@
           (throw (ex-info "Closed value choice already exists"
                           {:error :value-exists
                            :type :notification
-                           :payload {:message "Choice already exists"
+                           :payload {:message "Choice already exists."
+                                     :i18n-key :property.choice/already-exists
                                      :type :warning}}))
 
           validate-message
           (throw (ex-info "Invalid property value"
                           {:error :value-invalid
                            :type :notification
-                           :payload {:message validate-message
+                           :payload {:message (str "Invalid choice \"" value' "\" for this property: " validate-message ".")
+                                     :i18n-key :property.choice/invalid
+                                     :i18n-args [value' validate-message]
                                      :type :warning}}))
 
           (nil? resolved-value)
@@ -894,6 +908,7 @@
        (throw (ex-info "The choice can't be deleted"
                        {:type :notification
                         :payload {:message "The choice can't be deleted because it's built-in."
+                                  :i18n-key :property.choice/cant-delete-built-in
                                   :type :warning}}))
        (let [tx-data (conj (:tx-data (outliner-core/delete-blocks @conn [value-block] {}))
                            (outliner-core/block-with-updated-at {:db/id (:db/id property)}))]

+ 42 - 9
deps/outliner/src/logseq/outliner/validate.cljs

@@ -19,6 +19,7 @@
                     (merge meta-m
                            {:type :notification
                             :payload {:message "Page name can't include \"#\"."
+                                      :i18n-key :page.validation/name-no-hash
                                       :type :warning}}))))
   (when (and (string/includes? page-title ns-util/parent-char)
              (not (common-date/normalize-date page-title nil)))
@@ -26,6 +27,7 @@
                     (merge meta-m
                            {:type :notification
                             :payload {:message "Page name can't include \"/\"."
+                                      :i18n-key :page.validation/name-no-slash
                                       :type :warning}})))))
 
 (defn ^:api validate-page-title
@@ -35,6 +37,7 @@
                     (merge meta-m
                            {:type :notification
                             :payload {:message "Page name can't be blank."
+                                      :i18n-key :page.validation/name-blank
                                       :type :warning}})))))
 
 (defn- find-other-ids-with-title-and-tags
@@ -91,11 +94,15 @@
             (throw (ex-info "Duplicate property"
                             {:type :notification
                              :payload {:message (str "Another property named " (pr-str new-title) " already exists.")
+                                       :i18n-key :property.validation/duplicate
+                                       :i18n-args [new-title]
                                        :type :warning}}))
             (ldb/class? entity)
             (throw (ex-info "Duplicate class"
                             {:type :notification
                              :payload {:message (str "Another tag named " (pr-str new-title) " already exists.")
+                                       :i18n-key :class.validation/duplicate
+                                       :i18n-args [new-title]
                                        :type :warning}}))
             :else
             (throw (ex-info "Duplicate page"
@@ -103,6 +110,10 @@
                              :payload {:message (str "Another page named " (pr-str new-title) " already exists for tags: "
                                                      (string/join ", "
                                                                   (map (fn [id] (str "#" (:block/title (d/entity db id)))) common-tag-ids)))
+                                       :i18n-key :page.validation/duplicate
+                                       :i18n-args [new-title
+                                                   (string/join ", "
+                                                                (map (fn [id] (str "#" (:block/title (d/entity db id)))) common-tag-ids))]
                                        :type :warning}}))))))))
 
 (defn ^:api validate-unique-by-name-and-tags
@@ -122,6 +133,7 @@
     (throw (ex-info "Page can't be renamed to a journal"
                     {:type :notification
                      :payload {:message "This page can't be changed to a journal page"
+                               :i18n-key :journal/page-cant-convert-warning
                                :type :warning}}))))
 
 (defn validate-block-title
@@ -139,6 +151,7 @@
                      (merge meta-m
                             {:type :notification
                              :payload {:message "This is an invalid property name. A property name cannot start with page reference characters '#' or '[['."
+                                       :i18n-key :property.validation/invalid-name
                                        :type :error}}))))))
 
 (defn- validate-extends-property-have-correct-type
@@ -149,6 +162,7 @@
     (throw (ex-info "Can't extend this page since either it is not a tag or is extending from a page that is not a tag"
                     {:type :notification
                      :payload {:message "Can't extend this page since either it is not a tag or is extending from a page that is not a tag"
+                               :i18n-key :class.validation/invalid-extends-type
                                :type :error}
                      :blocks (map #(select-keys % [:db/id :block/title]) (remove ldb/class? child-ents))}))))
 
@@ -158,6 +172,7 @@
     (throw (ex-info "Can't change the extends of a built-in tag"
                     {:type :notification
                      :payload {:message "Can't change the extends of a built-in tag"
+                               :i18n-key :class.validation/built-in-extends-change
                                :type :error}}))))
 
 (defn- disallow-extends-cycle
@@ -169,6 +184,7 @@
         (throw (ex-info "Extends cycle"
                         {:type :notification
                          :payload {:message "Tag extends cycle"
+                                   :i18n-key :class.validation/extends-cycle
                                    :type :error
                                    :blocks (map #(select-keys % [:db/id :block/title]) [child])}}))))))
 
@@ -189,6 +205,8 @@
       (throw (ex-info (str "Can't set tag with built-in page that isn't a tag " (pr-str (:block/title tag-ent)))
                       {:type :notification
                        :payload {:message (str "Can't set tag with built-in page that isn't a tag " (pr-str (:block/title tag-ent)))
+                                 :i18n-key :class.validation/tag-with-non-tag
+                                 :i18n-args [(:block/title tag-ent)]
                                  :type :error}
                        :property-value v})))))
 
@@ -201,14 +219,17 @@
               (and
                (every? (fn [id] (ldb/asset? (d/entity db id))) block-eids)
                (= :logseq.class/Asset (:db/ident (d/entity db v))))))
-    (throw (ex-info (str (if delete? "Can't remove tag" "Can't set tag")
-                         " with built-in #" (:block/title (d/entity db v)))
-                    {:type :notification
-                     :payload {:message (str (if delete? "Can't remove tag" "Can't set tag")
-                                             " with built-in #" (:block/title (d/entity db v)))
-                               :type :error}
-                     :property-id :block/tags
-                     :property-value v}))))
+    (let [tag-title (:block/title (d/entity db v))]
+      (throw (ex-info (str (if delete? "Can't remove tag" "Can't set tag")
+                           " with built-in #" tag-title)
+                      {:type :notification
+                       :payload {:message (str (if delete? "Can't remove tag" "Can't set tag")
+                                               " with built-in #" tag-title)
+                                 :i18n-key (if delete? :class.validation/cant-remove-tag-built-in :class.validation/cant-set-tag-built-in)
+                                 :i18n-args [tag-title]
+                                 :type :error}
+                       :property-id :block/tags
+                       :property-value v})))))
 
 (defn- disallow-tagging-a-built-in-entity
   [db block-eids & {:keys [delete?]}]
@@ -219,6 +240,8 @@
                     {:type :notification
                      :payload {:message (str (if delete? "Can't remove tag" "Can't add tag")
                                              " on built-in " (pr-str (:block/title built-in-ent)))
+                               :i18n-key (if delete? :class.validation/cant-remove-tag-on-built-in :class.validation/cant-add-tag-on-built-in)
+                               :i18n-args [(:block/title built-in-ent)]
                                :type :error}}))))
 
 (defn- disallow-removing-page-tag
@@ -239,6 +262,8 @@
                                :payload
                                {:message (str "Page " (pr-str (:block/title entity)) " cannot be converted to a block")
                                 :type :error
+                                :i18n-key :page.convert/cant-be-block
+                                :i18n-args [(:block/title entity)]
                                 :entity (into {} entity)
                                 :property :block/tags}}))
               (= (:db/id library-page) (:db/id (:block/parent entity)))
@@ -247,6 +272,8 @@
                                :payload
                                {:message (str "Page " (pr-str (:block/title entity)) " cannot be converted to a block, please move it to another page first")
                                 :type :error
+                                :i18n-key :page.convert/cant-be-block-move-first
+                                :i18n-args [(:block/title entity)]
                                 :entity (into {} entity)
                                 :property :block/tags}}))
               (some entity-util/page? (:block/_parent entity))
@@ -255,6 +282,8 @@
                                :payload
                                {:message (str "Page " (pr-str (:block/title entity)) " cannot be converted to a block because it has page children")
                                 :type :error
+                                :i18n-key :page.convert/cant-be-block-has-children
+                                :i18n-args [(:block/title entity)]
                                 :entity (into {} entity)
                                 :property :block/tags}})))))))))
 
@@ -274,10 +303,14 @@
                     (:logseq.property/created-from-property block))
             (let [message (if (:logseq.property/created-from-property block)
                             "Can't convert property value to page."
-                            "Can't convert this block to page since its parent is not a page.")]
+                            "Can't convert this block to page since its parent is not a page.")
+                  i18n-key (if (:logseq.property/created-from-property block)
+                             :page.convert/property-value-to-page
+                             :page.convert/block-parent-not-page)]
               (throw (ex-info message
                               {:type :notification
                                :payload {:message message
+                                         :i18n-key i18n-key
                                          :type :error
                                          :block (into {} block)}})))))))))
 

+ 13 - 10
deps/shui/src/logseq/shui/dialog/core.cljs

@@ -173,8 +173,9 @@
 
 (rum/defc alert-inner
   [config]
-  (let [{:keys [id title description content footer deferred open?]} config
-        props (dissoc config :id :title :description :content :footer :deferred :open? :alert?)]
+  (let [{:keys [id title description content footer deferred open? ok-label]} config
+        props (dissoc config :id :title :description :content :footer :deferred :open? :alert? :ok-label)
+        ok-label (or ok-label "OK")]
 
     (hooks/use-effect!
      (fn []
@@ -205,15 +206,18 @@
                                (base/button
                                 {:key "ok"
                                  :on-click #(do (close!) (p/resolve! deferred true))
-                                 :size :sm} "OK")]))))))
+                                 :size :sm} ok-label)]))))))
 
 (rum/defc confirm-inner
   [config]
-  (let [{:keys [id deferred outside-cancel? data-reminder]} config
+  (let [{:keys [id deferred outside-cancel? data-reminder data-reminder-label
+                cancel-label ok-label]} config
         reminder? (boolean (and id data-reminder))
         [ready?, set-ready!] (rum/use-state (not reminder?))
         *ok-ref (rum/use-ref nil)
-        *reminder-ref (rum/use-ref nil)]
+        *reminder-ref (rum/use-ref nil)
+        cancel-label (or cancel-label "Cancel")
+        ok-label (or ok-label "OK")]
 
     (hooks/use-effect!
      (fn []
@@ -245,17 +249,16 @@
               :footer
               [:<>
                [:span.flex.items-center.pt-1
-                (when (and id data-reminder)
+                (when (and id data-reminder data-reminder-label)
                   [:label.flex.items-center.gap-1.text-sm
                    (form/checkbox {:ref *reminder-ref})
-                   [:span.opacity-50 "Don't remind me again"]])]
+                   [:span.opacity-50 data-reminder-label]])]
                [:span.flex.gap-2
                 (base/button
                  {:key "cancel"
                   :on-click #(do (close!) (p/reject! deferred false))
                   :variant :outline
-                  :size :sm}
-                 "Cancel")
+                  :size :sm} cancel-label)
                 (base/button
                  {:key "ok"
                   :ref *ok-ref
@@ -265,7 +268,7 @@
                                   (js/localStorage.setItem (str id) (js/Date.now))))
                               (close!)
                               (p/resolve! deferred true))
-                  :size :sm} "OK")]])))))
+                  :size :sm} ok-label)]])))))
 
 (rum/defc install-modals
   < rum/static

+ 6 - 5
deps/shui/src/logseq/shui/select/multi.cljs

@@ -2,7 +2,7 @@
   (:require [clojure.string :as string]
             [logseq.shui.form.core :as form]
             [logseq.shui.hooks :as hooks]
-            [logseq.shui.popup.core :as popup]
+            [logseq.shui.popup.core :as shui-popup]
             [rum.core :as rum]))
 
 (defn- get-k [item]
@@ -35,8 +35,7 @@
     [:div.search-input
      {:ref *el}
      (form/input
-      (merge {:placeholder "search"
-              :on-key-up #(case (.-key %)
+      (merge {:on-key-up #(case (.-key %)
                             "ArrowDown" (set-down! (inc down))
                             "ArrowUp" nil
                             "Enter" (when (fn? on-enter) (on-enter))
@@ -57,10 +56,11 @@
   [items selected-items & {:keys [on-chosen item-render value-render
                                   head-render foot-render open? close!
                                   search-enabled? search-key on-search-key-change
+                                  search-input-placeholder
                                   search-fn search-key-render
                                   item-props content-props]}]
-  (let [x-content popup/dropdown-menu-content
-        x-item popup/dropdown-menu-item
+  (let [x-content shui-popup/dropdown-menu-content
+        x-item shui-popup/dropdown-menu-item
         *head-ref (rum/use-ref nil)
         [search-key1 set-search-key!] (rum/use-state search-key)
         search-key1' (some-> search-key1 (string/trim) (string/lower-case))
@@ -118,6 +118,7 @@
         (when search-enabled?
           (search-input
            {:value search-key1
+            :placeholder (or search-input-placeholder "")
             :on-key-down (fn [^js e]
                            (.stopPropagation e)
                            (case (.-key e)

+ 108 - 79
docs/contributing-to-translations.md

@@ -1,109 +1,138 @@
 ## Intro
 
-Thanks for your interest in improving our translations! This document provides
-details on how to contribute to a translation. This document assumes you can run
-commandline tools, know how to switch languages within Logseq and basic
-Clojurescript familiarity. We use [tongue](https://github.com/tonsky/tongue), a
-most excellent library, for our translations.
+Thanks for helping improve Logseq translations.
+
+This guide is for contributors who translate existing UI text or add missing
+translations for a locale. It is not the guide for changing application code,
+inventing dictionary keys, or rewriting the English source text in
+`src/resources/dicts/en.edn`.
+
+If the English wording or key name is wrong, ask a developer to update
+`en.edn` and follow [the key naming guide](i18n-key-naming.md).
 
 ## Setup
 
-In order to run the commands in this doc, you will need to install
+To run the commands in this doc, install
 [Babashka](https://github.com/babashka/babashka#installation).
 
-## Where to Contribute
-
-Language translations are under,
-[src/resources/dicts/](https://github.com/logseq/logseq/blob/master/src/resources/dicts/) with each language having its own file. For example, the es locale is in `es.edn`.
-
-## Language Overview
-
-First, let's get an overview of Logseq's languages and how many translations your
-language has compared to others:
-
-```shell
-$ bb lang:list
-
-|  :locale | :percent-translated | :translation-count |              :language |
-|----------+---------------------+--------------------+------------------------|
-|      :es |                 100 |                492 |                Español |
-|      :tr |                 100 |                492 |                 Türkçe |
-|      :en |                 100 |                492 |                English |
-|      :uk |                  95 |                466 |             Українська |
-|      :ru |                  95 |                466 |                Русский |
-|      :ko |                  93 |                459 |                    한국어 |
-|      :de |                  93 |                459 |                Deutsch |
-|      :fr |                  92 |                453 |               Français |
-|   :pt-PT |                  92 |                453 |    Português (Europeu) |
-|   :pt-BR |                  92 |                451 | Português (Brasileiro) |
-|      :sk |                  90 |                445 |             Slovenčina |
-|   :zh-CN |                  90 |                441 |                   简体中文 |
-|   :nb-NO |                  75 |                370 |         Norsk (bokmål) |
-|      :ja |                  75 |                368 |                    日本語 |
-|      :pl |                  72 |                353 |                 Polski |
-|      :nl |                  72 |                353 |     Dutch (Nederlands) |
-| :zh-Hant |                  71 |                349 |                   繁體中文 |
-|      :it |                  71 |                349 |               Italiano |
-|      :af |                  22 |                106 |              Afrikaans |
-Total: 19
-```
+## Where Translations Live
 
-Let's try to get your language translated as close to 100% as you can!
+Translation dictionaries live under
+[src/resources/dicts/](https://github.com/logseq/logseq/blob/master/src/resources/dicts/).
+Each locale has its own EDN file, for example `es.edn`.
 
-## Edit a Language
+`en.edn` is the source of truth for keys and English text. Most translation
+contributors only need to edit their locale file.
 
-To see what translations are missing for your language, let's run a command using `es` as the example language:
+## Find Missing Translations
 
-```shell
-$ bb lang:missing es
-|                      :translation-key |                                  :string-to-translate |         :file |
-|---------------------------------------+-------------------------------------------------------+---------------|
-|    :command.editor/toggle-number-list |                                    Toggle number list | dicts/es.edn  |
-...
+To see the overall translation status of every locale:
+
+```sh
+bb lang:list
 ```
 
-Now, manually, add keys for your language to the translation files, save and rerun the above command.
-Over time you're aiming to have this list drop to zero. Since this process can be tedious, there is an option to print the untranslated strings to copy and paste them to the files:
+That table includes `:untranslated-count`, which shows how many English keys
+are still missing for each locale, and `:same-as-en-count`, which helps you
+spot locales that still contain entries copied from English.
+
+To see which entries are missing for one locale, use `es` as an example:
 
 ```sh
-# When pasting this content, be sure to update the indentation to match the file
-$ bb lang:missing es --copy
+bb lang:missing es
+```
 
-;; For dicts/es.edn
-:command.editor/toggle-number-list "Toggle number list"
-...
+To print copy/paste-ready entries:
+
+```sh
+bb lang:missing es --copy
 ```
 
-Almost all translations are small. The only exceptions to this are keys that point to files e.g. their value is prefixed with `#resource`. TODO: Update when new tutorials are written
+That command prints the missing keys and the current English value so you can
+paste them into your locale file and translate them there.
+
+## Find Entries Still Matching English
+
+To list them for one locale, use `es` as an example:
+
+```sh
+bb lang:pseudo es
+```
+
+This is a review tool, not a hard error. Some entries may legitimately match
+English, but many are unfinished translations copied from `en.edn`.
+
+## Edit a Locale
+
+1. Run `bb lang:missing <locale>`.
+2. Add the missing keys to `src/resources/dicts/<locale>.edn`.
+3. Save the file.
+4. Run `bb lang:missing <locale>` again until the list is empty or contains
+   only entries you want to leave for later.
+
+Missing keys are allowed. Logseq falls back to English automatically, so do not
+copy English into your locale file just to make the list shorter.
 
 ### Editing Tips
 
-* Some translations may include punctuation like `:` or `!`. When translating them, please use the punctuation that makes the most sense for your language as you don't have to follow the English ones.
-* Some translations may include arguments/interpolations e.g. `{1}`. If you see them in a translation, be sure to include them. These arguments are substituted in the string and are usually used for something the app needs to calculate e.g. a number. See [these docs](https://github.com/tonsky/tongue#interpolation) for more examples.
-* Rarely, a translation is a function that calls code and look like `(fn ... )`
-    * The logic for these fns must be simple and can only use the following fns: `str`, `when`, `if` and `=`.
-    * These fn translations are usually used to handle pluralization or handle formatted text by returning [hiccup-style HTML](https://github.com/weavejester/hiccup#syntax). For example, a hiccup style translation would look like `(fn [] [:div "FOO"])`. See `:on-boarding/main-title` for more examples.
+- Translate the complete sentence or label owned by the key. Do not rename keys
+  or split one sentence across multiple keys.
+- If the English value is a plain string, keep your locale value a plain
+  string.
+- Keep placeholders exactly aligned with English, for example `{1}` and `{2}`.
+- If the English value uses hiccup or `(fn ...)`, keep the same outer shape and
+  translate only the user-visible strings inside it. If changing that structure
+  seems necessary, ask a developer for help.
+- Preserve emoji and icon glyphs from `en.edn` exactly, but use punctuation
+  that is natural for your language.
+- If a sentence is already correct in your language without plural logic, use a
+  plain string. Do not add function logic just because English does.
 
 ## Fix Mistakes
 
-There is a lint command to catch common translation mistakes - `bb
-lang:validate-translations`. This runs for all contribution pull requests so
-you'll need to ensure it doesn't fail. Mistakes that it catches:
+Run this before submitting translation changes:
+
+```sh
+bb lang:validate-translations
+```
+
+It checks for:
 
-* Adding translation entries for nonexistent entries in English.
-    * Most common mistake is mistyping an entry name
-* Adding English entries for translations that don't exist in the UI.
-* Adding translation entries that are just duplicates of the English entry.
-    * This catches contributors copying entries from English and then forgetting to translate. Sometimes you do want to have the translation be the same. For this case, add an entry to `allowed-duplicates` in
-[lang.clj](https://github.com/logseq/logseq/blob/master/scripts/src/logseq/tasks/lang.clj) for your language
-with a list of duplicated entries e.g. `:nb-NO #{:port ...}`.
+- locale keys that do not exist in `en.edn`
+- dictionary keys that are no longer used
+- placeholder mismatches such as `{1}` vs `{2}`
+- locale entries that no longer match an English rich-translation shape
 
-Nonexistent and some invalid entries can be removed by running `bb lang:validate-translations --fix`.
+`bb lang:validate-translations` does not flag entries that still match English.
+Use `bb lang:pseudo <locale>` when you want to review those separately.
+
+To remove stale or invalid keys automatically:
+
+```sh
+bb lang:validate-translations --fix
+```
+
+`--fix` removes invalid or unused keys. It does not repair placeholder mistakes
+or rewrite rich translations for you.
+
+After editing dictionary files, run:
+
+```sh
+bb lang:format-dicts
+```
+
+This restores the repo's canonical key ordering and namespace spacing.
+
+You do not need `bb lang:lint-hardcoded` for translation-only work. That
+command is for developers who are editing UI code.
 
 ## Add a Language
 
 To add a new language:
-* Add an entry to `frontend.dicts/languages`
-* Create a new file under `src/resources/dicts/` and name the file the same as the locale e.g. zz.edn for a hypothetical zz locale.
-* Add an entry in `frontend.dicts/dicts` referencing the file you created.
-* Then start translating for your language and adding entries in your language's EDN file using the `bb lang:missing` workflow.
+
+1. Add an entry to `frontend.dicts/languages`.
+2. Create a new file under `src/resources/dicts/` and name it after the locale,
+   for example `zz.edn`.
+3. Add that file to `frontend.dicts/dicts`.
+4. Use the `bb lang:missing <locale>` workflow to populate translations.
+5. Run `bb lang:validate-translations` and `bb lang:format-dicts`.

+ 87 - 19
docs/dev-practices.md

@@ -76,23 +76,90 @@ error if it detects an invalid query.
 
 ### Translations
 
-We use [tongue](https://github.com/tonsky/tongue), a simple and effective
-library, for translations. We have a couple bb tasks for working with
-translations under `lang:` e.g. `bb lang:list`. See [the translator
-guide](./contributing-to-translations.md) for usage.
-
-One useful task for reviewers (us) and contributors alike, is `bb
-lang:validate-translations` which catches [common
-mistakes](./contributing-to-translations.md#fix-mistakes)). When reviewing
-translations here are some things to keep in mind:
-
-* Punctuation and delimiting characters (e.g. `:`, `:`, `?`) should be part of
-  the translatable string. Those characters and their position may vary depending on the language.
-* Translations usually return strings but they can return hiccup vectors with a
-  fn translation. Hiccup vectors are needed when word order matters for a
-  translation and formatting is involved. See [this 3 word Turkish
-  example](https://github.com/logseq/logseq/commit/1d932f07c4a0aad44606da6df03a432fe8421480#r118971415).
-* Translations can be anonymous fns with arguments for interpolating strings. Fns should be simple and only include the following fns: `str`, `when`, `if` and `=`.
+We use [tongue](https://github.com/tonsky/tongue) for translations.
+
+Responsibilities are split across a few files:
+
+* [docs/contributing-to-translations.md](./contributing-to-translations.md) is
+  for locale contributors.
+* [docs/i18n-key-naming.md](./i18n-key-naming.md) is for naming and reusing
+  keys in `src/resources/dicts/en.edn`.
+* [.i18n-lint.toml](../.i18n-lint.toml) is the source of truth for hardcoded UI
+  text lint scope, translatable helpers/attributes, exclusions, and allowlists.
+
+#### What must be internationalized
+
+Inside the scope defined by `.i18n-lint.toml`, all user-visible UI text must be
+internationalized.
+
+Exceptions:
+
+* Console output does not need translation.
+* Keep out-of-scope developer-only `(Dev)` labels next to the developer
+  UI/command definition; do not add them to translation dictionaries.
+
+If you introduce a new UI helper, alert API, UI namespace, translatable
+attribute, or other shipped UI surface, update `.i18n-lint.toml` so the lint
+continues to cover it.
+
+#### Translation helpers
+
+All translation helpers live in
+`src/main/frontend/context/i18n.cljs`. Do not add parallel ad hoc i18n helpers
+elsewhere.
+
+| Helper | Use for |
+|---|---|
+| `t` | Standard translation with preferred-locale lookup |
+| `tt` | Try multiple keys and return the first existing translation |
+| `t-en` | Force English output, for example when UI text also needs an English console copy |
+| `interpolate-rich-text` / `interpolate-rich-text-node` | Replace placeholders with rich-text or hiccup fragments |
+| `interpolate-sentence` | Keep a full sentence in one key while inserting placeholders and inline links |
+| `replace-newlines-with-br` | Render translated newline characters as `[:br]` nodes |
+| `locale-join-rich-text` / `locale-join-rich-text-node` | Join rich fragments with locale-aware separators |
+| `locale-format-number` / `locale-format-date` / `locale-format-time` | Format dynamic numbers and dates before passing them into translations |
+
+#### Developer workflow
+
+1. Use `.i18n-lint.toml` to decide whether the text is in i18n scope.
+2. Search `src/resources/dicts/en.edn` for an existing key with the same
+   semantic owner and textual role.
+3. If no exact match exists, follow
+   [the key naming guide](./i18n-key-naming.md) and add the English source text
+   to `en.edn`.
+4. Add non-English locale entries only when you are also providing actual
+   translations. When renaming or removing keys, clean up stale locale keys.
+5. Replace the literal with the appropriate helper from
+   `frontend.context.i18n`.
+
+Recommended checks:
+
+```sh
+bb lang:validate-translations
+bb lang:lint-hardcoded --git-changed
+bb lang:format-dicts
+```
+
+`bb lang:format-dicts` is the repo-owned formatter for dictionary key ordering
+and namespace spacing. Run it after editing dict files.
+
+#### Content rules
+
+* Keep each translation as complete as possible. Do not assemble sentences from
+  fragments in the caller.
+* For plain dynamic text, use placeholders like `{1}` and pre-format arguments
+  in the caller before passing them to `t`.
+* Function-valued translations are allowed only when a locale needs real logic
+  or rich-text hiccup output. When functions are necessary, only `str`, `when`,
+  `if`, and `=` are allowed inside the function body.
+* Keep rich text in a single translation entry. Do not split one sentence
+  across multiple keys.
+* Non-English locale files should contain only actual translations. Do not copy
+  English values just to fill gaps; Tongue falls back to `:en`.
+* Preserve emoji/icon glyphs from `en.edn` exactly, and use punctuation natural
+  to each locale.
+* Pluralization is locale-specific. Do not force English singular/plural rules
+  onto other languages.
 
 ### Spell Checker
 
@@ -444,8 +511,9 @@ These tasks are specific to database graphs. For these tasks there is a one time
 ### Dev Commands
 
 In the app, you can enable Dev commands under `Settings > Advanced > Developer
-mode`. Then search for commands starting with `(Dev)`. Commands include
-inspectors for block/page data and AST.
+mode`. Then search for commands labeled with `(Dev)`. Those labels are
+intentionally hardcoded English developer-only labels, not translation keys.
+Commands include inspectors for block/page data and AST.
 
 ### Desktop Developer Tools
 

+ 710 - 0
docs/i18n-key-naming.md

@@ -0,0 +1,710 @@
+# Logseq i18n Key Naming Standard
+
+## Purpose
+
+This document defines how to name new i18n keys in `src/resources/dicts/en.edn`.
+
+Goal: given any new user-facing string, this document should let you determine
+its key name directly.
+
+Secondary goal: keep prefixes converged. A name that is slightly less "pure" but
+stays inside an existing owner is usually better than creating a new low-density
+root or singleton dotted subdomain.
+
+This standard is intended to be deterministic. After applying it, you should be
+able to choose one reasonable key name. If you still cannot determine the name,
+treat that as a gap in the standard rather than guessing:
+
+- AI agents must stop and ask for human guidance.
+- Developers are encouraged to report the gap to the Logseq team so the standard
+  can be clarified.
+
+Audience:
+
+- developers adding or renaming English keys
+- AI agents reviewing or generating i18n changes
+
+Non-English locale contributors should not invent or rename keys. They should
+use [contributing-to-translations.md](contributing-to-translations.md) instead.
+
+This document is only about key naming and reuse. Rules for placeholders,
+hiccup, punctuation, locale fallback, linting, and helper selection live in
+[dev-practices.md](dev-practices.md).
+
+Developer-only `(Dev)` labels are out of scope for this document. Keep them as
+inline English labels next to the developer UI/command definition instead of
+adding translation keys.
+
+## Key Shape
+
+Use this shape:
+
+```clojure
+:<root>[.<subdomain>]/<leaf>
+```
+
+Rules:
+
+- `<root>` chooses the semantic owner
+- `<subdomain>` is optional and used only for a stable subfeature, workflow, or
+  representation
+- `<leaf>` describes the text's role inside that owner
+- `<root>` names are singular semantic owners
+- All segments use kebab-case
+
+Examples:
+
+```clojure
+:ui/close
+:page.delete/confirm-title
+:nav.all-pages/title
+:settings.editor/show-brackets
+:plugin.install-from-file/success
+:view.table/sort-ascending
+:cmdk.action/open
+:mobile.toolbar/undo
+```
+
+## Before Naming a New Key
+
+1. Search `src/resources/dicts/en.edn`.
+2. Reuse a key only when both match:
+   - semantic owner
+   - textual role
+3. If the English text matches but the owner or role differs, create a new key.
+
+Examples:
+
+- toolbar `"Bold"` and command `"Bold"` are different keys
+- dialog `"Close"` and window `"Close"` are different keys
+- reusable `"Copied!"` feedback may share a `notification/*` key only when it is
+  intentionally cross-domain
+
+## Step 1: Choose the Owner
+
+The namespace must be chosen by owner, not by file path, not by component name,
+and not by where the text happens to be rendered.
+
+There are only 5 owner classes.
+
+### 1. Interaction Systems
+
+Use when the text belongs to an interaction registry or interaction subsystem.
+
+| Namespace | Use for |
+|---|---|
+| `command.*` | Built-in command descriptions |
+| `shortcut.category` | Shortcut help categories |
+| `keymap` | Keybinding editor text |
+| `cmdk` | Command palette text |
+
+Use this class when:
+
+- the text is attached to a command id
+- the text names a shortcut group
+- the text belongs to rebinding/conflict/chord UI
+- the text belongs only to command palette behavior
+
+For `command.*` keys:
+
+- the subdomain is the command group name
+- for built-in command descriptions, mirror the command id namespace even when
+  the command opens another surface or workflow; the owner is still the command
+  registry entry
+- use a stable semantic group name, not an implementation placeholder
+- avoid new opaque or self-referential groups such as `command.command`
+- `command.command-palette/*` is valid for descriptions attached to
+  `:command-palette/*` ids; reserve `cmdk.*` for command-palette UI copy itself
+
+Examples:
+
+```clojure
+:command.editor/bold
+:command.graph/open
+:command.page/toggle-favorite
+:command.shell/run
+:shortcut.category/block-editing
+:keymap/search-placeholder
+:cmdk.action/open
+```
+
+### 2. Shared Primitives
+
+Use only when the wording keeps the same meaning across unrelated domains.
+
+| Namespace | Use for |
+|---|---|
+| `ui` | Generic actions and states |
+| `nav` | Global destinations and route-level constraints |
+| `notification` | Reusable cross-feature feedback and notification-center shell controls |
+| `search` | Generic search vocabulary |
+| `select` | Generic picker vocabulary |
+| `format` | Formatting vocabulary |
+| `color` | Color vocabulary |
+
+Qualification rules:
+
+- removing product context does not change the meaning
+- the wording can be reused in multiple unrelated domains
+- the wording does not name a specific entity or workflow
+- the wording stays natural with the same grammatical role across locales; if
+  callers need different inflection, gender, number, part of speech, or
+  label-vs-status behavior, do not force one shared key just because English
+  matches
+- if the text names a product entity such as `graph`, `page`, or `server`, use
+  that product domain instead
+- do not use `notification` for feature-specific toast text just because it is
+  shown via `notification/show!`; the delivery mechanism does not determine
+  owner
+
+Examples:
+
+```clojure
+:ui/close
+:ui/save
+:nav/home
+:notification/copied
+:search/no-result
+:select/default-prompt
+:format/bold
+:color/red
+```
+
+### 3. Product Domains
+
+This is the default class. Most keys should be here.
+
+Use when the text belongs to a first-class product feature, entity, or workflow.
+
+The 5 groups below are taxonomy buckets, not a priority order. Do not infer
+ownership priority from subsection order. When multiple product domains seem
+plausible, use the conflict rules below.
+
+#### 3.1 Workspace and content domains
+
+- `graph`
+- `file`
+- `page`
+- `block`
+- `node`
+- `journal`
+- `library`
+- `date`
+- `editor`
+- `reference`
+- `property`
+- `class`
+- `view`
+- `query`
+- `icon`
+- `asset`
+- `pdf`
+- `flashcard`
+
+#### 3.2 Data movement and publishing domains
+
+- `import`
+- `export`
+- `publish`
+
+#### 3.3 Customization, AI, and extensibility domains
+
+- `settings`
+- `theme`
+- `plugin`
+- `ai`
+- `youtube`
+- `zotero`
+- `server`
+- `storage`
+
+`ai` is a reserved owner for future built-in AI feature UI. Current AI-related
+settings copy may still live under `settings.ai/*`.
+
+#### 3.4 Account, cloud, and security domains
+
+- `account`
+- `sync`
+- `collaboration`
+- `encryption`
+
+#### 3.5 Support, diagnostics, and lifecycle domains
+
+- `onboarding`
+- `help`
+- `bug-report`
+- `shell`
+- `profiler`
+- `updater`
+- `deeplink`
+
+#### Product domain boundaries
+
+Use this table to resolve common conflicts.
+
+| Namespace | Owns | Does not own |
+|---|---|---|
+| `graph` | Graph lifecycle, graph switching, graph-level state, graph visualization entry points | Individual pages, blocks, raw files |
+| `file` | Raw file browser, file metadata, file-level errors | Graph switching, page semantics, import workflow |
+| `page` | Page metadata and page-level workflows | Active editing mechanics |
+| `block` | Block as stored content entity | Active editing session behavior |
+| `node` | Generic node vocabulary intentionally shared across page/block/tag/property-like entities | Page-only, block-only, property-only, or class-only workflows |
+| `editor` | Active authoring behavior: selection, cursor actions, paste, heading changes, inline creation | Command registry text, page metadata, property schema |
+| `journal` | Journal-only behavior | Generic page behavior |
+| `library` | Library page copy and library-specific add/remove flows | Generic page search or generic page metadata outside the library feature |
+| `date` | Relative-date vocabulary, natural-language date phrases, and date-only labeling/parsing copy | Journal-only workflows, editor command registries |
+| `reference` | Backlinks, linked references, block refs, page refs | Generic search or editing text |
+| `property` | Property schema, values, choices, dialogs, validation | Query semantics and result presentation |
+| `class` | Class/tag schema and class-specific configuration | Generic property schema |
+| `query` | Query definition, query source, query inputs, live-query semantics | Table sorting, grouping, row selection |
+| `view` | Result presentation, table controls, grouping, sorting, columns, selection, representation modes | Query source semantics or property schema |
+| `icon` | Icon picker, emoji/icon browsing, icon-search tabs and counts | Generic search vocabulary or generic select/picker wording outside icon picking |
+| `asset` | Attachments and embedded media assets | Generic file browser or export |
+| `pdf` | PDF viewer and PDF-specific reading/annotation behavior | Generic asset browsing or generic reference behavior |
+| `flashcard` | Card review, card study flow, card-specific review UI | Generic editor actions or generic query/view controls |
+| `import` | Import workflows, import source parsing, import options, and import-specific validation/feedback | Export, publish, or generic file browser wording |
+| `export` | Export workflows, export format/options, export progress, and export-specific feedback | Import flows, publish lifecycle, or generic file browser wording |
+| `publish` | Publish and unpublish flows, publish access settings, and publish status/failure messages | Generic export formats/backups, sync state, or account identity |
+| `settings` | Built-in settings shell copy: settings sections, built-in setting labels, descriptions, and feedback about changing built-in settings | Child feature workflows or subsystem state merely rendered inside settings |
+| `theme` | Theme selection and theme-specific customization | Generic settings |
+| `plugin` | Plugin lifecycle, marketplace, plugin configuration, install/update/remove flows | Built-in settings or theme selection |
+| `ai` | Semantic search, embedding model selection, model download states, and other built-in AI feature UI | Generic settings scaffolding or non-AI search vocabulary |
+| `youtube` | YouTube-specific embed and timestamp behavior | Generic asset/video wording or generic mobile warnings |
+| `zotero` | Built-in Zotero integration, Zotero attachment access, Zotero-linked or imported file affordances, and Zotero-specific defaults | Generic file browser, generic import/export, or plugin lifecycle |
+| `server` | Local HTTP API, MCP, local server setup and diagnostics | Cloud sync or account identity |
+| `storage` | Local persistence, sqlite/local-db storage errors, recycle UI and recycle storage constraints | Cloud sync lifecycle or file browser UI |
+| `account` | Login, identity, plan, membership, billing-facing account state, and account-authentication actions such as resetting the account password | Graph sync state or passwords/keys that gate encrypted data |
+| `sync` | Graph sync, storage usage, invitations, remote graph lifecycle | Login identity or password management |
+| `collaboration` | Collaborators, participants, collaboration-only permissions and presence | Generic sync storage accounting |
+| `encryption` | Passwords, keypairs, encrypted graph access, and key reset flows for encrypted data | Login/billing identity state or account-authentication actions |
+| `onboarding` | First-run setup and initial import/graph setup | General settings or ongoing help |
+| `help` | Help hub copy: documentation, handbook, shortcut help, and community/support entry points | Child workflows launched from help, such as bug-reporting |
+| `bug-report` | Bug reporting, diagnostics, issue helpers | General help navigation |
+| `shell` | Built-in shell command runner UI and its workflow | Built-in command descriptions or generic terminal wording outside the shell runner feature |
+| `profiler` | Built-in profiling and diagnostics UI for developers or advanced users | Bug reporting copy, generic settings, or runtime performance logs |
+| `updater` | App-release update lifecycle: checking, availability, download/install progress, restart/install actions, and updater-specific errors/status | Settings-shell copy, plugin-update UI, or other container/entry-point copy |
+| `deeplink` | `logseq://` or deep-link open flows and deep-link resolution errors | Generic navigation labels or route names |
+
+#### Product domain conflict rules
+
+When multiple product domains could plausibly own the same text, apply these
+rules in order:
+
+1. Choose the narrowest stable owner that names the feature, entity,
+   integration, or workflow itself.
+2. Container or hub owners own only their own shell copy. They do not own child
+   feature copy just because it is rendered there.
+3. Status, progress, result, validation, and error copy belongs to the subsystem
+   or workflow emitting that state.
+4. Render location, launch point, or current screen does not determine owner.
+5. If the same feature text can appear in multiple places, keep one
+   feature-owned key instead of forking container-specific duplicates.
+
+Conflict examples:
+
+```clojure
+:settings.general/check-for-updates
+:updater/checking-for-updates
+:help.shortcuts/title
+:bug-report.inspector/title
+```
+
+More product-domain examples:
+
+```clojure
+:page/delete
+:page.validation/name-no-hash
+:page.convert/cant-be-block
+:editor/remove-heading
+:editor.slash/node-reference
+:date.nlp/today
+:node/built-in-cant-delete-error
+:property/default-value
+:view.table/sort-ascending
+:plugin/install
+:settings.editor/show-brackets
+:sync/invitation-sent
+:encryption/reset-password
+:bug-report.inspector/title
+```
+
+### 4. Shell Surfaces
+
+Use only when the meaning depends on the shell surface itself.
+
+| Namespace | Use for |
+|---|---|
+| `header` | Header-only actions and labels |
+| `sidebar.left` | Left sidebar shell affordances |
+| `sidebar.right` | Right sidebar shell affordances |
+| `context-menu` | Context menu-only affordances |
+| `window` | Window chrome actions |
+
+Use this class when:
+
+- moving the text to another surface would change its meaning
+- the text describes pane controls, sidebar controls, or window chrome
+- the text is not reused in another surface or runtime with the same meaning
+
+Do not use a surface namespace for:
+
+- feature titles rendered inside a surface
+- route or destination labels rendered inside a surface
+- domain workflows that happen to be launched from a surface
+- text that already appears with the same meaning in another surface; move it to
+  `ui`, `nav`, or the feature owner
+
+Examples:
+
+```clojure
+:header/go-back
+:sidebar.left/favorites
+:sidebar.right/close
+:context-menu/set-icon
+:window/minimize
+```
+
+### 5. Platform Runtimes
+
+Use only when the text exists because one runtime has a unique implementation.
+
+| Namespace | Use for |
+|---|---|
+| `mobile` | Mobile-only runtime behavior |
+| `electron` | Electron-only runtime behavior |
+
+Use this class when:
+
+- the workflow exists only on one runtime
+- the wording refers to a native/runtime-only capability
+
+Examples:
+
+```clojure
+:mobile.tab/graphs
+:mobile.settings/version
+:electron/new-window
+:electron/add-to-dictionary
+```
+
+## Step 2: Apply the Decision Tree
+
+Choose the first matching branch and stop.
+
+1. Is the text owned by an interaction system? Use `command.*`,
+   `shortcut.category`, `keymap`, or `cmdk`.
+2. Is the text a shared primitive reused across unrelated domains? Use `ui`,
+   `nav`, `notification`, `search`, `select`, `format`, or `color`.
+3. Is the text owned by a product domain? Use the matching product domain
+   namespace. If multiple product domains seem possible, apply `Product domain
+   conflict rules` and then stop.
+4. Is the text owned by a shell surface? Use `header`, `sidebar.left`,
+   `sidebar.right`, `context-menu`, or `window`.
+5. Is the text runtime-exclusive? Use `mobile` or `electron`.
+
+If none fits, define a new product domain only when the feature has a clear,
+long-lived product boundary. Otherwise, keep the nearest existing product domain
+and use a more specific leaf.
+
+## Owner Constraints
+
+- Do not create roots from implementation modules or component files such as
+  `outliner`, `content`, or `views`.
+- Do not create plural owner roots such as `flashcards` or `views`. Use the
+  singular owner.
+- Do not use implementation acronyms such as `e2ee` when the product-facing
+  owner is `encryption`.
+- Do not use implementation state holders such as `state` as owners. Use the
+  semantic feature owner such as `journal.default-query/*`.
+- Do not use a surface owner for a destination label. Use `nav/*` or the feature
+  owner.
+- Do not use a container or hub owner such as `settings` or `help` for child
+  feature text just because the feature is rendered there.
+- Do not use a validator or storage engine as owner for a domain rule.
+  Validation copy belongs to the constrained domain.
+- Treat a new root with fewer than ~5 plausible near-term keys as a smell, not a
+  goal. Small roots are acceptable only when they name a first-class product
+  feature, entity, or integration with a clear independent boundary.
+- When keeping a new root, update this taxonomy in the same change so the
+  standard stays aligned with `src/resources/dicts/en.edn`.
+- Not every existing key in `en.edn` is a good naming precedent. Prefer this
+  standard even when some legacy keys remain unchanged for compatibility.
+
+Examples:
+
+```clojure
+:reference.filter/title
+:help.handbook/title
+:page.validation/name-blank
+:property.choice/already-exists
+:class.validation/extends-cycle
+```
+
+## Established Namespace Notes
+
+These namespaces already exist in `en.edn` and are acceptable patterns, but
+they have specific reuse guidance.
+
+| Namespace | Status | Guidance |
+|---|---|---|
+| `property.built-in`, `class.built-in` | Intentional | Stable built-in schema vocabularies under the `property` and `class` owners. |
+| `block.macro`, `property.repeat-recur-unit` | Intentional | Stable representation/enum groups. This pattern is acceptable when the subdomain names a real user-facing concept. |
+
+## Step 3: Decide Whether a Subdomain Is Needed
+
+Use a dotted subdomain only for one of these 4 cases.
+
+### 1. Stable section
+
+Examples:
+
+```clojure
+:nav.all-pages/title
+:settings/account
+:settings.editor/show-brackets
+```
+
+### 2. Stable workflow
+
+Examples:
+
+```clojure
+:page.delete/confirm-title
+:page.delete/warning
+:page.delete/success
+:plugin.install-from-file/title
+:editor.slash/group-basic
+```
+
+### 3. Stable representation or mode
+
+Examples:
+
+```clojure
+:view.table/default-title
+:view.table/sort-ascending
+:mobile.toolbar/undo
+:server.status/running
+:cmdk.action/open
+```
+
+### 4. Stable validator, conversion, or settings section
+
+Examples:
+
+```clojure
+:page.validation/name-no-hash
+:page.convert/cant-be-block
+:property.choice/already-exists
+:settings/account
+:help.shortcuts/title
+```
+
+Rules:
+
+- use `.validation/` when the message is the direct result of a validation
+  check, constraint violation, or failed precondition
+- use an existing workflow subdomain such as `.convert/` or `.delete/` for
+  workflow-specific actions, confirmations, and blockers
+- do not create a narrower workflow-variant subdomain when an existing workflow
+  already owns the text
+- for built-in settings tab labels, use flat keys such as `:settings/general`;
+  reserve `:settings.<section>/*` for copy inside that settings section
+- choose compact concept names for subdomains; do not copy a long UI label
+  phrase into a subdomain when a shorter stable concept name exists
+- prefer a flat key when the dotted subdomain would only contain one string for
+  now
+- if an owner already has a flat canonical key for the concept, prefer flat
+  role-suffixed siblings such as `about-title` or `auto-update-check-feedback`
+  over introducing a dotted subdomain just to add another role
+- a flat leaf with a structured suffix such as `about-title` or `terms-title` is
+  acceptable when the same owner already needs the base leaf such as `about` or
+  `terms` for a different role, and creating a dotted singleton namespace would
+  be worse
+- create a new dotted subdomain only when at least one of these is true:
+  - the namespace already has sibling keys
+  - the workflow or section clearly needs multiple roles such as `title` +
+    `desc`, `confirm-title` + `confirm-desc`, or `empty` + `empty-desc`
+  - the flat leaf would become less readable than the dotted form
+- a prefix with 2 to 4 keys is often healthy; the main smell is a singleton
+  dotted subdomain shape such as `help.about/*` or `graph.delete-local/*`
+
+Good examples:
+
+```clojure
+:page.convert/tag-to-page-action
+:page.convert/tag-to-page-confirm-desc
+:property.validation/invalid-name
+:help/about-title
+:help/about
+:graph/delete-local-confirm-desc
+```
+
+Do not use a subdomain for:
+
+- component names
+- implementation names
+- generic layout slices
+- words like `main`, `section`, `btn`, or `modal` when they are only
+  implementation layout terms and not real user-facing modes, surfaces, or
+  scopes
+
+Bad shapes for new keys:
+
+- `:<owner>.main/*`
+- `:command.command/*`
+
+## Step 4: Choose the Leaf
+
+The leaf describes the text's role inside its owner.
+
+### 1. Canonical labels
+
+Use a bare subject or action when the text is the canonical label itself.
+
+Examples:
+
+```clojure
+:ui/save
+:page/backlinks
+:property/default-value
+:plugin/install
+```
+
+### 2. Structured role suffixes
+
+Use these suffixes consistently.
+
+| Suffix | Use for |
+|---|---|
+| `title` | Panel, section, page, dialog, modal title |
+| `desc` | Supporting description |
+| `label` | Control, nav, picker, or form label when it is not the title |
+| `prompt` | Short picker or flow prompt |
+| `placeholder` | Input placeholder |
+| `hint` | Short inline help |
+| `tip` | Advice or explanatory tip |
+| `tooltip` | Hover text |
+| `empty` | Empty-state heading or label |
+| `empty-desc` | Empty-state description |
+| `confirm-title` | Confirmation title |
+| `confirm-desc` | Confirmation body |
+| `success` | Success feedback |
+| `error` | Error feedback |
+| `warning` | Warning feedback |
+| `feedback` | Neutral or severity-agnostic feedback |
+| `count` | Parameterized count text |
+| `action` | Action label when a bare verb would be ambiguous |
+
+Additional rules:
+
+- use `desc`, not `description`, for the textual role suffix
+- this does not ban the literal word `Description` when it is the product term
+  being named
+- use `prompt`, not `message`, for short chooser, picker, or action-sheet
+  instructions
+- use `label` when the text prefixes an inline value such as an ID, date, or
+  selected item
+- do not split one sentence into `*-prefix` and `*-suffix` keys; keep a single
+  translation entry and insert links, shortcuts, or styled fragments with
+  placeholders
+- use `error` or `warning` for failure feedback; prefer `*-error` or `*-warning`
+  over `*-failed`
+- for success, error, and warning feedback, prefer an action or condition stem
+  such as `update-success`, `unpublish-error`, or `invalid-date-warning` instead
+  of past-tense English like `updated` or `failed`
+- do not mechanically shorten a leaf just because one word also appears in the
+  owner; keep the action or condition name when it distinguishes a workflow or
+  condition inside that owner, for example `:publish/publish-error`,
+  `:import/zip-import-error`, or `:date/invalid-date-warning`
+- use `feedback` when the same toast or callout may appear with varying
+  severity, or when the severity is incidental to the wording
+- use `status` only when the text names a status field, status value, or status
+  representation in the product model; do not use `status` as a catch-all suffix
+  for post-action toasts
+- when the base concept is already a fixed product term, keep it intact even if
+  the role suffix repeats an English word, for example `:ui/error-boundary-error`
+
+Examples:
+
+```clojure
+:help.shortcuts/label
+:graph.switch/select-prompt
+:nav.all-pages/title
+:server.config/port-label
+:graph/delete-local-confirm-desc
+:plugin/auto-update-check-feedback
+:property/update-success
+:publish/unpublish-error
+:plugin.install-from-file/success
+:graph.switch/empty-desc
+:page.convert/tag-to-page-action
+```
+
+Use `error` or `warning` for failure feedback, based on the feedback severity
+shown in the UI. Do not use `failure` as a leaf.
+
+## Reuse Rules
+
+Do not reuse a key only because the English text matches.
+
+Reuse a key only when both are the same:
+
+1. semantic owner
+2. textual role
+
+Examples:
+
+- toolbar `"Bold"` and command `"Bold"` are different keys
+- dialog `"Close"` and window `"Close"` are different keys
+- `"Copied!"` may be shared only if it is intentionally a reusable cross-domain
+  notification
+- settings-shell `"Check for updates"` and updater-state `"Checking for
+  updates"` are different keys because the owner differs
+
+When two keys are truly duplicates:
+
+- keep the key that already follows the standard
+- deprecate the duplicate key
+- do not merge keys when one message carries extra workflow or domain-specific
+  detail
+
+## Naming Workflow
+
+For every new string:
+
+1. Identify the owner with the decision tree.
+2. Choose the root namespace from the owner taxonomy.
+3. Add a subdomain only if the string belongs to a stable section, workflow, or
+   representation.
+4. Choose the leaf from the role rules.
+5. Search `src/resources/dicts/en.edn` for an existing key with the same owner
+   and role.
+6. Reuse only on exact semantic match.
+7. If the new name would create a new root or a singleton dotted subdomain,
+   justify why convergence would be worse without it.
+8. Add the English source text to `src/resources/dicts/en.edn`.
+9. After editing dict files, run `bb lang:format-dicts`.
+
+## Canonical Examples
+
+| Need | Correct key |
+|---|---|
+| Generic dialog close button | `:ui/close` |
+| Header back button tooltip | `:header/go-back` |
+| Window close button | `:window/close` |
+| Graph local deletion confirmation body | `:graph/delete-local-confirm-desc` |
+| Page name validation error | `:page.validation/name-no-hash` |
+| Active editor action `"Remove heading"` | `:editor/remove-heading` |
+| Built-in node delete validation | `:node/built-in-cant-delete-error` |
+| Property name input placeholder | `:property/name-placeholder` |
+| Recycle item restore action | `:storage.recycle/restore` |
+| Recycle page deletion metadata | `:storage.recycle/page-deleted-at` |
+| Graph switch picker prompt | `:graph.switch/select-prompt` |
+| Export copied page data feedback | `:export/page-data-copied` |
+| Live query table title | `:view.table/live-query-title` |
+| Table sort ascending action | `:view.table/sort-ascending` |
+| Plugin install-from-file success | `:plugin.install-from-file/success` |
+| Command palette open action | `:cmdk.action/open` |
+| Mobile-only graph tab | `:mobile.tab/graphs` |
+| Server running status | `:server.status/running` |

+ 2 - 1
packages/ui/package.json

@@ -7,7 +7,8 @@
     "watch:ui:examples": "parcel serve ./examples/index.html",
     "build:ui:only": "parcel build --target ui",
     "build:ui": "rm -rf .parcel-cache && yarn build:ui:only",
-    "postinstall": "yarn build:ui"
+    "postinstall": "yarn build:ui",
+    "test": "node --experimental-strip-types --test src/i18n.test.mts"
   },
   "dependencies": {
     "@hookform/resolvers": "^5.2.2",

+ 42 - 0
packages/ui/src/amplify/errors.ts

@@ -0,0 +1,42 @@
+type AuthErrorLike = {
+  code?: string
+  name?: string
+  message?: string
+}
+
+function getAuthErrorName(error: unknown) {
+  const authError = (error ?? {}) as AuthErrorLike
+  return authError.name || authError.code || ''
+}
+
+export function getAuthErrorMessageKey(error: unknown) {
+  switch (getAuthErrorName(error)) {
+    case 'UserNotFoundException':
+      return 'AUTH_ERROR_USER_NOT_FOUND'
+    case 'NotAuthorizedException':
+      return 'AUTH_ERROR_INVALID_CREDENTIALS'
+    case 'UserNotConfirmedException':
+      return 'AUTH_ERROR_USER_NOT_CONFIRMED'
+    case 'UsernameExistsException':
+      return 'AUTH_ERROR_USERNAME_EXISTS'
+    case 'InvalidPasswordException':
+      return 'PW_POLICY_TIP'
+    case 'CodeMismatchException':
+      return 'AUTH_ERROR_CODE_MISMATCH'
+    case 'ExpiredCodeException':
+      return 'AUTH_ERROR_CODE_EXPIRED'
+    case 'LimitExceededException':
+    case 'TooManyRequestsException':
+      return 'AUTH_ERROR_TOO_MANY_REQUESTS'
+    case 'TooManyFailedAttemptsException':
+      return 'AUTH_ERROR_TOO_MANY_ATTEMPTS'
+    case 'CodeDeliveryFailureException':
+      return 'AUTH_ERROR_CODE_DELIVERY_FAILED'
+    case 'UserAlreadyAuthenticatedException':
+      return 'AUTH_ERROR_ALREADY_AUTHENTICATED'
+    case 'InvalidParameterException':
+      return 'AUTH_ERROR_INVALID_PARAMETER'
+    default:
+      return 'AUTH_ERROR_GENERIC'
+  }
+}

+ 1207 - 20
packages/ui/src/amplify/lang.ts

@@ -1,5 +1,6 @@
 export default {
   'en': {
+    'login': 'Login',
     'signup': 'Sign Up',
     'reset-password': 'Reset Password',
     'confirm-code': 'Confirm Code',
@@ -8,42 +9,106 @@ export default {
       '2. must have lowercase characters.\n' +
       '3. must have uppercase characters.\n' +
       '4. must have symbol characters.',
+    'You are already logged in as': 'You are already logged in as',
+    'Sign out': 'Sign Out',
+    'Bad Response.': 'Bad Response.',
+    'Email': 'Email',
+    'Password': 'Password',
+    'Sign in': 'Sign In',
+    'Confirm': 'Confirm',
+    'Don\'t have an account?': 'Don\'t have an account?',
+    'Sign up': 'Sign Up',
+    'or': 'or',
+    'Forgot your password?': 'Forgot your password?',
+    'Create account': 'Create Account',
+    'Username': 'Username',
+    'Confirm Password': 'Confirm Password',
+    'New Password': 'New Password',
+    'By signing up, you agree to our': 'By signing up, you agree to our',
+    'Terms of Service': 'Terms of Service',
+    ' and ': ' and ',
+    'Privacy Policy': 'Privacy Policy',
+    'Already have an account?': 'Already have an account?',
+    'Reset password': 'Reset Password',
+    'Enter the code sent to your email': 'Enter the code sent to your email',
+    'Send code': 'Send Code',
+    'Resend code': 'Resend Code',
+    'Back to login': 'Back to Login',
+    'Enter your email': 'Enter your email',
+    'Invalid Password': 'Invalid Password',
+    'Passwords do not match.': 'Passwords do not match.',
+    'We have sent a numeric verification code to your email address at': 'We have sent a numeric verification code to your email address at',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Unsupported sign-in step:',
+    'AUTH_ERROR_GENERIC': 'Authentication failed. Please try again.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Incorrect email or password.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Your account is not confirmed yet. Please verify it with the code we sent.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'This username is already taken.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'We could not find an account for that email address.',
+    'AUTH_ERROR_CODE_MISMATCH': 'The verification code is incorrect.',
+    'AUTH_ERROR_CODE_EXPIRED': 'The verification code has expired. Please request a new one.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Too many requests. Please wait a moment and try again.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Too many failed attempts. Please wait a moment and try again.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'We could not send the verification code. Please try again later.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'You are already signed in.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Some information is invalid. Please check your input and try again.',
   },
-  'zh-cn': {
+  'zh-CN': {
     'login': '登录',
     'signup': '注册',
     'reset-password': '重置密码',
     'confirm-code': '确认验证码',
+    'CODE_ON_THE_WAY_TIP': '验证码已发送。请输入我们发送给您的验证码以登录。可能需要一分钟才能收到。',
     'PW_POLICY_TIP': '1. 密码长度至少8个字符\n' +
       '2. 密码必须包含小写字母\n' +
       '3. 密码必须包含大写字母\n' +
       '4. 密码必须包含特殊字符',
-    'CODE_ON_THE_WAY_TIP': '验证码已发送。请输入我们发送给您的验证码以登录。可能需要一分钟才能收到。',
     'Sign in to your account': '登录到您的账户',
-    'Email': '电子邮箱',
+    'You are already logged in as': '您当前已登录为',
+    'Sign out': '退出登录',
+    'Bad Response.': '请求失败。',
+    'Email': '邮箱',
     'Password': '密码',
     'Sign in': '登录',
     'Confirm': '确认',
     'Don\'t have an account?': '还没有账户?',
     'Sign up': '注册',
-    'or': '或 ',
+    'or': '或',
     'Forgot your password?': '忘记密码?',
-    'Create account': '创建您的账户',
+    'Create account': '创建账户',
     'Username': '用户名',
     'Confirm Password': '确认密码',
     'New Password': '新密码',
-    'By signing up, you agree to our': '注册即表示您同意我们的 ',
+    'By signing up, you agree to our': '注册即表示您同意我们的',
     'Terms of Service': '服务条款',
+    ' and ': '和',
     'Privacy Policy': '隐私政策',
     'Already have an account?': '已经有账户?',
-    'Reset password': '重置您的密码',
+    'Reset password': '重置密码',
     'Enter the code sent to your email': '输入发送到您邮箱的验证码',
     'Send code': '发送验证码',
     'Resend code': '重新发送验证码',
     'Back to login': '返回登录',
-    'Enter your email': '请输入您的电子邮箱'
+    'Enter your email': '请输入您的邮箱',
+    'Invalid Password': '密码无效',
+    'Passwords do not match.': '两次输入的密码不一致。',
+    'We have sent a numeric verification code to your email address at': '我们已向此邮箱发送数字验证码:',
+    'COUNTDOWN_SUFFIX': '秒',
+    'Unsupported sign-in step:': '不支持的登录步骤:',
+    'AUTH_ERROR_GENERIC': '认证失败,请重试。',
+    'AUTH_ERROR_INVALID_CREDENTIALS': '邮箱或密码错误。',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': '账户尚未完成验证,请使用我们发送的验证码完成验证。',
+    'AUTH_ERROR_USERNAME_EXISTS': '该用户名已被占用。',
+    'AUTH_ERROR_USER_NOT_FOUND': '未找到与该邮箱地址对应的账户。',
+    'AUTH_ERROR_CODE_MISMATCH': '验证码不正确。',
+    'AUTH_ERROR_CODE_EXPIRED': '验证码已过期,请重新获取。',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': '请求过于频繁,请稍后再试。',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': '失败次数过多,请稍后再试。',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': '验证码发送失败,请稍后再试。',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': '您已登录。',
+    'AUTH_ERROR_INVALID_PARAMETER': '输入信息无效,请检查后重试。'
   },
-  'zh-hant': {
+  'zh-Hant': {
     'login': '登入',
     'signup': '註冊',
     'reset-password': '重置密碼',
@@ -54,28 +119,49 @@ export default {
       '3. 密碼必須包含大寫字母\n' +
       '4. 密碼必須包含特殊字符',
     'Sign in to your account': '登入到您的帳戶',
-    'Email': '電子郵箱',
+    'You are already logged in as': '您目前已登入為',
+    'Sign out': '登出',
+    'Bad Response.': '請求失敗。',
+    'Email': '電子郵件',
     'Password': '密碼',
     'Sign in': '登入',
     'Confirm': '確認',
     'Don\'t have an account?': '還沒有帳戶?',
     'Sign up': '註冊',
-    'or': '或 ',
+    'or': '或',
     'Forgot your password?': '忘記密碼?',
-    'Create account': '創建您的帳戶',
+    'Create account': '建立帳戶',
     'Username': '用戶名',
     'Confirm Password': '確認密碼',
     'New Password': '新密碼',
-    'By signing up, you agree to our': '註冊即表示您同意我們的 ',
+    'By signing up, you agree to our': '註冊即表示您同意我們的',
     'Terms of Service': '服務條款',
+    ' and ': '和',
     'Privacy Policy': '隱私政策',
     'Already have an account?': '已經有帳戶?',
-    'Reset password': '重置您的密碼',
+    'Reset password': '重置密碼',
     'Enter the code sent to your email': '輸入發送到您郵箱的驗證碼',
     'Send code': '發送驗證碼',
     'Resend code': '重新發送驗證碼',
     'Back to login': '返回登入',
-    'Enter your email': '請輸入您的電子郵箱'
+    'Enter your email': '請輸入您的電子郵件',
+    'Invalid Password': '密碼無效',
+    'Passwords do not match.': '兩次輸入的密碼不一致。',
+    'We have sent a numeric verification code to your email address at': '我們已向此電子郵件地址發送數字驗證碼:',
+    'COUNTDOWN_SUFFIX': '秒',
+    'Unsupported sign-in step:': '不支援的登入步驟:',
+    'AUTH_ERROR_GENERIC': '驗證失敗,請再試一次。',
+    'AUTH_ERROR_INVALID_CREDENTIALS': '電子郵件或密碼錯誤。',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': '帳戶尚未完成驗證,請使用我們發送的驗證碼完成驗證。',
+    'AUTH_ERROR_USERNAME_EXISTS': '此用戶名已被使用。',
+    'AUTH_ERROR_USER_NOT_FOUND': '找不到與該電子郵件地址對應的帳戶。',
+    'AUTH_ERROR_CODE_MISMATCH': '驗證碼不正確。',
+    'AUTH_ERROR_CODE_EXPIRED': '驗證碼已過期,請重新取得。',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': '請求過於頻繁,請稍後再試。',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': '失敗次數過多,請稍後再試。',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': '無法發送驗證碼,請稍後再試。',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': '您已登入。',
+    'AUTH_ERROR_INVALID_PARAMETER': '輸入資訊無效,請檢查後再試。'
   },
   'ja': {
     'login': 'ログイン',
@@ -88,27 +174,1128 @@ export default {
       '3. パスワードには大文字を含める必要があります。\n' +
       '4. パスワードには記号を含める必要があります。',
     'Sign in to your account': 'アカウントにサインイン',
+    'You are already logged in as': '現在のログインユーザー',
+    'Sign out': 'ログアウト',
+    'Bad Response.': 'リクエストに失敗しました。',
     'Email': 'メール',
     'Password': 'パスワード',
     'Sign in': 'サインイン',
     'Confirm': '確認',
     'Don\'t have an account?': 'アカウントをお持ちでないですか?',
     'Sign up': 'サインアップ',
-    'or': 'または ',
+    'or': 'または',
     'Forgot your password?': 'パスワードをお忘れですか?',
-    'Create account': 'アカウントを作成する',
+    'Create account': 'アカウントを作成',
     'Username': 'ユーザー名',
     'New Password': '新しいパスワード',
     'Confirm Password': 'パスワードを確認する',
-    'By signing up, you agree to our': 'サインアップすることで、あなたは私たちの ',
+    'By signing up, you agree to our': 'サインアップすると、次の内容に同意したものとみなされます',
     'Terms of Service': '利用規約',
+    ' and ': 'および',
     'Privacy Policy': 'プライバシーポリシー',
-    'Already have an account? ': 'すでにアカウントをお持ちですか?',
+    'Already have an account?': 'すでにアカウントをお持ちですか?',
     'Reset password': 'パスワードをリセットする',
     'Enter the code sent to your email': 'メールに送信されたコードを入力してください',
     'Send code': 'コードを送信',
     'Resend code': 'コードを再送信',
     'Back to login': 'ログインに戻る',
-    'Enter your email': 'メールアドレスを入力してください'
+    'Enter your email': 'メールアドレスを入力してください',
+    'Invalid Password': '無効なパスワード',
+    'Passwords do not match.': 'パスワードが一致しません。',
+    'We have sent a numeric verification code to your email address at': '次のメールアドレスに数字の確認コードを送信しました:',
+    'COUNTDOWN_SUFFIX': '秒',
+    'Unsupported sign-in step:': '未対応のサインイン手順:',
+    'AUTH_ERROR_GENERIC': '認証に失敗しました。もう一度お試しください。',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'メールアドレスまたはパスワードが正しくありません。',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'アカウントの確認がまだ完了していません。送信されたコードで確認してください。',
+    'AUTH_ERROR_USERNAME_EXISTS': 'このユーザー名は既に使用されています。',
+    'AUTH_ERROR_USER_NOT_FOUND': 'そのメールアドレスに対応するアカウントが見つかりません。',
+    'AUTH_ERROR_CODE_MISMATCH': '確認コードが正しくありません。',
+    'AUTH_ERROR_CODE_EXPIRED': '確認コードの有効期限が切れています。新しいコードをリクエストしてください。',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'リクエストが多すぎます。しばらく待ってからやり直してください。',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': '失敗回数が多すぎます。しばらく待ってからやり直してください。',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': '確認コードを送信できませんでした。しばらくしてからもう一度お試しください。',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'すでにサインインしています。',
+    'AUTH_ERROR_INVALID_PARAMETER': '入力内容が正しくありません。確認してからやり直してください。'
+  },
+  'de': {
+    'login': 'Anmelden',
+    'signup': 'Registrieren',
+    'reset-password': 'Passwort zurücksetzen',
+    'confirm-code': 'Code bestätigen',
+    'CODE_ON_THE_WAY_TIP': 'Dein Code ist unterwegs. Um dich anzumelden, gib den Code ein, den wir dir gesendet haben. Es kann eine Minute dauern, bis er ankommt.',
+    'PW_POLICY_TIP': '1. Mindestens 8 Zeichen.\n' +
+      '2. Muss Kleinbuchstaben enthalten.\n' +
+      '3. Muss Großbuchstaben enthalten.\n' +
+      '4. Muss Sonderzeichen enthalten.',
+    'You are already logged in as': 'Du bist bereits angemeldet als',
+    'Sign out': 'Abmelden',
+    'Bad Response.': 'Fehlerhafte Antwort.',
+    'Email': 'E-Mail',
+    'Password': 'Passwort',
+    'Sign in': 'Anmelden',
+    'Confirm': 'Bestätigen',
+    'Don\'t have an account?': 'Noch kein Konto?',
+    'Sign up': 'Registrieren',
+    'or': 'oder',
+    'Forgot your password?': 'Passwort vergessen?',
+    'Create account': 'Konto erstellen',
+    'Username': 'Benutzername',
+    'Confirm Password': 'Passwort bestätigen',
+    'New Password': 'Neues Passwort',
+    'By signing up, you agree to our': 'Mit der Registrierung stimmst du unseren',
+    'Terms of Service': 'Nutzungsbedingungen',
+    ' and ': ' und ',
+    'Privacy Policy': 'Datenschutzrichtlinie',
+    'Already have an account?': 'Bereits ein Konto?',
+    'Reset password': 'Passwort zurücksetzen',
+    'Enter the code sent to your email': 'Gib den an deine E-Mail gesendeten Code ein',
+    'Send code': 'Code senden',
+    'Resend code': 'Code erneut senden',
+    'Back to login': 'Zurück zur Anmeldung',
+    'Enter your email': 'E-Mail-Adresse eingeben',
+    'Invalid Password': 'Ungültiges Passwort',
+    'Passwords do not match.': 'Passwörter stimmen nicht überein.',
+    'We have sent a numeric verification code to your email address at': 'Wir haben einen numerischen Bestätigungscode an deine E-Mail-Adresse gesendet:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Nicht unterstützter Anmeldeschritt:',
+    'AUTH_ERROR_GENERIC': 'Authentifizierung fehlgeschlagen. Bitte versuche es erneut.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Falsche E-Mail oder falsches Passwort.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Dein Konto ist noch nicht bestätigt. Bitte verifiziere es mit dem Code, den wir gesendet haben.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Dieser Benutzername ist bereits vergeben.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Wir konnten kein Konto für diese E-Mail-Adresse finden.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Der Verifizierungscode ist falsch.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Der Verifizierungscode ist abgelaufen. Bitte fordere einen neuen an.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Zu viele Anfragen. Bitte warte einen Moment und versuche es erneut.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Zu viele fehlgeschlagene Versuche. Bitte warte einen Moment und versuche es erneut.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Wir konnten den Verifizierungscode nicht senden. Bitte versuche es später erneut.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Du bist bereits angemeldet.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Einige Angaben sind ungültig. Bitte überprüfe deine Eingabe und versuche es erneut.'
+  },
+  'nl': {
+    'login': 'Inloggen',
+    'signup': 'Registreren',
+    'reset-password': 'Wachtwoord resetten',
+    'confirm-code': 'Code bevestigen',
+    'CODE_ON_THE_WAY_TIP': 'Je code is onderweg. Om in te loggen, voer de code in die we je hebben gestuurd. Het kan een minuutje duren voordat deze aankomt.',
+    'PW_POLICY_TIP': '1. Minimaal 8 tekens.\n' +
+      '2. Moet kleine letters bevatten.\n' +
+      '3. Moet hoofdletters bevatten.\n' +
+      '4. Moet speciale tekens bevatten.',
+    'You are already logged in as': 'Je bent al ingelogd als',
+    'Sign out': 'Uitloggen',
+    'Bad Response.': 'Slechte reactie.',
+    'Email': 'E-mail',
+    'Password': 'Wachtwoord',
+    'Sign in': 'Inloggen',
+    'Confirm': 'Bevestigen',
+    'Don\'t have an account?': 'Nog geen account?',
+    'Sign up': 'Registreren',
+    'or': 'of',
+    'Forgot your password?': 'Wachtwoord vergeten?',
+    'Create account': 'Account aanmaken',
+    'Username': 'Gebruikersnaam',
+    'Confirm Password': 'Wachtwoord bevestigen',
+    'New Password': 'Nieuw wachtwoord',
+    'By signing up, you agree to our': 'Door je te registreren, ga je akkoord met onze',
+    'Terms of Service': 'Servicevoorwaarden',
+    ' and ': ' en ',
+    'Privacy Policy': 'Privacybeleid',
+    'Already have an account?': 'Al een account?',
+    'Reset password': 'Wachtwoord resetten',
+    'Enter the code sent to your email': 'Voer de code in die naar je e-mail is gestuurd',
+    'Send code': 'Code versturen',
+    'Resend code': 'Code opnieuw versturen',
+    'Back to login': 'Terug naar inloggen',
+    'Enter your email': 'Voer je e-mailadres in',
+    'Invalid Password': 'Ongeldig wachtwoord',
+    'Passwords do not match.': 'Wachtwoorden komen niet overeen.',
+    'We have sent a numeric verification code to your email address at': 'We hebben een numerieke verificatiecode gestuurd naar je e-mailadres:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Niet-ondersteunde inlogstap:',
+    'AUTH_ERROR_GENERIC': 'Authenticatie mislukt. Probeer het opnieuw.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Onjuist e-mailadres of wachtwoord.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Je account is nog niet bevestigd. Verifieer het met de code die we hebben gestuurd.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Deze gebruikersnaam is al in gebruik.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'We konden geen account vinden voor dat e-mailadres.',
+    'AUTH_ERROR_CODE_MISMATCH': 'De verificatiecode is onjuist.',
+    'AUTH_ERROR_CODE_EXPIRED': 'De verificatiecode is verlopen. Vraag een nieuwe aan.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Te veel verzoeken. Wacht even en probeer het opnieuw.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Te veel mislukte pogingen. Wacht even en probeer het opnieuw.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'We konden de verificatiecode niet versturen. Probeer het later opnieuw.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Je bent al ingelogd.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Bepaalde informatie is ongeldig. Controleer je invoer en probeer het opnieuw.'
+  },
+  'fr': {
+    'login': 'Connexion',
+    'signup': 'Inscription',
+    'reset-password': 'Réinitialiser le mot de passe',
+    'confirm-code': 'Confirmer le code',
+    'CODE_ON_THE_WAY_TIP': 'Votre code est en route. Pour vous connecter, saisissez le code que nous vous avons envoyé. Il peut mettre une minute à arriver.',
+    'PW_POLICY_TIP': '1. Au moins 8 caractères.\n' +
+      '2. Doit contenir des lettres minuscules.\n' +
+      '3. Doit contenir des lettres majuscules.\n' +
+      '4. Doit contenir des caractères spéciaux.',
+    'You are already logged in as': 'Vous êtes déjà connecté en tant que',
+    'Sign out': 'Se déconnecter',
+    'Bad Response.': 'Mauvaise réponse.',
+    'Email': 'E-mail',
+    'Password': 'Mot de passe',
+    'Sign in': 'Se connecter',
+    'Confirm': 'Confirmer',
+    'Don\'t have an account?': 'Pas encore de compte ?',
+    'Sign up': 'S\'inscrire',
+    'or': 'ou',
+    'Forgot your password?': 'Mot de passe oublié ?',
+    'Create account': 'Créer un compte',
+    'Username': 'Nom d\'utilisateur',
+    'Confirm Password': 'Confirmer le mot de passe',
+    'New Password': 'Nouveau mot de passe',
+    'By signing up, you agree to our': 'En vous inscrivant, vous acceptez nos',
+    'Terms of Service': 'Conditions d\'utilisation',
+    ' and ': ' et ',
+    'Privacy Policy': 'Politique de confidentialité',
+    'Already have an account?': 'Vous avez déjà un compte ?',
+    'Reset password': 'Réinitialiser le mot de passe',
+    'Enter the code sent to your email': 'Entrez le code envoyé à votre e-mail',
+    'Send code': 'Envoyer le code',
+    'Resend code': 'Renvoyer le code',
+    'Back to login': 'Retour à la connexion',
+    'Enter your email': 'Entrez votre e-mail',
+    'Invalid Password': 'Mot de passe invalide',
+    'Passwords do not match.': 'Les mots de passe ne correspondent pas.',
+    'We have sent a numeric verification code to your email address at': 'Nous avons envoyé un code de vérification numérique à votre adresse e-mail :',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Étape de connexion non prise en charge :',
+    'AUTH_ERROR_GENERIC': 'Échec de l\'authentification. Veuillez réessayer.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'E-mail ou mot de passe incorrect.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Votre compte n\'est pas encore confirmé. Veuillez le vérifier avec le code que nous avons envoyé.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Ce nom d\'utilisateur est déjà pris.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Nous n\'avons pas pu trouver de compte pour cette adresse e-mail.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Le code de vérification est incorrect.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Le code de vérification a expiré. Veuillez en demander un nouveau.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Trop de requêtes. Veuillez patienter un moment et réessayer.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Trop de tentatives échouées. Veuillez patienter un moment et réessayer.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Nous n\'avons pas pu envoyer le code de vérification. Veuillez réessayer plus tard.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Vous êtes déjà connecté.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Certaines informations sont invalides. Veuillez vérifier votre saisie et réessayer.'
+  },
+  'af': {
+    'login': 'Aanmeld',
+    'signup': 'Registreer',
+    'reset-password': 'Stel wagwoord terug',
+    'confirm-code': 'Bevestig kode',
+    'CODE_ON_THE_WAY_TIP': 'Jou kode is op pad. Om aan te meld, voer die kode in wat ons vir jou gestuur het. Dit kan \'n minuut neem om te arriveer.',
+    'PW_POLICY_TIP': '1. Ten minste 8 karakters.\n' +
+      '2. Moet kleinletters bevat.\n' +
+      '3. Moet hoofletter bevat.\n' +
+      '4. Moet simboolkarakters bevat.',
+    'You are already logged in as': 'Jy is reeds aangemeld as',
+    'Sign out': 'Teken uit',
+    'Bad Response.': 'Slegte respons.',
+    'Email': 'E-pos',
+    'Password': 'Wagwoord',
+    'Sign in': 'Teken in',
+    'Confirm': 'Bevestig',
+    'Don\'t have an account?': 'Het jy nie \'n rekening nie?',
+    'Sign up': 'Registreer',
+    'or': 'of',
+    'Forgot your password?': 'Wagwoord vergeet?',
+    'Create account': 'Skep rekening',
+    'Username': 'Gebruikersnaam',
+    'Confirm Password': 'Bevestig wagwoord',
+    'New Password': 'Nuwe wagwoord',
+    'By signing up, you agree to our': 'Deur te registreer, stem jy in tot ons',
+    'Terms of Service': 'Diensvoorwaardes',
+    ' and ': ' en ',
+    'Privacy Policy': 'Privaatheidsbeleid',
+    'Already have an account?': 'Het jy reeds \'n rekening?',
+    'Reset password': 'Stel wagwoord terug',
+    'Enter the code sent to your email': 'Voer die kode in wat na jou e-pos gestuur is',
+    'Send code': 'Stuur kode',
+    'Resend code': 'Stuur kode weer',
+    'Back to login': 'Terug na aanmelding',
+    'Enter your email': 'Voer jou e-posadres in',
+    'Invalid Password': 'Ongeldige wagwoord',
+    'Passwords do not match.': 'Wagwoorde stem nie ooreen nie.',
+    'We have sent a numeric verification code to your email address at': 'Ons het \'n numeriese verifikasiekode na jou e-posadres gestuur:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Nie-ondersteunde aanmeldstap:',
+    'AUTH_ERROR_GENERIC': 'Verifikasie het misluk. Probeer asseblief weer.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Verkeerde e-pos of wagwoord.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Jou rekening is nog nie bevestig nie. Verifieer dit met die kode wat ons gestuur het.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Hierdie gebruikersnaam is reeds geneem.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Ons kon nie \'n rekening vind vir daardie e-posadres nie.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Die verifikasiekode is verkeerd.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Die verifikasiekode het verstryk. Versoek asseblief \'n nuwe een.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Te veel versoeke. Wag asseblief \'n oomblik en probeer weer.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Te veel mislukte pogings. Wag asseblief \'n oomblik en probeer weer.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Ons kon nie die verifikasiekode stuur nie. Probeer asseblief later weer.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Jy is reeds aangemeld.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Sommige inligting is ongeldig. Kontroleer jou invoer en probeer weer.'
+  },
+  'ca': {
+    'login': 'Inicia sessió',
+    'signup': 'Registra\'t',
+    'reset-password': 'Restableix la contrasenya',
+    'confirm-code': 'Confirma el codi',
+    'CODE_ON_THE_WAY_TIP': 'El teu codi està en camí. Per iniciar sessió, introdueix el codi que t\'hem enviat. Pot trigar un minut a arribar.',
+    'PW_POLICY_TIP': '1. Almenys 8 caràcters.\n' +
+      '2. Ha de contenir lletres minúscules.\n' +
+      '3. Ha de contenir lletres majúscules.\n' +
+      '4. Ha de contenir caràcters especials.',
+    'You are already logged in as': 'Ja has iniciat sessió com a',
+    'Sign out': 'Tanca la sessió',
+    'Bad Response.': 'Resposta incorrecta.',
+    'Email': 'Correu electrònic',
+    'Password': 'Contrasenya',
+    'Sign in': 'Inicia sessió',
+    'Confirm': 'Confirma',
+    'Don\'t have an account?': 'No tens compte?',
+    'Sign up': 'Registra\'t',
+    'or': 'o',
+    'Forgot your password?': 'Has oblidat la contrasenya?',
+    'Create account': 'Crea un compte',
+    'Username': 'Nom d\'usuari',
+    'Confirm Password': 'Confirma la contrasenya',
+    'New Password': 'Nova contrasenya',
+    'By signing up, you agree to our': 'En registrar-te, acceptes els nostres',
+    'Terms of Service': 'Termes de servei',
+    ' and ': ' i ',
+    'Privacy Policy': 'Política de privadesa',
+    'Already have an account?': 'Ja tens compte?',
+    'Reset password': 'Restableix la contrasenya',
+    'Enter the code sent to your email': 'Introdueix el codi enviat al teu correu electrònic',
+    'Send code': 'Envia el codi',
+    'Resend code': 'Torna a enviar el codi',
+    'Back to login': 'Torna a l\'inici de sessió',
+    'Enter your email': 'Introdueix el teu correu electrònic',
+    'Invalid Password': 'Contrasenya no vàlida',
+    'Passwords do not match.': 'Les contrasenyes no coincideixen.',
+    'We have sent a numeric verification code to your email address at': 'Hem enviat un codi de verificació numèric a la teva adreça de correu electrònic:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Pas d\'inici de sessió no admès:',
+    'AUTH_ERROR_GENERIC': 'L\'autenticació ha fallat. Torna-ho a intentar.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Correu electrònic o contrasenya incorrectes.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'El teu compte encara no s\'ha confirmat. Verifica\'l amb el codi que t\'hem enviat.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Aquest nom d\'usuari ja està en ús.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'No hem pogut trobar un compte per a aquesta adreça de correu electrònic.',
+    'AUTH_ERROR_CODE_MISMATCH': 'El codi de verificació és incorrecte.',
+    'AUTH_ERROR_CODE_EXPIRED': 'El codi de verificació ha caducat. Sol·licita\'n un de nou.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Massa sol·licituds. Espera un moment i torna-ho a intentar.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Massa intents fallits. Espera un moment i torna-ho a intentar.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'No hem pogut enviar el codi de verificació. Torna-ho a intentar més tard.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Ja has iniciat sessió.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Alguna informació no és vàlida. Comprova la teva entrada i torna-ho a intentar.'
+  },
+  'es': {
+    'login': 'Iniciar sesión',
+    'signup': 'Registrarse',
+    'reset-password': 'Restablecer contraseña',
+    'confirm-code': 'Confirmar código',
+    'CODE_ON_THE_WAY_TIP': 'Tu código está en camino. Para iniciar sesión, ingresa el código que te enviamos. Puede tardar un minuto en llegar.',
+    'PW_POLICY_TIP': '1. Al menos 8 caracteres.\n' +
+      '2. Debe contener letras minúsculas.\n' +
+      '3. Debe contener letras mayúsculas.\n' +
+      '4. Debe contener caracteres especiales.',
+    'You are already logged in as': 'Ya has iniciado sesión como',
+    'Sign out': 'Cerrar sesión',
+    'Bad Response.': 'Respuesta incorrecta.',
+    'Email': 'Correo electrónico',
+    'Password': 'Contraseña',
+    'Sign in': 'Iniciar sesión',
+    'Confirm': 'Confirmar',
+    'Don\'t have an account?': '¿No tienes cuenta?',
+    'Sign up': 'Registrarse',
+    'or': 'o',
+    'Forgot your password?': '¿Olvidaste tu contraseña?',
+    'Create account': 'Crear cuenta',
+    'Username': 'Nombre de usuario',
+    'Confirm Password': 'Confirmar contraseña',
+    'New Password': 'Nueva contraseña',
+    'By signing up, you agree to our': 'Al registrarte, aceptas nuestros',
+    'Terms of Service': 'Términos de servicio',
+    ' and ': ' y ',
+    'Privacy Policy': 'Política de privacidad',
+    'Already have an account?': '¿Ya tienes cuenta?',
+    'Reset password': 'Restablecer contraseña',
+    'Enter the code sent to your email': 'Ingresa el código enviado a tu correo electrónico',
+    'Send code': 'Enviar código',
+    'Resend code': 'Reenviar código',
+    'Back to login': 'Volver al inicio de sesión',
+    'Enter your email': 'Ingresa tu correo electrónico',
+    'Invalid Password': 'Contraseña no válida',
+    'Passwords do not match.': 'Las contraseñas no coinciden.',
+    'We have sent a numeric verification code to your email address at': 'Hemos enviado un código de verificación numérico a tu dirección de correo electrónico:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Paso de inicio de sesión no compatible:',
+    'AUTH_ERROR_GENERIC': 'La autenticación falló. Por favor, inténtalo de nuevo.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Correo electrónico o contraseña incorrectos.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Tu cuenta no está confirmada aún. Verifícala con el código que te enviamos.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Este nombre de usuario ya está en uso.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'No pudimos encontrar una cuenta para esa dirección de correo electrónico.',
+    'AUTH_ERROR_CODE_MISMATCH': 'El código de verificación es incorrecto.',
+    'AUTH_ERROR_CODE_EXPIRED': 'El código de verificación ha expirado. Por favor, solicita uno nuevo.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Demasiadas solicitudes. Por favor, espera un momento e inténtalo de nuevo.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Demasiados intentos fallidos. Por favor, espera un momento e inténtalo de nuevo.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'No pudimos enviar el código de verificación. Por favor, inténtalo más tarde.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Ya has iniciado sesión.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Alguna información no es válida. Por favor, revisa tu entrada e inténtalo de nuevo.'
+  },
+  'nb-NO': {
+    'login': 'Logg inn',
+    'signup': 'Registrer deg',
+    'reset-password': 'Tilbakestill passord',
+    'confirm-code': 'Bekreft kode',
+    'CODE_ON_THE_WAY_TIP': 'Koden din er på vei. For å logge inn, skriv inn koden vi sendte deg. Det kan ta ett minutt å komme frem.',
+    'PW_POLICY_TIP': '1. Minst 8 tegn.\n' +
+      '2. Må inneholde små bokstaver.\n' +
+      '3. Må inneholde store bokstaver.\n' +
+      '4. Må inneholde symboler.',
+    'You are already logged in as': 'Du er allerede logget inn som',
+    'Sign out': 'Logg ut',
+    'Bad Response.': 'Dårlig svar.',
+    'Email': 'E-post',
+    'Password': 'Passord',
+    'Sign in': 'Logg inn',
+    'Confirm': 'Bekreft',
+    'Don\'t have an account?': 'Har du ikke en konto?',
+    'Sign up': 'Registrer deg',
+    'or': 'eller',
+    'Forgot your password?': 'Glemt passordet?',
+    'Create account': 'Opprett konto',
+    'Username': 'Brukernavn',
+    'Confirm Password': 'Bekreft passord',
+    'New Password': 'Nytt passord',
+    'By signing up, you agree to our': 'Ved å registrere deg godtar du våre',
+    'Terms of Service': 'Vilkår for bruk',
+    ' and ': ' og ',
+    'Privacy Policy': 'Personvernregler',
+    'Already have an account?': 'Har du allerede en konto?',
+    'Reset password': 'Tilbakestill passord',
+    'Enter the code sent to your email': 'Skriv inn koden som ble sendt til e-posten din',
+    'Send code': 'Send kode',
+    'Resend code': 'Send kode på nytt',
+    'Back to login': 'Tilbake til innlogging',
+    'Enter your email': 'Skriv inn e-postadressen din',
+    'Invalid Password': 'Ugyldig passord',
+    'Passwords do not match.': 'Passordene stemmer ikke overens.',
+    'We have sent a numeric verification code to your email address at': 'Vi har sendt en numerisk bekreftelseskode til e-postadressen din:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Ikke-støttet innloggingstrinn:',
+    'AUTH_ERROR_GENERIC': 'Autentisering mislyktes. Prøv igjen.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Feil e-post eller passord.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Kontoen din er ikke bekreftet ennå. Bekreft den med koden vi sendte.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Dette brukernavnet er allerede tatt.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Vi fant ingen konto for den e-postadressen.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Bekreftelseskoden er feil.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Bekreftelseskoden er utløpt. Be om en ny.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'For mange forespørsler. Vent litt og prøv igjen.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'For mange mislykkede forsøk. Vent litt og prøv igjen.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Vi kunne ikke sende bekreftelseskoden. Prøv igjen senere.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Du er allerede logget inn.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Noe informasjon er ugyldig. Sjekk inndataene dine og prøv igjen.'
+  },
+  'pt-BR': {
+    'login': 'Entrar',
+    'signup': 'Cadastrar',
+    'reset-password': 'Redefinir senha',
+    'confirm-code': 'Confirmar código',
+    'CODE_ON_THE_WAY_TIP': 'Seu código está a caminho. Para fazer login, insira o código que enviamos para você. Pode levar um minuto para chegar.',
+    'PW_POLICY_TIP': '1. Pelo menos 8 caracteres.\n' +
+      '2. Deve conter letras minúsculas.\n' +
+      '3. Deve conter letras maiúsculas.\n' +
+      '4. Deve conter caracteres especiais.',
+    'You are already logged in as': 'Você já está conectado como',
+    'Sign out': 'Sair',
+    'Bad Response.': 'Resposta inválida.',
+    'Email': 'E-mail',
+    'Password': 'Senha',
+    'Sign in': 'Entrar',
+    'Confirm': 'Confirmar',
+    'Don\'t have an account?': 'Não tem uma conta?',
+    'Sign up': 'Cadastrar',
+    'or': 'ou',
+    'Forgot your password?': 'Esqueceu sua senha?',
+    'Create account': 'Criar conta',
+    'Username': 'Nome de usuário',
+    'Confirm Password': 'Confirmar senha',
+    'New Password': 'Nova senha',
+    'By signing up, you agree to our': 'Ao se cadastrar, você concorda com nossos',
+    'Terms of Service': 'Termos de serviço',
+    ' and ': ' e ',
+    'Privacy Policy': 'Política de privacidade',
+    'Already have an account?': 'Já tem uma conta?',
+    'Reset password': 'Redefinir senha',
+    'Enter the code sent to your email': 'Insira o código enviado para seu e-mail',
+    'Send code': 'Enviar código',
+    'Resend code': 'Reenviar código',
+    'Back to login': 'Voltar ao login',
+    'Enter your email': 'Insira seu e-mail',
+    'Invalid Password': 'Senha inválida',
+    'Passwords do not match.': 'As senhas não correspondem.',
+    'We have sent a numeric verification code to your email address at': 'Enviamos um código de verificação numérico para seu endereço de e-mail:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Etapa de login não suportada:',
+    'AUTH_ERROR_GENERIC': 'Falha na autenticação. Por favor, tente novamente.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'E-mail ou senha incorretos.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Sua conta ainda não foi confirmada. Por favor, verifique-a com o código que enviamos.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Este nome de usuário já está em uso.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Não encontramos uma conta para esse endereço de e-mail.',
+    'AUTH_ERROR_CODE_MISMATCH': 'O código de verificação está incorreto.',
+    'AUTH_ERROR_CODE_EXPIRED': 'O código de verificação expirou. Por favor, solicite um novo.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Muitas solicitações. Por favor, aguarde um momento e tente novamente.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Muitas tentativas malsucedidas. Por favor, aguarde um momento e tente novamente.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Não conseguimos enviar o código de verificação. Por favor, tente novamente mais tarde.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Você já está conectado.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Algumas informações são inválidas. Por favor, verifique sua entrada e tente novamente.'
+  },
+  'pt-PT': {
+    'login': 'Iniciar sessão',
+    'signup': 'Registar',
+    'reset-password': 'Repor palavra-passe',
+    'confirm-code': 'Confirmar código',
+    'CODE_ON_THE_WAY_TIP': 'O seu código está a caminho. Para iniciar sessão, introduza o código que lhe enviámos. Pode demorar um minuto a chegar.',
+    'PW_POLICY_TIP': '1. Pelo menos 8 caracteres.\n' +
+      '2. Deve conter letras minúsculas.\n' +
+      '3. Deve conter letras maiúsculas.\n' +
+      '4. Deve conter caracteres especiais.',
+    'You are already logged in as': 'Já iniciou sessão como',
+    'Sign out': 'Terminar sessão',
+    'Bad Response.': 'Resposta inválida.',
+    'Email': 'E-mail',
+    'Password': 'Palavra-passe',
+    'Sign in': 'Iniciar sessão',
+    'Confirm': 'Confirmar',
+    'Don\'t have an account?': 'Não tem conta?',
+    'Sign up': 'Registar',
+    'or': 'ou',
+    'Forgot your password?': 'Esqueceu a palavra-passe?',
+    'Create account': 'Criar conta',
+    'Username': 'Nome de utilizador',
+    'Confirm Password': 'Confirmar palavra-passe',
+    'New Password': 'Nova palavra-passe',
+    'By signing up, you agree to our': 'Ao registar-se, concorda com os nossos',
+    'Terms of Service': 'Termos de serviço',
+    ' and ': ' e ',
+    'Privacy Policy': 'Política de privacidade',
+    'Already have an account?': 'Já tem conta?',
+    'Reset password': 'Repor palavra-passe',
+    'Enter the code sent to your email': 'Introduza o código enviado para o seu e-mail',
+    'Send code': 'Enviar código',
+    'Resend code': 'Reenviar código',
+    'Back to login': 'Voltar ao início de sessão',
+    'Enter your email': 'Introduza o seu e-mail',
+    'Invalid Password': 'Palavra-passe inválida',
+    'Passwords do not match.': 'As palavras-passe não coincidem.',
+    'We have sent a numeric verification code to your email address at': 'Enviámos um código de verificação numérico para o seu endereço de e-mail:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Passo de início de sessão não suportado:',
+    'AUTH_ERROR_GENERIC': 'Falha na autenticação. Por favor, tente novamente.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'E-mail ou palavra-passe incorretos.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'A sua conta ainda não foi confirmada. Por favor, verifique-a com o código que enviámos.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Este nome de utilizador já está em uso.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Não encontrámos nenhuma conta para esse endereço de e-mail.',
+    'AUTH_ERROR_CODE_MISMATCH': 'O código de verificação está incorreto.',
+    'AUTH_ERROR_CODE_EXPIRED': 'O código de verificação expirou. Por favor, solicite um novo.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Demasiadas solicitações. Por favor, aguarde um momento e tente novamente.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Demasiadas tentativas falhadas. Por favor, aguarde um momento e tente novamente.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Não conseguimos enviar o código de verificação. Por favor, tente novamente mais tarde.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Já tem sessão iniciada.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Algumas informações são inválidas. Por favor, verifique a sua entrada e tente novamente.'
+  },
+  'ru': {
+    'login': 'Войти',
+    'signup': 'Зарегистрироваться',
+    'reset-password': 'Сбросить пароль',
+    'confirm-code': 'Подтвердить код',
+    'CODE_ON_THE_WAY_TIP': 'Ваш код в пути. Чтобы войти, введите код, который мы вам отправили. Это может занять минуту.',
+    'PW_POLICY_TIP': '1. Не менее 8 символов.\n' +
+      '2. Должен содержать строчные буквы.\n' +
+      '3. Должен содержать заглавные буквы.\n' +
+      '4. Должен содержать специальные символы.',
+    'You are already logged in as': 'Вы уже вошли как',
+    'Sign out': 'Выйти',
+    'Bad Response.': 'Некорректный ответ.',
+    'Email': 'Эл. почта',
+    'Password': 'Пароль',
+    'Sign in': 'Войти',
+    'Confirm': 'Подтвердить',
+    'Don\'t have an account?': 'Нет аккаунта?',
+    'Sign up': 'Зарегистрироваться',
+    'or': 'или',
+    'Forgot your password?': 'Забыли пароль?',
+    'Create account': 'Создать аккаунт',
+    'Username': 'Имя пользователя',
+    'Confirm Password': 'Подтвердить пароль',
+    'New Password': 'Новый пароль',
+    'By signing up, you agree to our': 'Регистрируясь, вы соглашаетесь с нашими',
+    'Terms of Service': 'Условиями обслуживания',
+    ' and ': ' и ',
+    'Privacy Policy': 'Политикой конфиденциальности',
+    'Already have an account?': 'Уже есть аккаунт?',
+    'Reset password': 'Сбросить пароль',
+    'Enter the code sent to your email': 'Введите код, отправленный на вашу почту',
+    'Send code': 'Отправить код',
+    'Resend code': 'Отправить код повторно',
+    'Back to login': 'Вернуться к входу',
+    'Enter your email': 'Введите адрес эл. почты',
+    'Invalid Password': 'Неверный пароль',
+    'Passwords do not match.': 'Пароли не совпадают.',
+    'We have sent a numeric verification code to your email address at': 'Мы отправили числовой код подтверждения на ваш адрес эл. почты:',
+    'COUNTDOWN_SUFFIX': 'с',
+    'Unsupported sign-in step:': 'Неподдерживаемый шаг входа:',
+    'AUTH_ERROR_GENERIC': 'Ошибка аутентификации. Пожалуйста, попробуйте снова.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Неверный адрес эл. почты или пароль.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Ваш аккаунт ещё не подтверждён. Пожалуйста, подтвердите его с помощью кода, который мы отправили.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Это имя пользователя уже занято.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Нам не удалось найти аккаунт с таким адресом эл. почты.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Код подтверждения неверен.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Срок действия кода подтверждения истёк. Пожалуйста, запросите новый.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Слишком много запросов. Пожалуйста, подождите немного и попробуйте снова.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Слишком много неудачных попыток. Пожалуйста, подождите немного и попробуйте снова.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Нам не удалось отправить код подтверждения. Пожалуйста, попробуйте позже.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Вы уже вошли в систему.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Некоторые данные неверны. Пожалуйста, проверьте введённые данные и попробуйте снова.'
+  },
+  'it': {
+    'login': 'Accedi',
+    'signup': 'Registrati',
+    'reset-password': 'Reimposta la password',
+    'confirm-code': 'Conferma il codice',
+    'CODE_ON_THE_WAY_TIP': 'Il tuo codice è in arrivo. Per accedere, inserisci il codice che ti abbiamo inviato. Potrebbe impiegare un minuto ad arrivare.',
+    'PW_POLICY_TIP': '1. Almeno 8 caratteri.\n' +
+      '2. Deve contenere lettere minuscole.\n' +
+      '3. Deve contenere lettere maiuscole.\n' +
+      '4. Deve contenere caratteri speciali.',
+    'You are already logged in as': 'Sei già connesso come',
+    'Sign out': 'Disconnetti',
+    'Bad Response.': 'Risposta errata.',
+    'Email': 'E-mail',
+    'Password': 'Password',
+    'Sign in': 'Accedi',
+    'Confirm': 'Conferma',
+    'Don\'t have an account?': 'Non hai un account?',
+    'Sign up': 'Registrati',
+    'or': 'o',
+    'Forgot your password?': 'Hai dimenticato la password?',
+    'Create account': 'Crea account',
+    'Username': 'Nome utente',
+    'Confirm Password': 'Conferma password',
+    'New Password': 'Nuova password',
+    'By signing up, you agree to our': 'Registrandoti, accetti i nostri',
+    'Terms of Service': 'Termini di servizio',
+    ' and ': ' e ',
+    'Privacy Policy': 'Informativa sulla privacy',
+    'Already have an account?': 'Hai già un account?',
+    'Reset password': 'Reimposta la password',
+    'Enter the code sent to your email': 'Inserisci il codice inviato alla tua e-mail',
+    'Send code': 'Invia codice',
+    'Resend code': 'Invia di nuovo il codice',
+    'Back to login': 'Torna al login',
+    'Enter your email': 'Inserisci la tua e-mail',
+    'Invalid Password': 'Password non valida',
+    'Passwords do not match.': 'Le password non corrispondono.',
+    'We have sent a numeric verification code to your email address at': 'Abbiamo inviato un codice di verifica numerico al tuo indirizzo e-mail:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Fase di accesso non supportata:',
+    'AUTH_ERROR_GENERIC': 'Autenticazione non riuscita. Riprova.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'E-mail o password errati.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Il tuo account non è ancora confermato. Verificalo con il codice che abbiamo inviato.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Questo nome utente è già in uso.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Non abbiamo trovato un account per quell\'indirizzo e-mail.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Il codice di verifica non è corretto.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Il codice di verifica è scaduto. Richiedine uno nuovo.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Troppe richieste. Attendi un momento e riprova.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Troppi tentativi falliti. Attendi un momento e riprova.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Non siamo riusciti a inviare il codice di verifica. Riprova più tardi.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Hai già effettuato l\'accesso.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Alcune informazioni non sono valide. Controlla l\'inserimento e riprova.'
+  },
+  'tr': {
+    'login': 'Giriş yap',
+    'signup': 'Kayıt ol',
+    'reset-password': 'Şifreyi sıfırla',
+    'confirm-code': 'Kodu onayla',
+    'CODE_ON_THE_WAY_TIP': 'Kodunuz yolda. Giriş yapmak için size gönderdiğimiz kodu girin. Gelmesi bir dakika sürebilir.',
+    'PW_POLICY_TIP': '1. En az 8 karakter.\n' +
+      '2. Küçük harf içermelidir.\n' +
+      '3. Büyük harf içermelidir.\n' +
+      '4. Sembol karakterleri içermelidir.',
+    'You are already logged in as': 'Zaten şu kullanıcı olarak giriş yapıldı:',
+    'Sign out': 'Çıkış yap',
+    'Bad Response.': 'Hatalı yanıt.',
+    'Email': 'E-posta',
+    'Password': 'Şifre',
+    'Sign in': 'Giriş yap',
+    'Confirm': 'Onayla',
+    'Don\'t have an account?': 'Hesabınız yok mu?',
+    'Sign up': 'Kayıt ol',
+    'or': 'veya',
+    'Forgot your password?': 'Şifrenizi mi unuttunuz?',
+    'Create account': 'Hesap oluştur',
+    'Username': 'Kullanıcı adı',
+    'Confirm Password': 'Şifreyi onayla',
+    'New Password': 'Yeni şifre',
+    'By signing up, you agree to our': 'Kayıt olarak',
+    'Terms of Service': 'Kullanım Koşullarımızı',
+    ' and ': ' ve ',
+    'Privacy Policy': 'Gizlilik Politikamızı',
+    'Already have an account?': 'Zaten bir hesabınız var mı?',
+    'Reset password': 'Şifreyi sıfırla',
+    'Enter the code sent to your email': 'E-postanıza gönderilen kodu girin',
+    'Send code': 'Kodu gönder',
+    'Resend code': 'Kodu yeniden gönder',
+    'Back to login': 'Girişe geri dön',
+    'Enter your email': 'E-posta adresinizi girin',
+    'Invalid Password': 'Geçersiz şifre',
+    'Passwords do not match.': 'Şifreler eşleşmiyor.',
+    'We have sent a numeric verification code to your email address at': 'E-posta adresinize sayısal bir doğrulama kodu gönderdik:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Desteklenmeyen giriş adımı:',
+    'AUTH_ERROR_GENERIC': 'Kimlik doğrulama başarısız oldu. Lütfen tekrar deneyin.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Yanlış e-posta veya şifre.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Hesabınız henüz onaylanmadı. Lütfen gönderdiğimiz kodla doğrulayın.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Bu kullanıcı adı zaten alınmış.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Bu e-posta adresi için bir hesap bulamadık.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Doğrulama kodu yanlış.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Doğrulama kodunun süresi doldu. Lütfen yeni bir tane isteyin.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Çok fazla istek. Lütfen bir süre bekleyin ve tekrar deneyin.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Çok fazla başarısız deneme. Lütfen bir süre bekleyin ve tekrar deneyin.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Doğrulama kodunu gönderemedik. Lütfen daha sonra tekrar deneyin.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Zaten giriş yapmış durumdasınız.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Bazı bilgiler geçersiz. Lütfen girdinizi kontrol edin ve tekrar deneyin.'
+  },
+  'ko': {
+    'login': '로그인',
+    'signup': '회원가입',
+    'reset-password': '비밀번호 재설정',
+    'confirm-code': '코드 확인',
+    'CODE_ON_THE_WAY_TIP': '코드가 전송 중입니다. 로그인하려면 보내드린 코드를 입력하세요. 도착하는 데 약 1분이 걸릴 수 있습니다.',
+    'PW_POLICY_TIP': '1. 최소 8자 이상.\n' +
+      '2. 소문자를 포함해야 합니다.\n' +
+      '3. 대문자를 포함해야 합니다.\n' +
+      '4. 특수 문자를 포함해야 합니다.',
+    'You are already logged in as': '현재 로그인된 계정:',
+    'Sign out': '로그아웃',
+    'Bad Response.': '잘못된 응답입니다.',
+    'Email': '이메일',
+    'Password': '비밀번호',
+    'Sign in': '로그인',
+    'Confirm': '확인',
+    'Don\'t have an account?': '계정이 없으신가요?',
+    'Sign up': '회원가입',
+    'or': '또는',
+    'Forgot your password?': '비밀번호를 잊으셨나요?',
+    'Create account': '계정 만들기',
+    'Username': '사용자 이름',
+    'Confirm Password': '비밀번호 확인',
+    'New Password': '새 비밀번호',
+    'By signing up, you agree to our': '회원가입 시',
+    'Terms of Service': '이용약관',
+    ' and ': ' 및 ',
+    'Privacy Policy': '개인정보 처리방침',
+    'Already have an account?': '이미 계정이 있으신가요?',
+    'Reset password': '비밀번호 재설정',
+    'Enter the code sent to your email': '이메일로 전송된 코드를 입력하세요',
+    'Send code': '코드 전송',
+    'Resend code': '코드 재전송',
+    'Back to login': '로그인으로 돌아가기',
+    'Enter your email': '이메일 주소를 입력하세요',
+    'Invalid Password': '유효하지 않은 비밀번호',
+    'Passwords do not match.': '비밀번호가 일치하지 않습니다.',
+    'We have sent a numeric verification code to your email address at': '다음 이메일 주소로 숫자 인증 코드를 전송했습니다:',
+    'COUNTDOWN_SUFFIX': '초',
+    'Unsupported sign-in step:': '지원되지 않는 로그인 단계:',
+    'AUTH_ERROR_GENERIC': '인증에 실패했습니다. 다시 시도해 주세요.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': '이메일 또는 비밀번호가 올바르지 않습니다.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': '계정이 아직 확인되지 않았습니다. 전송된 코드로 확인해 주세요.',
+    'AUTH_ERROR_USERNAME_EXISTS': '이미 사용 중인 사용자 이름입니다.',
+    'AUTH_ERROR_USER_NOT_FOUND': '해당 이메일 주소에 대한 계정을 찾을 수 없습니다.',
+    'AUTH_ERROR_CODE_MISMATCH': '인증 코드가 올바르지 않습니다.',
+    'AUTH_ERROR_CODE_EXPIRED': '인증 코드가 만료되었습니다. 새로운 코드를 요청해 주세요.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': '실패한 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': '인증 코드를 전송하지 못했습니다. 나중에 다시 시도해 주세요.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': '이미 로그인되어 있습니다.',
+    'AUTH_ERROR_INVALID_PARAMETER': '일부 정보가 올바르지 않습니다. 입력 내용을 확인하고 다시 시도해 주세요.'
+  },
+  'pl': {
+    'login': 'Zaloguj się',
+    'signup': 'Zarejestruj się',
+    'reset-password': 'Zresetuj hasło',
+    'confirm-code': 'Potwierdź kod',
+    'CODE_ON_THE_WAY_TIP': 'Twój kod jest w drodze. Aby się zalogować, wpisz kod, który Ci wysłaliśmy. Może to zająć minutę.',
+    'PW_POLICY_TIP': '1. Co najmniej 8 znaków.\n' +
+      '2. Musi zawierać małe litery.\n' +
+      '3. Musi zawierać duże litery.\n' +
+      '4. Musi zawierać znaki specjalne.',
+    'You are already logged in as': 'Jesteś już zalogowany jako',
+    'Sign out': 'Wyloguj się',
+    'Bad Response.': 'Nieprawidłowa odpowiedź.',
+    'Email': 'E-mail',
+    'Password': 'Hasło',
+    'Sign in': 'Zaloguj się',
+    'Confirm': 'Potwierdź',
+    'Don\'t have an account?': 'Nie masz konta?',
+    'Sign up': 'Zarejestruj się',
+    'or': 'lub',
+    'Forgot your password?': 'Nie pamiętasz hasła?',
+    'Create account': 'Utwórz konto',
+    'Username': 'Nazwa użytkownika',
+    'Confirm Password': 'Potwierdź hasło',
+    'New Password': 'Nowe hasło',
+    'By signing up, you agree to our': 'Rejestrując się, zgadzasz się na nasze',
+    'Terms of Service': 'Warunki korzystania',
+    ' and ': ' i ',
+    'Privacy Policy': 'Politykę prywatności',
+    'Already have an account?': 'Masz już konto?',
+    'Reset password': 'Zresetuj hasło',
+    'Enter the code sent to your email': 'Wpisz kod wysłany na Twój adres e-mail',
+    'Send code': 'Wyślij kod',
+    'Resend code': 'Wyślij kod ponownie',
+    'Back to login': 'Wróć do logowania',
+    'Enter your email': 'Wpisz swój adres e-mail',
+    'Invalid Password': 'Nieprawidłowe hasło',
+    'Passwords do not match.': 'Hasła nie są identyczne.',
+    'We have sent a numeric verification code to your email address at': 'Wysłaliśmy numeryczny kod weryfikacyjny na Twój adres e-mail:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Nieobsługiwany krok logowania:',
+    'AUTH_ERROR_GENERIC': 'Uwierzytelnianie nie powiodło się. Spróbuj ponownie.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Nieprawidłowy adres e-mail lub hasło.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Twoje konto nie zostało jeszcze potwierdzone. Zweryfikuj je za pomocą kodu, który wysłaliśmy.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Ta nazwa użytkownika jest już zajęta.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Nie znaleźliśmy konta dla tego adresu e-mail.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Kod weryfikacyjny jest nieprawidłowy.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Kod weryfikacyjny wygasł. Poproś o nowy.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Zbyt wiele żądań. Poczekaj chwilę i spróbuj ponownie.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Zbyt wiele nieudanych prób. Poczekaj chwilę i spróbuj ponownie.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Nie mogliśmy wysłać kodu weryfikacyjnego. Spróbuj ponownie później.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Jesteś już zalogowany.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Niektóre informacje są nieprawidłowe. Sprawdź dane i spróbuj ponownie.'
+  },
+  'sk': {
+    'login': 'Prihlásiť sa',
+    'signup': 'Zaregistrovať sa',
+    'reset-password': 'Obnoviť heslo',
+    'confirm-code': 'Potvrdiť kód',
+    'CODE_ON_THE_WAY_TIP': 'Váš kód je na ceste. Ak sa chcete prihlásiť, zadajte kód, ktorý sme vám poslali. Príchod môže trvať minútu.',
+    'PW_POLICY_TIP': '1. Aspoň 8 znakov.\n' +
+      '2. Musí obsahovať malé písmená.\n' +
+      '3. Musí obsahovať veľké písmená.\n' +
+      '4. Musí obsahovať špeciálne znaky.',
+    'You are already logged in as': 'Ste už prihlásený ako',
+    'Sign out': 'Odhlásiť sa',
+    'Bad Response.': 'Neplatná odpoveď.',
+    'Email': 'E-mail',
+    'Password': 'Heslo',
+    'Sign in': 'Prihlásiť sa',
+    'Confirm': 'Potvrdiť',
+    'Don\'t have an account?': 'Nemáte účet?',
+    'Sign up': 'Zaregistrovať sa',
+    'or': 'alebo',
+    'Forgot your password?': 'Zabudli ste heslo?',
+    'Create account': 'Vytvoriť účet',
+    'Username': 'Meno používateľa',
+    'Confirm Password': 'Potvrdiť heslo',
+    'New Password': 'Nové heslo',
+    'By signing up, you agree to our': 'Registráciou súhlasíte s našimi',
+    'Terms of Service': 'Podmienkami služby',
+    ' and ': ' a ',
+    'Privacy Policy': 'Zásadami ochrany súkromia',
+    'Already have an account?': 'Máte už účet?',
+    'Reset password': 'Obnoviť heslo',
+    'Enter the code sent to your email': 'Zadajte kód odoslaný na váš e-mail',
+    'Send code': 'Odoslať kód',
+    'Resend code': 'Znova odoslať kód',
+    'Back to login': 'Späť na prihlásenie',
+    'Enter your email': 'Zadajte svoju e-mailovú adresu',
+    'Invalid Password': 'Neplatné heslo',
+    'Passwords do not match.': 'Heslá sa nezhodujú.',
+    'We have sent a numeric verification code to your email address at': 'Poslali sme číselný overovací kód na vašu e-mailovú adresu:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Nepodporovaný krok prihlásenia:',
+    'AUTH_ERROR_GENERIC': 'Overenie zlyhalo. Skúste to prosím znova.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Nesprávny e-mail alebo heslo.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Váš účet ešte nebol potvrdený. Overte ho pomocou kódu, ktorý sme poslali.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Toto meno používateľa je už obsadené.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Pre túto e-mailovú adresu sme nenašli žiadny účet.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Overovací kód je nesprávny.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Platnosť overovacieho kódu vypršala. Požiadajte o nový.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Príliš veľa požiadaviek. Chvíľu počkajte a skúste to znova.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Príliš veľa neúspešných pokusov. Chvíľu počkajte a skúste to znova.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Overovací kód sa nám nepodarilo odoslať. Skúste to neskôr.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Ste už prihlásený.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Niektoré informácie sú neplatné. Skontrolujte zadané údaje a skúste to znova.'
+  },
+  'uk': {
+    'login': 'Увійти',
+    'signup': 'Зареєструватися',
+    'reset-password': 'Скинути пароль',
+    'confirm-code': 'Підтвердити код',
+    'CODE_ON_THE_WAY_TIP': 'Ваш код вже в дорозі. Щоб увійти, введіть код, який ми вам надіслали. Це може зайняти хвилину.',
+    'PW_POLICY_TIP': '1. Не менше 8 символів.\n' +
+      '2. Повинен містити малі літери.\n' +
+      '3. Повинен містити великі літери.\n' +
+      '4. Повинен містити спеціальні символи.',
+    'You are already logged in as': 'Ви вже увійшли як',
+    'Sign out': 'Вийти',
+    'Bad Response.': 'Некоректна відповідь.',
+    'Email': 'Ел. пошта',
+    'Password': 'Пароль',
+    'Sign in': 'Увійти',
+    'Confirm': 'Підтвердити',
+    'Don\'t have an account?': 'Немає облікового запису?',
+    'Sign up': 'Зареєструватися',
+    'or': 'або',
+    'Forgot your password?': 'Забули пароль?',
+    'Create account': 'Створити обліковий запис',
+    'Username': 'Ім\'я користувача',
+    'Confirm Password': 'Підтвердити пароль',
+    'New Password': 'Новий пароль',
+    'By signing up, you agree to our': 'Реєструючись, ви погоджуєтесь із нашими',
+    'Terms of Service': 'Умовами надання послуг',
+    ' and ': ' та ',
+    'Privacy Policy': 'Політикою конфіденційності',
+    'Already have an account?': 'Вже є обліковий запис?',
+    'Reset password': 'Скинути пароль',
+    'Enter the code sent to your email': 'Введіть код, надісланий на вашу пошту',
+    'Send code': 'Надіслати код',
+    'Resend code': 'Надіслати код повторно',
+    'Back to login': 'Повернутися до входу',
+    'Enter your email': 'Введіть адресу ел. пошти',
+    'Invalid Password': 'Недійсний пароль',
+    'Passwords do not match.': 'Паролі не збігаються.',
+    'We have sent a numeric verification code to your email address at': 'Ми надіслали числовий код підтвердження на вашу адресу ел. пошти:',
+    'COUNTDOWN_SUFFIX': 'с',
+    'Unsupported sign-in step:': 'Непідтримуваний крок входу:',
+    'AUTH_ERROR_GENERIC': 'Помилка автентифікації. Будь ласка, спробуйте ще раз.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Неправильна адреса ел. пошти або пароль.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Ваш обліковий запис ще не підтверджено. Будь ласка, підтвердьте його за допомогою коду, який ми надіслали.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Це ім\'я користувача вже зайнято.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Нам не вдалося знайти обліковий запис для цієї адреси ел. пошти.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Код підтвердження невірний.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Термін дії коду підтвердження закінчився. Будь ласка, запросіть новий.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Забагато запитів. Будь ласка, зачекайте хвилину і спробуйте знову.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Забагато невдалих спроб. Будь ласка, зачекайте хвилину і спробуйте знову.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Нам не вдалося надіслати код підтвердження. Будь ласка, спробуйте пізніше.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Ви вже увійшли в систему.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Деяка інформація недійсна. Будь ласка, перевірте введені дані та спробуйте знову.'
+  },
+  'fa': {
+    'login': 'ورود',
+    'signup': 'ثبت‌نام',
+    'reset-password': 'بازنشانی رمز عبور',
+    'confirm-code': 'تأیید کد',
+    'CODE_ON_THE_WAY_TIP': 'کد شما در راه است. برای ورود، کدی که برای شما ارسال کرده‌ایم را وارد کنید. ممکن است یک دقیقه طول بکشد تا برسد.',
+    'PW_POLICY_TIP': '1. حداقل ۸ کاراکتر.\n' +
+      '2. باید حروف کوچک داشته باشد.\n' +
+      '3. باید حروف بزرگ داشته باشد.\n' +
+      '4. باید نویسه‌های نمادی داشته باشد.',
+    'You are already logged in as': 'شما در حال حاضر با این حساب وارد شده‌اید:',
+    'Sign out': 'خروج',
+    'Bad Response.': 'پاسخ نامعتبر.',
+    'Email': 'ایمیل',
+    'Password': 'رمز عبور',
+    'Sign in': 'ورود',
+    'Confirm': 'تأیید',
+    'Don\'t have an account?': 'حساب کاربری ندارید؟',
+    'Sign up': 'ثبت‌نام',
+    'or': 'یا',
+    'Forgot your password?': 'رمز عبورتان را فراموش کرده‌اید؟',
+    'Create account': 'ایجاد حساب',
+    'Username': 'نام کاربری',
+    'Confirm Password': 'تأیید رمز عبور',
+    'New Password': 'رمز عبور جدید',
+    'By signing up, you agree to our': 'با ثبت‌نام، شما با',
+    'Terms of Service': 'شرایط خدمات',
+    ' and ': ' و ',
+    'Privacy Policy': 'سیاست حریم خصوصی',
+    'Already have an account?': 'حساب کاربری دارید؟',
+    'Reset password': 'بازنشانی رمز عبور',
+    'Enter the code sent to your email': 'کد ارسال‌شده به ایمیل خود را وارد کنید',
+    'Send code': 'ارسال کد',
+    'Resend code': 'ارسال مجدد کد',
+    'Back to login': 'بازگشت به ورود',
+    'Enter your email': 'آدرس ایمیل خود را وارد کنید',
+    'Invalid Password': 'رمز عبور نامعتبر',
+    'Passwords do not match.': 'رمزهای عبور مطابقت ندارند.',
+    'We have sent a numeric verification code to your email address at': 'یک کد تأیید عددی به آدرس ایمیل شما ارسال کرده‌ایم:',
+    'COUNTDOWN_SUFFIX': 'ث',
+    'Unsupported sign-in step:': 'مرحله ورود پشتیبانی‌نشده:',
+    'AUTH_ERROR_GENERIC': 'احراز هویت ناموفق بود. لطفاً دوباره امتحان کنید.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'ایمیل یا رمز عبور اشتباه است.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'حساب شما هنوز تأیید نشده است. لطفاً آن را با کدی که ارسال کرده‌ایم تأیید کنید.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'این نام کاربری قبلاً استفاده شده است.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'حسابی برای آن آدرس ایمیل پیدا نکردیم.',
+    'AUTH_ERROR_CODE_MISMATCH': 'کد تأیید اشتباه است.',
+    'AUTH_ERROR_CODE_EXPIRED': 'کد تأیید منقضی شده است. لطفاً یک کد جدید درخواست کنید.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'درخواست‌های بیش از حد. لطفاً کمی صبر کنید و دوباره امتحان کنید.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'تلاش‌های ناموفق زیاد. لطفاً کمی صبر کنید و دوباره امتحان کنید.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'نتوانستیم کد تأیید را ارسال کنیم. لطفاً بعداً دوباره امتحان کنید.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'شما قبلاً وارد شده‌اید.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'برخی اطلاعات نامعتبر است. لطفاً ورودی خود را بررسی کنید و دوباره امتحان کنید.'
+  },
+  'id': {
+    'login': 'Masuk',
+    'signup': 'Daftar',
+    'reset-password': 'Atur ulang kata sandi',
+    'confirm-code': 'Konfirmasi kode',
+    'CODE_ON_THE_WAY_TIP': 'Kode Anda sedang dalam perjalanan. Untuk masuk, masukkan kode yang kami kirimkan. Mungkin perlu satu menit untuk tiba.',
+    'PW_POLICY_TIP': '1. Minimal 8 karakter.\n' +
+      '2. Harus mengandung huruf kecil.\n' +
+      '3. Harus mengandung huruf besar.\n' +
+      '4. Harus mengandung karakter simbol.',
+    'You are already logged in as': 'Anda sudah masuk sebagai',
+    'Sign out': 'Keluar',
+    'Bad Response.': 'Respons buruk.',
+    'Email': 'Email',
+    'Password': 'Kata sandi',
+    'Sign in': 'Masuk',
+    'Confirm': 'Konfirmasi',
+    'Don\'t have an account?': 'Belum punya akun?',
+    'Sign up': 'Daftar',
+    'or': 'atau',
+    'Forgot your password?': 'Lupa kata sandi?',
+    'Create account': 'Buat akun',
+    'Username': 'Nama pengguna',
+    'Confirm Password': 'Konfirmasi kata sandi',
+    'New Password': 'Kata sandi baru',
+    'By signing up, you agree to our': 'Dengan mendaftar, Anda menyetujui',
+    'Terms of Service': 'Ketentuan Layanan',
+    ' and ': ' dan ',
+    'Privacy Policy': 'Kebijakan Privasi',
+    'Already have an account?': 'Sudah punya akun?',
+    'Reset password': 'Atur ulang kata sandi',
+    'Enter the code sent to your email': 'Masukkan kode yang dikirim ke email Anda',
+    'Send code': 'Kirim kode',
+    'Resend code': 'Kirim ulang kode',
+    'Back to login': 'Kembali ke halaman masuk',
+    'Enter your email': 'Masukkan email Anda',
+    'Invalid Password': 'Kata sandi tidak valid',
+    'Passwords do not match.': 'Kata sandi tidak cocok.',
+    'We have sent a numeric verification code to your email address at': 'Kami telah mengirimkan kode verifikasi numerik ke alamat email Anda:',
+    'COUNTDOWN_SUFFIX': 'd',
+    'Unsupported sign-in step:': 'Langkah masuk yang tidak didukung:',
+    'AUTH_ERROR_GENERIC': 'Autentikasi gagal. Silakan coba lagi.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Email atau kata sandi salah.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Akun Anda belum dikonfirmasi. Silakan verifikasi dengan kode yang kami kirimkan.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Nama pengguna ini sudah digunakan.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Kami tidak dapat menemukan akun untuk alamat email tersebut.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Kode verifikasi salah.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Kode verifikasi telah kedaluwarsa. Silakan minta yang baru.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Terlalu banyak permintaan. Tunggu sebentar dan coba lagi.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Terlalu banyak upaya gagal. Tunggu sebentar dan coba lagi.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Kami tidak dapat mengirimkan kode verifikasi. Silakan coba lagi nanti.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Anda sudah masuk.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Beberapa informasi tidak valid. Periksa masukan Anda dan coba lagi.'
+  },
+  'cs': {
+    'login': 'Přihlásit se',
+    'signup': 'Zaregistrovat se',
+    'reset-password': 'Obnovit heslo',
+    'confirm-code': 'Potvrdit kód',
+    'CODE_ON_THE_WAY_TIP': 'Váš kód je na cestě. Chcete-li se přihlásit, zadejte kód, který jsme vám poslali. Příchod může trvat minutu.',
+    'PW_POLICY_TIP': '1. Alespoň 8 znaků.\n' +
+      '2. Musí obsahovat malá písmena.\n' +
+      '3. Musí obsahovat velká písmena.\n' +
+      '4. Musí obsahovat speciální znaky.',
+    'You are already logged in as': 'Jste již přihlášeni jako',
+    'Sign out': 'Odhlásit se',
+    'Bad Response.': 'Špatná odpověď.',
+    'Email': 'E-mail',
+    'Password': 'Heslo',
+    'Sign in': 'Přihlásit se',
+    'Confirm': 'Potvrdit',
+    'Don\'t have an account?': 'Nemáte účet?',
+    'Sign up': 'Zaregistrovat se',
+    'or': 'nebo',
+    'Forgot your password?': 'Zapomněli jste heslo?',
+    'Create account': 'Vytvořit účet',
+    'Username': 'Uživatelské jméno',
+    'Confirm Password': 'Potvrdit heslo',
+    'New Password': 'Nové heslo',
+    'By signing up, you agree to our': 'Registrací souhlasíte s našimi',
+    'Terms of Service': 'Podmínkami služby',
+    ' and ': ' a ',
+    'Privacy Policy': 'Zásadami ochrany osobních údajů',
+    'Already have an account?': 'Již máte účet?',
+    'Reset password': 'Obnovit heslo',
+    'Enter the code sent to your email': 'Zadejte kód odeslaný na váš e-mail',
+    'Send code': 'Odeslat kód',
+    'Resend code': 'Znovu odeslat kód',
+    'Back to login': 'Zpět na přihlášení',
+    'Enter your email': 'Zadejte svůj e-mail',
+    'Invalid Password': 'Neplatné heslo',
+    'Passwords do not match.': 'Hesla se neshodují.',
+    'We have sent a numeric verification code to your email address at': 'Zaslali jsme číselný ověřovací kód na vaši e-mailovou adresu:',
+    'COUNTDOWN_SUFFIX': 's',
+    'Unsupported sign-in step:': 'Nepodporovaný krok přihlášení:',
+    'AUTH_ERROR_GENERIC': 'Ověření se nezdařilo. Zkuste to prosím znovu.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'Nesprávný e-mail nebo heslo.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'Váš účet ještě nebyl potvrzen. Ověřte ho prosím pomocí kódu, který jsme odeslali.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'Toto uživatelské jméno je již obsazeno.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'Nenašli jsme žádný účet pro tuto e-mailovou adresu.',
+    'AUTH_ERROR_CODE_MISMATCH': 'Ověřovací kód je nesprávný.',
+    'AUTH_ERROR_CODE_EXPIRED': 'Platnost ověřovacího kódu vypršela. Požádejte o nový.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'Příliš mnoho požadavků. Chvíli počkejte a zkuste to znovu.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'Příliš mnoho neúspěšných pokusů. Chvíli počkejte a zkuste to znovu.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'Nepodařilo se nám odeslat ověřovací kód. Zkuste to prosím later.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'Jste již přihlášeni.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'Některé informace jsou neplatné. Zkontrolujte svůj vstup a zkuste to znovu.'
+  },
+  'ar': {
+    'login': 'تسجيل الدخول',
+    'signup': 'إنشاء حساب',
+    'reset-password': 'إعادة تعيين كلمة المرور',
+    'confirm-code': 'تأكيد الرمز',
+    'CODE_ON_THE_WAY_TIP': 'رمزك في الطريق. لتسجيل الدخول، أدخل الرمز الذي أرسلناه إليك. قد يستغرق الأمر دقيقة للوصول.',
+    'PW_POLICY_TIP': '1. 8 أحرف على الأقل.\n' +
+      '2. يجب أن يحتوي على أحرف صغيرة.\n' +
+      '3. يجب أن يحتوي على أحرف كبيرة.\n' +
+      '4. يجب أن يحتوي على رموز.',
+    'You are already logged in as': 'أنت بالفعل مسجل دخولك بحساب:',
+    'Sign out': 'تسجيل الخروج',
+    'Bad Response.': 'استجابة غير صالحة.',
+    'Email': 'البريد الإلكتروني',
+    'Password': 'كلمة المرور',
+    'Sign in': 'تسجيل الدخول',
+    'Confirm': 'تأكيد',
+    'Don\'t have an account?': 'ليس لديك حساب؟',
+    'Sign up': 'إنشاء حساب',
+    'or': 'أو',
+    'Forgot your password?': 'هل نسيت كلمة المرور؟',
+    'Create account': 'إنشاء حساب',
+    'Username': 'اسم المستخدم',
+    'Confirm Password': 'تأكيد كلمة المرور',
+    'New Password': 'كلمة مرور جديدة',
+    'By signing up, you agree to our': 'بالتسجيل، أنت توافق على',
+    'Terms of Service': 'شروط الخدمة',
+    ' and ': ' و',
+    'Privacy Policy': 'سياسة الخصوصية',
+    'Already have an account?': 'لديك حساب بالفعل؟',
+    'Reset password': 'إعادة تعيين كلمة المرور',
+    'Enter the code sent to your email': 'أدخل الرمز المرسل إلى بريدك الإلكتروني',
+    'Send code': 'إرسال الرمز',
+    'Resend code': 'إعادة إرسال الرمز',
+    'Back to login': 'العودة إلى تسجيل الدخول',
+    'Enter your email': 'أدخل بريدك الإلكتروني',
+    'Invalid Password': 'كلمة مرور غير صالحة',
+    'Passwords do not match.': 'كلمتا المرور غير متطابقتين.',
+    'We have sent a numeric verification code to your email address at': 'لقد أرسلنا رمز تحقق رقمياً إلى عنوان بريدك الإلكتروني:',
+    'COUNTDOWN_SUFFIX': 'ث',
+    'Unsupported sign-in step:': 'خطوة تسجيل دخول غير مدعومة:',
+    'AUTH_ERROR_GENERIC': 'فشل المصادقة. يرجى المحاولة مرة أخرى.',
+    'AUTH_ERROR_INVALID_CREDENTIALS': 'بريد إلكتروني أو كلمة مرور غير صحيحة.',
+    'AUTH_ERROR_USER_NOT_CONFIRMED': 'لم يتم تأكيد حسابك بعد. يرجى التحقق منه باستخدام الرمز الذي أرسلناه.',
+    'AUTH_ERROR_USERNAME_EXISTS': 'اسم المستخدم هذا مأخوذ بالفعل.',
+    'AUTH_ERROR_USER_NOT_FOUND': 'لم نتمكن من العثور على حساب لعنوان البريد الإلكتروني هذا.',
+    'AUTH_ERROR_CODE_MISMATCH': 'رمز التحقق غير صحيح.',
+    'AUTH_ERROR_CODE_EXPIRED': 'انتهت صلاحية رمز التحقق. يرجى طلب رمز جديد.',
+    'AUTH_ERROR_TOO_MANY_REQUESTS': 'طلبات كثيرة جداً. يرجى الانتظار لحظة والمحاولة مرة أخرى.',
+    'AUTH_ERROR_TOO_MANY_ATTEMPTS': 'محاولات فاشلة كثيرة جداً. يرجى الانتظار لحظة والمحاولة مرة أخرى.',
+    'AUTH_ERROR_CODE_DELIVERY_FAILED': 'لم نتمكن من إرسال رمز التحقق. يرجى المحاولة مرة أخرى لاحقاً.',
+    'AUTH_ERROR_ALREADY_AUTHENTICATED': 'أنت بالفعل مسجل دخولك.',
+    'AUTH_ERROR_INVALID_PARAMETER': 'بعض المعلومات غير صالحة. يرجى التحقق من إدخالك والمحاولة مرة أخرى.'
   }
 }

+ 38 - 18
packages/ui/src/amplify/ui.tsx

@@ -2,13 +2,14 @@ import { Button } from '@/components/ui/button'
 import { Input, InputProps } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { cn } from '@/lib/utils'
-import { FormHTMLAttributes, useEffect, useState } from 'react'
+import { FormHTMLAttributes, useEffect, useRef, useState } from 'react'
 import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
 import { AlertCircleIcon, Loader2Icon, LucideEye, LucideEyeClosed, LucideX } from 'lucide-react'
 import { AuthFormRootContext, t, useAuthFormState } from './core'
 import * as Auth from 'aws-amplify/auth'
 import { Skeleton } from '@/components/ui/skeleton'
 import * as React from 'react'
+import { getAuthErrorMessageKey } from './errors'
 
 function ErrorTip({ error, removeError }: {
   error: string | { variant?: 'warning' | 'destructive', title?: string, message: string | any },
@@ -108,14 +109,26 @@ function validatePasswordPolicy(password: string) {
   }
 }
 
+function getAuthErrorMessage(error: unknown) {
+  return t(getAuthErrorMessageKey(error))
+}
+
 function useCountDown() {
   const [countDownNum, setCountDownNum] = useState<number>(0)
+  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
+
   const startCountDown = () => {
+    if (intervalRef.current) {
+      clearInterval(intervalRef.current)
+    }
     setCountDownNum(60)
-    const interval = setInterval(() => {
+    intervalRef.current = setInterval(() => {
       setCountDownNum((num) => {
         if (num <= 1) {
-          clearInterval(interval)
+          if (intervalRef.current) {
+            clearInterval(intervalRef.current)
+            intervalRef.current = null
+          }
           return 0
         }
         return num - 1
@@ -125,7 +138,10 @@ function useCountDown() {
 
   useEffect(() => {
     return () => {
-      setCountDownNum(0)
+      if (intervalRef.current) {
+        clearInterval(intervalRef.current)
+        intervalRef.current = null
+      }
     }
   }, [])
 
@@ -224,10 +240,10 @@ export function LoginForm() {
             await loadSession()
             return
           default:
-            throw new Error('Unsupported sign-in step: ' + nextStep)
+            throw new Error(`${t('Unsupported sign-in step:')} ${nextStep}`)
         }
       } catch (e) {
-        setErrors({ password: { message: (e as Error).message, title: t('Bad Response.') } })
+        setErrors({ password: { message: getAuthErrorMessage(e), title: t('Bad Response.') } })
         console.error(e)
       } finally {
         setLoading(false)
@@ -338,7 +354,7 @@ export function SignupForm() {
           }
         } catch (e: any) {
           console.error(e)
-          const error = { title: t('Bad Response.'), message: (e as Error).message }
+          const error = { title: t('Bad Response.'), message: getAuthErrorMessage(e) }
           let k = 'confirm_password'
           if (e.name === 'UsernameExistsException') {
             k = 'username'
@@ -423,21 +439,25 @@ export function ResetPasswordForm() {
             setIsSentCode(true)
           } catch (error) {
             console.error('Error sending reset code:', error)
-            setErrors({ email: { message: (error as Error).message, title: t('Bad Response.') } })
+            setErrors({ email: { message: getAuthErrorMessage(error), title: t('Bad Response.') } })
           } finally {
             setLoading(false)
           }
         } else {
           // confirm reset password
-          if ((data.password as string)?.length < 8) {
+          try {
+            validatePasswordPolicy(data.password as string)
+          } catch (error) {
             setErrors({
               password: {
-                message: t('Password must be at least 8 characters.'),
+                message: (error as Error).message,
                 title: t('Invalid Password')
               }
             })
             return
-          } else if (data.password !== data.confirm_password) {
+          }
+
+          if (data.password !== data.confirm_password) {
             setErrors({
               confirm_password: {
                 message: t('Passwords do not match.'),
@@ -458,7 +478,7 @@ export function ResetPasswordForm() {
               setCurrentTab('login')
             } catch (error) {
               console.error('Error confirming reset password:', error)
-              setErrors({ 'confirm_password': { message: (error as Error).message, title: t('Bad Response.') } })
+              setErrors({ 'confirm_password': { message: getAuthErrorMessage(error), title: t('Bad Response.') } })
             } finally {
               setLoading(false)
             }
@@ -470,7 +490,7 @@ export function ResetPasswordForm() {
           <div className={'w-full opacity-60 flex justify-end relative h-0 z-[2]'}>
             {countDownNum > 0 ? (
               <span className={'text-sm opacity-50 select-none absolute top-3 right-0'}>
-                {countDownNum}s
+                {countDownNum}{t('COUNTDOWN_SUFFIX')}
               </span>
             ) : (<a onClick={async () => {
               startCountDown()
@@ -479,7 +499,7 @@ export function ResetPasswordForm() {
                 console.debug('[Auth] reset pw code re-sent: ', ret)
               } catch (error) {
                 console.error('Error resending reset code:', error)
-                setErrors({ email: { message: (error as Error).message, title: t('Bad Response.') } })
+                setErrors({ email: { message: getAuthErrorMessage(error), title: t('Bad Response.') } })
               } finally {}
             }} className={'text-sm opacity-70 hover:opacity-90 underline absolute top-3 right-0 select-none'}>
               {t('Resend code')}
@@ -589,7 +609,7 @@ export function ConfirmWithCodeForm(
             console.debug('confirmSignIn: ', ret)
           }
         } catch (e) {
-          setErrors({ code: { message: (e as Error).message, title: t('Bad Response.') } })
+          setErrors({ code: { message: getAuthErrorMessage(e), title: t('Bad Response.') } })
           console.error(e)
         } finally {
           setLoading(false)
@@ -613,7 +633,7 @@ export function ConfirmWithCodeForm(
       <span className={'w-full flex justify-end relative h-0 z-10'}>
         {countDownNum > 0 ? (
           <span className={'text-sm opacity-50 select-none absolute -bottom-8'}>
-            {countDownNum}s
+            {countDownNum}{t('COUNTDOWN_SUFFIX')}
           </span>
         ) : <a
           className={'text-sm opacity-50 hover:opacity-80 active:opacity-50 select-none underline absolute -bottom-8'}
@@ -632,7 +652,7 @@ export function ConfirmWithCodeForm(
                 // await Auth.resendSignInCode(props.user)
               }
             } catch (e) {
-              setErrors({ code: { message: (e as Error).message, title: t('Bad Response.') } })
+              setErrors({ code: { message: getAuthErrorMessage(e), title: t('Bad Response.') } })
               setCountDownNum(0)
               console.error(e)
             } finally {}
@@ -707,4 +727,4 @@ export function LSAuthenticator(props: any) {
       </div>
     </AuthFormRootContext.Provider>
   )
-}
+}

+ 48 - 0
packages/ui/src/i18n.test.mts

@@ -0,0 +1,48 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { setLocale, setNSDicts, setTranslate, translate } from './i18n'
+import { getAuthErrorMessageKey } from './amplify/errors'
+
+test('translate uses the selected locale when the namespace dict contains it', () => {
+  setTranslate((locale, dicts, key, ...args) => dicts[locale]?.[key] ?? args[0] ?? key)
+  setNSDicts('locale', {
+    en: { greeting: 'Hello' },
+    'zh-CN': { greeting: '你好' }
+  })
+  setLocale('zh-CN')
+
+  assert.equal(translate('locale', 'greeting'), '你好')
+})
+
+test('translate falls back to English when the current locale is unavailable', () => {
+  setTranslate((locale, dicts, key, ...args) => dicts[locale]?.[key] ?? args[0] ?? key)
+  setNSDicts('fallback', {
+    en: { greeting: 'Hello' }
+  })
+  setLocale('zh-CN')
+
+  assert.equal(translate('fallback', 'greeting'), 'Hello')
+})
+
+test('translate falls back to English when the current locale dict has no corresponding key', () => {
+  setTranslate((locale, dicts, key, ...args) => dicts[locale]?.[key] ?? args[0] ?? key)
+  setNSDicts('fallback', {
+    en: { greeting: 'Hello' },
+    'zh-CN': { farewell: '再见' }
+  })
+  setLocale('zh-CN')
+
+  assert.equal(translate('fallback', 'greeting'), 'Hello')
+})
+
+test('getAuthErrorMessageKey maps common Cognito errors to localized keys', () => {
+  assert.equal(getAuthErrorMessageKey({ name: 'NotAuthorizedException' }), 'AUTH_ERROR_INVALID_CREDENTIALS')
+  assert.equal(getAuthErrorMessageKey({ name: 'CodeMismatchException' }), 'AUTH_ERROR_CODE_MISMATCH')
+  assert.equal(getAuthErrorMessageKey({ name: 'InvalidPasswordException' }), 'PW_POLICY_TIP')
+})
+
+test('getAuthErrorMessageKey falls back to a generic localized key for unknown errors', () => {
+  assert.equal(getAuthErrorMessageKey({ name: 'SomethingUnexpected' }), 'AUTH_ERROR_GENERIC')
+  assert.equal(getAuthErrorMessageKey(new Error('plain error')), 'AUTH_ERROR_GENERIC')
+})

+ 5 - 5
packages/ui/src/i18n.ts

@@ -13,7 +13,7 @@ let _translate: TranslateFn = (
   key: string,
   ...args: any
 ) => {
-  return dicts[locale]?.[key] || args[0] || key
+  return dicts[locale]?.[key] ?? args[0] ?? key
 }
 
 export function setTranslate(t: TranslateFn) {
@@ -24,7 +24,7 @@ export function setLocale(locale: string) {
   _locale = locale
 }
 
-export function setNSDicts(ns: string, dicts: Record<string, string>) {
+export function setNSDicts(ns: string, dicts: Record<string, any>) {
   (_nsDicts as any)[ns] = dicts
 }
 
@@ -34,7 +34,7 @@ export const translate = (
   ...args: any
 ) => {
   const dicts = (_nsDicts as any)[ns] || {}
-  return _translate(
-    _nsDicts?.hasOwnProperty(_locale) ? _locale : 'en',
-    dicts, key, ...args)
+  const localeDict = dicts[_locale]
+  const locale = (localeDict && Object.prototype.hasOwnProperty.call(localeDict, key)) ? _locale : 'en'
+  return _translate(locale, dicts, key, ...args)
 }

+ 39 - 2
prompts/review.md

@@ -18,7 +18,7 @@ You're Clojure(script) expert, you're responsible to check those common errors:
   - Replace `js/console.warn` with `log/warn`.
   - Replace `js/console.log` with `log/info`.
   - NOTE: `log/<level>` function takes key-value pairs as arguments
-  
+
 - After adding a new property in `logseq.db.frontend.property/built-in-properties`, you need to add a corresponding migration in `frontend.worker.db.migrate/schema-version->updates`.
   - e.g. `["65.10" {:properties [:block/journal-day]}]`
 
@@ -27,7 +27,44 @@ You're Clojure(script) expert, you're responsible to check those common errors:
 
 - A function that returns a promise, and its function name starts with "<".
 
+## i18n review rules
+
+- Use `.i18n-lint.toml` as the source of truth for i18n lint scope, covered UI
+  helpers, translated attributes, exclusions, and allowlists.
+- Inside that scope, all shipped user-facing UI text must use helpers from
+  `frontend.context.i18n`. Console text is exempt. Keep out-of-scope
+  developer-only `(Dev)` labels inline in code/config, not in translation
+  dictionaries.
+- If a new user-facing surface is not represented in `.i18n-lint.toml`, flag
+  the missing lint coverage.
+- Reuse existing `src/resources/dicts/en.edn` keys only on exact semantic owner
+  and textual role match. Otherwise follow `docs/i18n-key-naming.md`.
+- Add new English source text to `src/resources/dicts/en.edn`. Add non-English
+  entries only when providing actual translations. When renaming or removing
+  keys, clean up stale keys in affected locale files.
+- `notification/show!` and translated attributes from `.i18n-lint.toml`
+  (placeholder/title/aria/label-like UI text) must not receive raw English
+  string literals unless proven non-user-facing.
+- For plain dynamic text, use placeholders like `{1}` and pre-format arguments
+  in the caller before passing them to `t`.
+- Keep complete sentences in one translation entry. Use
+  `interpolate-rich-text`, `interpolate-sentence`, `locale-join-rich-text`, and
+  `locale-format-*` from `frontend.context.i18n` instead of assembling text ad
+  hoc in the caller.
+- Function-valued translations are allowed only for real logic or hiccup rich
+  text, and may only use `str`, `when`, `if`, and `=`.
+- Rich text and inline links must stay in a single translation entry, not split
+  across multiple keys.
+- Preserve emoji/icon glyphs from `en.edn`, and use punctuation natural to each
+  locale.
+- Pluralization is locale-specific. Do not force English singular/plural
+  structure onto other locales.
+- After changing keys run `bb lang:validate-translations`; after changing UI
+  text run `bb lang:lint-hardcoded`; after editing dictionary files run
+  `bb lang:format-dicts`.
+- If you add a new linted helper or attribute, update `.i18n-lint.toml`.
+
 - Prohibit converting js/Uint8Array to vector. e.g. `(vec uint8-array)`
-  - This operation is very slow when the Uint8Array is large (e.g. an asset). 
+  - This operation is very slow when the Uint8Array is large (e.g. an asset).
 
 - `:block/content` attribute is not used in the DB version; `:block/title` is the attribute that stores the main content of the block.

+ 3 - 0
scripts/package.json

@@ -2,6 +2,9 @@
   "name": "nbb-dev-scripts",
   "version": "1.0.0",
   "private": true,
+  "scripts": {
+    "test": "yarn nbb-logseq -cp test -m logseq.tasks.test-runner"
+  },
   "devDependencies": {
     "@logseq/nbb-logseq": "github:logseq/nbb-logseq#feat-db-v34"
   },

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
scripts/resources/schemaorg-current-https.json


+ 226 - 135
scripts/src/logseq/tasks/lang.clj

@@ -7,7 +7,9 @@
             [clojure.set :as set]
             [clojure.string :as string]
             [frontend.dicts :as dicts]
-            [logseq.tasks.util :as task-util]))
+            [logseq.tasks.lang-lint :as lang-lint]
+            [logseq.tasks.util :as task-util]
+            [rewrite-clj.node :as node]))
 
 (defn- get-dicts
   []
@@ -19,28 +21,48 @@
        (map (juxt :value :label))
        (into {})))
 
+(defn- shorten [s length]
+  (if (< (count s) length)
+    s
+    (string/replace (str (subs s 0 length) "...")
+                    ;; Keep shortened table rows single-line for multi-line translations.
+                    "\n" "\\n")))
+
 (defn list-langs
   "List translated languages with their number of translations"
   []
   (let [dicts (get-dicts)
-        en-count (count (dicts :en))
         langs (get-languages)]
-    (->> dicts
-         (map (fn [[locale dicts]]
-                [locale
-                 (Math/round (* 100.0 (/ (count dicts) en-count)))
-                 (count dicts)
-                 (langs locale)]))
-         (sort-by #(nth % 2) >)
-         (map #(zipmap [:locale :percent-translated :translation-count :language] %))
+    (->> (lang-lint/translation-summary-stats dicts)
+         (lang-lint/sort-translation-summary-stats)
+         (map (fn [{:keys [lang translation-count untranslated-count same-as-en-count]}]
+                {:locale lang
+                 :translation-count translation-count
+                 :untranslated-count (if (= lang :en) "-" untranslated-count)
+                 :same-as-en-count (if (= lang :en) "-" same-as-en-count)
+                 :language (langs lang)}))
          task-util/print-table)))
 
-(defn- shorten [s length]
-  (if (< (count s) length)
-    s
-    (string/replace (str (subs s 0 length) "...")
-                    ;; Escape newlines for multi-line translations like tutorials
-                    "\n" "\\n")))
+(defn list-pseudo
+  "List translations for LOCALE whose localized value is identical to English."
+  [& args]
+  (let [lang (or (some-> (first args) keyword)
+                 (task-util/print-usage "LOCALE"))
+        langs (get-languages)
+        dicts (get-dicts)]
+    (when-not (contains? langs lang)
+      (println "Language" lang "does not have an entry in frontend.dicts/languages")
+      (System/exit 1))
+    (let [findings (->> (lang-lint/identical-translation-findings dicts lang)
+                        (map (fn [{:keys [translation-key default-value]}]
+                               {:translation-key translation-key
+                                :same-as-en-value default-value
+                                :file (str "dicts/" (-> lang name string/lower-case) ".edn")}))
+                        (sort-by (juxt :file :translation-key)))]
+      (if (empty? findings)
+        (println "Language" lang "does not contain translations identical to English!")
+        (task-util/print-table
+         (map #(update % :same-as-en-value shorten 50) findings))))))
 
 (defn list-missing
   "List missing translations for a given language"
@@ -49,7 +71,7 @@
                  (task-util/print-usage "LOCALE [--copy]"))
         options (cli/parse-opts (rest args) {:coerce {:copy :boolean}})
         _ (when-not (contains? (get-languages) lang)
-            (println "Language" lang "does not have an entry in dicts/core.cljs")
+            (println "Language" lang "does not have an entry in frontend.dicts/languages")
             (System/exit 1))
         dicts (get-dicts)
         all-missing (select-keys (dicts :en)
@@ -83,21 +105,138 @@
                             result invalid-keys))]
       (spit (fs/file path) new-content))))
 
+(def ^:private dicts-dir
+  (fs/path "src/resources/dicts"))
+
+(def ^:private ignored-dict-node-tags
+  #{:comment :newline :whitespace})
+
+(defn- ignored-dict-node?
+  [node]
+  (contains? ignored-dict-node-tags (node/tag node)))
+
+(defn- dict-map-node
+  [root]
+  (->> (:children root)
+       (remove ignored-dict-node?)
+       first))
+
+(defn- parse-dict-entries
+  [text]
+  (let [root (rewrite/parse-string text)
+        map-node (dict-map-node root)]
+    (when-not (= :map (node/tag map-node))
+      (println "Expected a top-level map in dictionary file.")
+      (System/exit 1))
+    (let [entry-nodes (->> (:children map-node)
+                           (remove ignored-dict-node?))]
+      (when (odd? (count entry-nodes))
+        (println "Encountered an uneven number of top-level dictionary nodes.")
+        (System/exit 1))
+      (mapv (fn [[key-node value-node]]
+              {:key (rewrite/sexpr key-node)
+               :value-node value-node})
+            (partition 2 entry-nodes)))))
+
+(defn- render-dict-entry
+  [{:keys [key value-node]}]
+  (str " " key " " value-node))
+
+(defn- key-namespace-root
+  [key]
+  (some-> key namespace (string/split #"\.") first))
+
+(defn- key-leaf
+  [key]
+  (name key))
+
+(defn- compare-dict-keys
+  [key-a key-b]
+  (let [namespace-a (namespace key-a)
+        namespace-b (namespace key-b)
+        root-a (key-namespace-root key-a)
+        root-b (key-namespace-root key-b)
+        root-diff (compare root-a root-b)]
+    (cond
+      (not= 0 root-diff)
+      root-diff
+
+      (not= namespace-a root-a)
+      (if (= namespace-b root-b) 1
+          (let [namespace-diff (compare namespace-a namespace-b)]
+            (if (zero? namespace-diff)
+              (compare (key-leaf key-a) (key-leaf key-b))
+              namespace-diff)))
+
+      (not= namespace-b root-b)
+      -1
+
+      :else
+      (compare (key-leaf key-a) (key-leaf key-b)))))
+
+(defn- render-dict
+  [entries]
+  (let [sorted-entries (sort #(neg? (compare-dict-keys (:key %1) (:key %2))) entries)
+        lines (loop [remaining sorted-entries
+                     previous-namespace nil
+                     acc ["{"]]
+                (if-let [{:keys [key] :as entry} (first remaining)]
+                  (let [current-namespace (namespace key)
+                        acc (cond-> acc
+                              (and previous-namespace
+                                   (not= previous-namespace current-namespace))
+                              (conj "")
+                              true
+                              (conj (render-dict-entry entry)))]
+                    (recur (next remaining) current-namespace acc))
+                  (conj acc "}")))]
+    (str (string/join "\n" lines) "\n")))
+
+(defn- dict-file-paths
+  []
+  (->> (fs/list-dir dicts-dir)
+       (filter #(string/ends-with? (str %) ".edn"))
+       (sort-by fs/file-name)))
+
+(defn format-dicts
+  "Formats dictionary files by full-key sort order and inserts a blank line
+   between namespace groups. Use --check to fail when any file would change."
+  [& args]
+  (let [check? (contains? (set args) "--check")
+        changed? (volatile! false)]
+    (doseq [path (dict-file-paths)]
+      (let [file-name (fs/file-name path)
+            current-text (slurp (str path))
+            output-text (-> current-text
+                            parse-dict-entries
+                            render-dict)]
+        (if (= current-text output-text)
+          (println file-name ": already formatted")
+          (do
+            (vreset! changed? true)
+            (if check?
+              (println file-name "would change")
+              (do
+                (spit (str path) output-text)
+                (println file-name ": formatted")))))))
+    (when (and check? @changed?)
+      (System/exit 1))))
+
 (defn- validate-non-default-languages
   "This validation finds any translation keys that don't exist in the default
   language English. Logseq needs to work out of the box with its default
   language. This catches mistakes where another language has accidentally typoed
   keys or added ones without updating :en"
-  [{:keys [fix?]}]
+  [fix?]
   (let [dicts (get-dicts)
         ;; For now defined as :en but clj-kondo analysis could be more thorough
         valid-keys (set (keys (dicts :en)))
         invalid-dicts
         (->> (dissoc dicts :en)
-             (mapcat (fn [[lang get-dicts]]
+             (mapcat (fn [[lang lang-dicts]]
                        (map
                         #(hash-map :language lang :invalid-key %)
-                        (set/difference (set (keys get-dicts))
+                        (set/difference (set (keys lang-dicts))
                                         valid-keys)))))]
     (if (empty? invalid-dicts)
       (println "All non-default translations have valid keys!")
@@ -107,131 +246,83 @@
         (when fix?
           (delete-invalid-non-default-languages
            (update-vals (group-by :language invalid-dicts) #(map :invalid-key %)))
-          (println "These invalid non-language keys have been removed."))
+          (println "These invalid translation keys have been removed from non-default dictionaries."))
         (System/exit 1)))))
 
-;; Command to check for manual entries:
-;; grep -E -oh  '\(t [^ ):]+' -r src/main
-(def manual-ui-dicts
-  "Manual list of ui translations because they are dynamic i.e. keyword isn't
-  first arg. Only map values are used in linter as keys are for easily scanning
-  grep result."
-
-  {"(t (shortcut-helper/decorate-namespace" [] ;; shortcuts related so can ignore
-   "(t (keyword" [:color/yellow :color/red :color/pink :color/green :color/blue
-                  :color/purple :color/gray]
-   "(tt (keyword" [:left-side-bar/assets :left-side-bar/tasks]
-
-   ;; from 3 files
-   "(t (if" [:asset/show-in-folder :asset/open-in-browser
-             :search-item/page
-             :page/make-private :page/make-public]
-   "(t (name" [] ;; shortcuts related
-   "(t (dh/decorate-namespace" [] ;; shortcuts related
-   "(t prompt-key" [:select/default-prompt :select/default-select-multiple :select.graph/prompt]
-   ;; All args to ui/make-confirm-modal are not keywords
-   "(t title" []
-   "(t (or title-key" [:views.table/live-query-title :views.table/default-title :all-pages/table-title]
-   "(t subtitle" [:asset/physical-delete]})
-
-(defn- delete-not-used-key-from-dict-file
-  [invalid-keys]
-  (let [paths (fs/list-dir "src/resources/dicts")]
-    (doseq [path paths]
-      (let [result (rewrite/parse-string (String. (fs/read-all-bytes path)))
-            new-content (str (reduce
-                              (fn [result k]
-                                (rewrite/dissoc result k))
-                              result invalid-keys))]
-        (spit (fs/file path) new-content)))))
-
-(defn- validate-ui-translations-are-used
-  "This validation checks to see that translations done by (t ...) are equal to
-  the ones defined for the default :en lang. This catches translations that have
-  been added in UI but don't have an entry or translations no longer used in the UI"
-  [{:keys [fix?]}]
-  (let [actual-dicts (->> (shell {:out :string}
-                                 ;; This currently assumes all ui translations
-                                 ;; use (t and src/main. This can easily be
-                                 ;; tweaked as needed
-                                 "grep -E -oh '\\(tt? :[^ )]+' -r src/main")
-                          :out
-                          string/split-lines
-                          (map #(keyword (subs % 4)))
-                          (concat (mapcat val manual-ui-dicts))
-                          ;; Temporarily unused as they will be brought back soon
-                          (concat [:download])
-                          set)
-        expected-dicts (set (remove #(re-find #"^(command|shortcut)\." (str (namespace %)))
-                                    (keys (:en (get-dicts)))))
-        actual-only (set/difference actual-dicts expected-dicts)
-        expected-only (set/difference expected-dicts actual-dicts)]
-    (if (and (empty? actual-only) (empty? expected-only))
-      (println "All defined :en translation keys match the ones that are used!")
+(def ^:private i18n-lint-launcher-path
+  (fs/absolutize "bin/logseq-i18n-lint"))
+
+(def ^:private i18n-lint-config-path
+  (fs/absolutize ".i18n-lint.toml"))
+
+(defn- ensure-i18n-lint-ready!
+  []
+  (when-not (fs/exists? i18n-lint-launcher-path)
+    (println "logseq-i18n-lint launcher not found at" (str i18n-lint-launcher-path))
+    (System/exit 1))
+  (when-not (fs/exists? i18n-lint-config-path)
+    (println "i18n lint config not found at" (str i18n-lint-config-path))
+    (System/exit 1)))
+
+(defn- run-i18n-lint-command!
+  [subcommand cli-args]
+  (ensure-i18n-lint-ready!)
+  (let [cmd (into ["bash"
+                   (str i18n-lint-launcher-path)
+                   "-c"
+                   (str i18n-lint-config-path)
+                   subcommand]
+                  cli-args)
+        result (apply shell {:continue true
+                             :out :inherit
+                             :err :inherit}
+                      cmd)]
+    (when (pos? (:exit result))
+      (System/exit (:exit result)))))
+
+(defn- check-translation-keys
+  "Use logseq-i18n-lint to detect unused translation keys."
+  [args]
+  (run-i18n-lint-command! "check-keys" args))
+
+(defn- validate-rich-translations
+  "Checks that localized rich translations remain rich zero-arg functions.
+   Missing translations are allowed, but once a locale defines a rich key it
+   must preserve the same renderable contract as English."
+  []
+  (let [invalid-dicts (lang-lint/rich-translation-mismatch-findings (get-dicts))]
+    (if (empty? invalid-dicts)
+      (println "All rich translations preserve English render contracts!")
       (do
-        (when (seq actual-only)
-          (println "\nThese translation keys are invalid because they are used in the UI but not defined:")
-          (task-util/print-table (map #(hash-map :invalid-key %) actual-only)))
-        (when (seq expected-only)
-          (println "\nThese translation keys are invalid because they are not used in the UI:")
-          (task-util/print-table (map #(hash-map :invalid-key %) expected-only))
-          (when fix?
-            (delete-not-used-key-from-dict-file expected-only)
-            (println "These invalid ui keys have been removed.")))
+        (println "These translation keys are invalid because they no longer preserve English rich render contracts:")
+        (task-util/print-table invalid-dicts)
         (System/exit 1)))))
 
-(def allowed-duplicates
-  "Allows certain keys in a language to have the same translation
-   as English. Happens more in romance languages but pretty rare otherwise"
-  {:fr #{:port :type :help/docs :search-item/page :shortcut.category/navigating :text/image
-         :settings-of-plugins :code :shortcut.category/plugins}
-   :de #{:graph :host :plugins :port
-         :settings-of-plugins :shortcut.category/navigating
-         :settings-page/enable-tooltip :settings-page/plugin-system}
-   :ca #{:port :settings-page/tab-editor :settings-page/tab-general}
-   :es #{:settings-page/tab-general :settings-page/tab-editor}
-   :it #{:home :handbook/home :host :help/awesome-logseq
-         :settings-page/tab-account :settings-page/tab-editor}
-   :nl #{:plugins :type :left-side-bar/nav-recent-pages :plugin/update}
-   :pl #{:port :home :host :plugin/marketplace}
-   :pt-BR #{:plugins :right-side-bar/flashcards :settings-page/enable-flashcards :page/backlinks
-            :host :settings-page/tab-editor :shortcut.category/plugins :settings-of-plugins
-            :on-boarding/quick-tour-journal-page-desc-2 :plugin/downloads :plugin/popular
-            :settings-page/plugin-system}
-   :pt-PT #{:plugins :settings-of-plugins :plugin/downloads :right-side-bar/flashcards
-            :settings-page/enable-flashcards :settings-page/plugin-system}
-   :nb-NO #{:port :type :right-side-bar/flashcards :settings-page/enable-flashcards
-            :settings-page/tab-editor :linked-references/filter-heading}
-   :tr #{:help/awesome-logseq}
-   :id #{:host :port}
-   :cs #{:host :port :help/blog :settings-page/tab-editor}})
-
-(defn- validate-languages-dont-have-duplicates
-  "Looks up duplicates for all languages"
+(defn- validate-translation-placeholders
+  "Checks that every localized string uses the same placeholder set as English.
+   Missing translations are allowed because Tongue falls back to :en, but once
+   a locale defines a string it must preserve the placeholder contract."
   []
-  (let [dicts (get-dicts)
-        en-dicts (dicts :en)
-        invalid-dicts
-        (->> (dissoc dicts :en)
-             (mapcat
-              (fn [[lang lang-dicts]]
-                (keep
-                 #(when (= (en-dicts %) (lang-dicts %))
-                    {:translation-key %
-                     :lang lang
-                     :duplicate-value (shorten (lang-dicts %) 70)})
-                 (keys (apply dissoc lang-dicts (allowed-duplicates lang))))))
-             (sort-by (juxt :lang :translation-key)))]
+  (let [invalid-dicts (lang-lint/placeholder-mismatch-findings (get-dicts))]
     (if (empty? invalid-dicts)
-      (println "All languages have no duplicate English values!")
+      (println "All translations preserve English placeholder contracts!")
       (do
-        (println "These translations keys are invalid because they are just copying the English value:")
-        (task-util/print-table invalid-dicts)
+        (println "These translation keys are invalid because their placeholders do not match English:")
+        (task-util/print-table
+         (map #(dissoc % :default-value :localized-value) invalid-dicts))
         (System/exit 1)))))
 
 (defn validate-translations
   "Runs multiple translation validations that fail fast if one of them is invalid"
   [& args]
-  (validate-non-default-languages {:fix? (contains? (set args) "--fix")})
-  (validate-ui-translations-are-used {:fix? (contains? (set args) "--fix")})
-  (validate-languages-dont-have-duplicates))
+  (validate-non-default-languages (contains? (set args) "--fix"))
+  (check-translation-keys args)
+  (validate-rich-translations)
+  (validate-translation-placeholders))
+
+(defn lint-hardcoded
+  "Run logseq-i18n-lint to lint likely hardcoded user-facing strings in UI-oriented source files.
+   Use -w or --warn-only to report findings without failing and -g or --git-changed to scan
+   only files changed in git status."
+  [& args]
+  (run-i18n-lint-command! "lint" args))

+ 149 - 0
scripts/src/logseq/tasks/lang_lint.cljc

@@ -0,0 +1,149 @@
+(ns logseq.tasks.lang-lint)
+
+;; Matches numbered placeholders like `{1}` in translation strings.
+(def ^:private translation-placeholder-pattern
+  #"\{(\d+)\}")
+
+(defn translation-placeholders
+  "Return the placeholder indexes referenced by translation string `value`.
+
+  Non-string values return an empty set."
+  [value]
+  (if (string? value)
+    (->> (re-seq translation-placeholder-pattern value)
+         (map second)
+         set)
+    #{}))
+
+(defn- placeholders-compatible?
+  [default-value localized-value]
+  (or (not (string? default-value))
+      (not (string? localized-value))
+      (= (translation-placeholders default-value)
+         (translation-placeholders localized-value))))
+
+(defn placeholder-mismatch-findings
+  "Return localized string findings whose placeholder set diverges from
+  English."
+  [dicts]
+  (let [en-dicts (:en dicts)]
+    (->> (dissoc dicts :en)
+         (mapcat
+          (fn [[lang lang-dicts]]
+            (keep (fn [[translation-key localized-value]]
+                    (let [default-value (get en-dicts translation-key)]
+                      (when (and (string? default-value)
+                                 (string? localized-value)
+                                 (not (placeholders-compatible? default-value localized-value)))
+                        {:lang lang
+                         :translation-key translation-key
+                         :expected-placeholders (sort (translation-placeholders default-value))
+                         :actual-placeholders (sort (translation-placeholders localized-value))
+                         :default-value default-value
+                         :localized-value localized-value})))
+                  lang-dicts)))
+         (sort-by (juxt :lang :translation-key))
+         vec)))
+
+(defn- rich-translation-value?
+  "Return true when `value` preserves the rich zero-arg translation contract.
+
+  Babashka reads dictionary `fn` forms as lists, while tests may pass actual
+  function values, so both representations are treated as rich translations."
+  [value]
+  (or (fn? value)
+      (and (seq? value)
+           (= 'fn (first value))
+           (= [] (second value)))))
+
+(defn- value-kind
+  [value]
+  (cond
+    (rich-translation-value? value) :fn
+    (string? value) :string
+    (nil? value) :nil
+    :else :other))
+
+(defn rich-translation-mismatch-findings
+  "Return localized rich-translation findings whose value kind no longer
+  matches the English zero-arg function contract."
+  [dicts]
+  (let [en-dicts (:en dicts)]
+    (->> (dissoc dicts :en)
+         (mapcat
+          (fn [[lang lang-dicts]]
+            (keep (fn [[translation-key default-value]]
+                    (let [localized-present? (contains? lang-dicts translation-key)
+                          localized-value (get lang-dicts translation-key)]
+                      (when (and localized-present?
+                                 (rich-translation-value? default-value)
+                                 (not (rich-translation-value? localized-value)))
+                        {:lang lang
+                         :translation-key translation-key
+                         :expected-value-kind :fn
+                         :actual-value-kind (value-kind localized-value)})))
+                  en-dicts)))
+         (sort-by (juxt :lang :translation-key))
+         vec)))
+
+(defn identical-translation-findings
+  "Return localized translation findings whose defined value is identical to
+  English for the same key."
+  [dicts lang]
+  (let [en-dicts (:en dicts)
+        lang-dicts (get dicts lang)]
+    (->> lang-dicts
+         (keep (fn [[translation-key localized-value]]
+                 (let [default-value (get en-dicts translation-key ::missing)]
+                   (when (= default-value localized-value)
+                     {:lang lang
+                      :translation-key translation-key
+                      :default-value default-value}))))
+         (sort-by :translation-key)
+         vec)))
+
+(defn identical-translation-stats
+  "Return per-locale identical-to-English counts for defined translations."
+  [dicts]
+  (let [en-dicts (:en dicts)]
+    (->> dicts
+         (map (fn [[lang lang-dicts]]
+                {:lang lang
+                 :translation-count (count lang-dicts)
+                 :same-as-en-count
+                 (count
+                  (filter (fn [[translation-key localized-value]]
+                            (= (get en-dicts translation-key ::missing)
+                               localized-value))
+                          lang-dicts))}))
+         (sort-by (juxt (comp - :same-as-en-count) (comp - :translation-count) :lang))
+         vec)))
+
+(defn translation-summary-stats
+  "Return per-locale translation summary stats for overview tables."
+  [dicts]
+  (let [en-count (count (:en dicts))
+        same-as-en-counts (->> (identical-translation-stats dicts)
+                               (map (juxt :lang :same-as-en-count))
+                               (into {}))]
+    (->> dicts
+         (map (fn [[lang lang-dicts]]
+                {:lang lang
+                 :translation-count (count lang-dicts)
+                 :untranslated-count (when-not (= lang :en)
+                                       (max 0 (- en-count (count lang-dicts))))
+                 :same-as-en-count (when-not (= lang :en)
+                                     (get same-as-en-counts lang 0))}))
+         vec)))
+
+(defn sort-translation-summary-stats
+  "Sort translation summary stats with English first, then by untranslated
+  count descending, then by identical-to-English count descending."
+  [stats]
+  (let [en-stats (filter #(= :en (:lang %)) stats)
+        other-stats (remove #(= :en (:lang %)) stats)]
+    (into (vec en-stats)
+          (sort-by (juxt (comp - :untranslated-count)
+                         (comp - :same-as-en-count)
+                         :lang)
+                   other-stats))))

+ 0 - 21
scripts/src/logseq/tasks/util.clj

@@ -1,21 +0,0 @@
-(ns logseq.tasks.util
-  "Utils for tasks"
-  (:require [clojure.pprint :as pprint]
-            [babashka.fs :as fs]))
-
-(defn file-modified-later-than?
-  [file comparison-instant]
-  (pos? (.compareTo (fs/file-time->instant (fs/last-modified-time file))
-                    comparison-instant)))
-
-(defn print-usage [arg-str]
-  (println (format
-            "Usage: bb %s %s"
-            (System/getProperty "babashka.task")
-            arg-str))
-  (System/exit 1))
-
-(defn print-table
-  [rows]
-  (pprint/print-table rows)
-  (println "Total:" (count rows)))

+ 123 - 0
scripts/src/logseq/tasks/util.cljc

@@ -0,0 +1,123 @@
+(ns logseq.tasks.util
+  "Utils for tasks"
+  (:require [clojure.string :as string]
+            #?(:clj [babashka.fs :as fs])))
+
+(defn- in-range?
+  [code-point [start end]]
+  (<= start code-point end))
+
+(def ^:private zero-width-ranges
+  [[0x0300 0x036F]
+   [0x1AB0 0x1AFF]
+   [0x1DC0 0x1DFF]
+   [0x200C 0x200F]
+   [0x202A 0x202E]
+   [0x2060 0x206F]
+   [0x20D0 0x20FF]
+   [0xFE00 0xFE0F]
+   [0xFE20 0xFE2F]])
+
+(def ^:private wide-ranges
+  [[0x1100 0x115F]
+   [0x2329 0x232A]
+   [0x2E80 0xA4CF]
+   [0xAC00 0xD7A3]
+   [0xF900 0xFAFF]
+   [0xFE10 0xFE19]
+   [0xFE30 0xFE6F]
+   [0xFF00 0xFF60]
+   [0xFFE0 0xFFE6]
+   [0x1F300 0x1FAFF]
+   [0x20000 0x3FFFD]])
+
+(defn- code-point-width
+  [code-point]
+  (cond
+    (or (<= 0x0000 code-point 0x001F)
+        (<= 0x007F code-point 0x009F)
+        (some #(in-range? code-point %) zero-width-ranges))
+    0
+
+    (some #(in-range? code-point %) wide-ranges)
+    2
+
+    :else
+    1))
+
+(defn display-width
+  [value]
+  (let [text (str value)]
+    (loop [index 0
+           width 0]
+      (if (< index #?(:clj (.length text) :cljs (.-length text)))
+        (let [code-point #?(:clj (.codePointAt text index)
+                            :cljs (.codePointAt text index))]
+          (recur (+ index #?(:clj (Character/charCount code-point)
+                             :cljs (if (> code-point 0xFFFF) 2 1)))
+                 (+ width (code-point-width code-point))))
+        width))))
+
+(defn- pad-left
+  [text width]
+  (let [padding (- width (display-width text))]
+    (str (apply str (repeat (max 0 padding) " ")) text)))
+
+(defn- column-widths
+  [columns rows]
+  (reduce
+   (fn [widths column]
+     (assoc widths
+            column
+            (apply max
+                   (display-width (str column))
+                   (map #(display-width (str (get % column ""))) rows))))
+   {}
+   columns))
+
+(defn- render-separator
+  [columns widths]
+  (str "|" (string/join "+" (map #(apply str (repeat (+ 2 (get widths %)) "-")) columns)) "|"))
+
+(defn- render-row
+  [columns widths row]
+  (str "|"
+       (string/join "|" (map #(str " "
+                                   (pad-left (str (get row % "")) (get widths %))
+                                   " ")
+                             columns))
+       "|"))
+
+(defn render-table
+  [rows]
+  (when-let [columns (seq (keys (first rows)))]
+    (let [rows (vec rows)
+          widths (column-widths columns rows)
+          header-row (zipmap columns columns)]
+      (str (string/join
+            "\n"
+            (concat [(render-row columns widths header-row)
+                     (render-separator columns widths)]
+                    (map #(render-row columns widths %) rows)))
+           "\n"))))
+
+#?(:clj
+   (defn file-modified-later-than?
+     [file comparison-instant]
+     (pos? (.compareTo (fs/file-time->instant (fs/last-modified-time file))
+                       comparison-instant))))
+
+(defn print-usage [arg-str]
+  (println (str "Usage: bb "
+                #?(:clj (System/getProperty "babashka.task")
+                   :cljs "task")
+                " "
+                arg-str))
+  #?(:clj (System/exit 1)
+     :cljs (js/process.exit 1)))
+
+(defn print-table
+  [rows]
+  (when-some [rendered-table (render-table rows)]
+    (print rendered-table))
+  (println "Total:" (count rows)))

+ 2 - 2
scripts/test/logseq/tasks/db_graph/create_graph_with_large_sizes_test.cljs

@@ -15,8 +15,8 @@
            (map (comp :block/title :page) batch)))
     (is (= ["id-0" "id-4"]
            (map (comp :block/uuid :page) batch)))
-    (is (= [["Block" "Block" "Block"]
-            ["Block" "Block" "Block"]]
+    (is (= [[(#'sut/build-block-title 10 0) (#'sut/build-block-title 10 1) (#'sut/build-block-title 10 2)]
+            [(#'sut/build-block-title 11 0) (#'sut/build-block-title 11 1) (#'sut/build-block-title 11 2)]]
            (map (fn [{:keys [blocks]}]
                   (mapv :block/title blocks))
                 batch)))

+ 127 - 0
scripts/test/logseq/tasks/lang_test.cljs

@@ -0,0 +1,127 @@
+(ns logseq.tasks.lang-test
+  (:require [cljs.test :refer [deftest is testing]]
+            [logseq.tasks.lang-lint :as lang-lint]))
+
+(deftest translation-placeholders-detect-placeholder-sets
+  (is (= #{"1" "2"}
+         (lang-lint/translation-placeholders "Open {1} from {2}")))
+  (is (= #{}
+         (lang-lint/translation-placeholders "Search with Google"))))
+
+(deftest placeholder-mismatch-findings-detect-non-default-locale-errors
+  (testing "a localized value must match English placeholders exactly once it is defined"
+    (let [findings (lang-lint/placeholder-mismatch-findings
+                    {:en {:electron/link-open-confirm "Are you sure?\n\n{1}"
+                          :electron/write-file-failed-with-backup "Write failed {1} {2} {3}."}
+                     :zh-Hant {:electron/link-open-confirm "確定要開啟此外部連結嗎?"
+                               :electron/write-file-failed-with-backup "寫入失敗。備份檔案:{1}"}
+                     :zh-CN {:electron/link-open-confirm "确定要打开此链接吗?\n\n{1}"
+                             :electron/write-file-failed-with-backup "写入文件 {1} 失败,{2}。备份文件已保存到 {3}。"}})]
+      (is (= [{:lang :zh-Hant
+               :translation-key :electron/link-open-confirm
+               :expected-placeholders ["1"]
+               :actual-placeholders []
+               :default-value "Are you sure?\n\n{1}"
+               :localized-value "確定要開啟此外部連結嗎?"}
+              {:lang :zh-Hant
+               :translation-key :electron/write-file-failed-with-backup
+               :expected-placeholders ["1" "2" "3"]
+               :actual-placeholders ["1"]
+               :default-value "Write failed {1} {2} {3}."
+               :localized-value "寫入失敗。備份檔案:{1}"}]
+             findings)))))
+
+(deftest translation-rich-validation-findings-report-rich-contract-mismatches
+  (testing "a localized rich translation must remain a zero-arg function once defined"
+    (is (= [{:lang :zh-Hant
+             :translation-key :e2ee/cloud-password-rich
+             :expected-value-kind :fn
+             :actual-value-kind :string}
+            {:lang :zh-Hant
+             :translation-key :on-boarding/main-title
+             :expected-value-kind :fn
+             :actual-value-kind :string}]
+           (lang-lint/rich-translation-mismatch-findings
+            {:en {:on-boarding/main-title (fn [] ["Welcome to " [:strong "Logseq!"]])
+                  :e2ee/cloud-password-rich (fn [] ["Cloud sentence " [:span "Local sentence"]])
+                  :e2ee/remember-password-rich (fn [] [[:span "Remember "] "your password."])}
+             :zh-Hant {:on-boarding/main-title "歡迎使用 Logseq"
+                       :e2ee/cloud-password-rich "雲端密碼"
+                       :e2ee/remember-password-rich (fn [] [[:span "請記住"] "你的密碼。"])}})))))
+
+(deftest identical-translation-findings-report-defined-values-equal-to-english
+  (is (= [{:lang :ko
+           :translation-key :ui/cancel
+           :default-value "Cancel"}
+          {:lang :ko
+           :translation-key :ui/save
+           :default-value "Save"}]
+         (lang-lint/identical-translation-findings
+          {:en {:ui/cancel "Cancel"
+                :ui/save "Save"
+                :ui/close "Close"}
+           :ko {:ui/cancel "Cancel"
+                :ui/save "Save"
+                :ui/close "닫기"}
+           :fr {:ui/cancel "Annuler"}}
+          :ko))))
+
+(deftest identical-translation-stats-count-defined-values-equal-to-english
+  (is (= [{:lang :en
+           :translation-count 2
+           :same-as-en-count 2}
+          {:lang :ko
+           :translation-count 2
+           :same-as-en-count 1}
+          {:lang :fr
+           :translation-count 1
+           :same-as-en-count 0}]
+         (lang-lint/identical-translation-stats
+          {:en {:ui/cancel "Cancel"
+                :ui/save "Save"}
+           :ko {:ui/cancel "Cancel"
+                :ui/save "저장"}
+           :fr {:ui/cancel "Annuler"}}))))
+
+(deftest translation-summary-stats-report-untranslated-and-same-as-en-count
+  (is (= [{:lang :en
+           :translation-count 4
+           :untranslated-count nil
+           :same-as-en-count nil}
+          {:lang :fr
+           :translation-count 2
+           :untranslated-count 2
+           :same-as-en-count 1}
+          {:lang :ko
+           :translation-count 3
+           :untranslated-count 1
+           :same-as-en-count 2}]
+         (->> (lang-lint/translation-summary-stats
+               {:en {:ui/cancel "Cancel"
+                     :ui/save "Save"
+                     :ui/close "Close"
+                     :ui/delete "Delete"}
+                :ko {:ui/cancel "Cancel"
+                     :ui/save "저장"
+                     :ui/close "Close"}
+                :fr {:ui/cancel "Annuler"
+                     :ui/save "Save"}})
+              (sort-by :lang)
+              vec))))
+
+(deftest sort-translation-summary-stats-keeps-en-first-then-sorts-by-untranslated-and-same-as-en-count
+  (is (= [:en :ko :fr :zh-Hant]
+         (->> [{:lang :fr
+                :untranslated-count 3
+                :same-as-en-count 1}
+               {:lang :en
+                :untranslated-count nil
+                :same-as-en-count nil}
+               {:lang :zh-Hant
+                :untranslated-count 1
+                :same-as-en-count 3}
+               {:lang :ko
+                :untranslated-count 3
+                :same-as-en-count 2}]
+              lang-lint/sort-translation-summary-stats
+              (mapv :lang)))))

+ 6 - 2
scripts/test/logseq/tasks/test_runner.cljs

@@ -1,11 +1,15 @@
 (ns logseq.tasks.test-runner
   (:require [cljs.test :as test]
             [logseq.tasks.db-graph.create-graph-with-clojure-irc-history-test]
-            [logseq.tasks.db-graph.create-graph-with-large-sizes-test]))
+            [logseq.tasks.db-graph.create-graph-with-large-sizes-test]
+            [logseq.tasks.lang-test]
+            [logseq.tasks.util-test]))
 
 (defn -main [& _]
   (let [{:keys [fail error]}
         (test/run-tests 'logseq.tasks.db-graph.create-graph-with-large-sizes-test
-                        'logseq.tasks.db-graph.create-graph-with-clojure-irc-history-test)]
+                        'logseq.tasks.db-graph.create-graph-with-clojure-irc-history-test
+                        'logseq.tasks.lang-test
+                        'logseq.tasks.util-test)]
     (when (pos? (+ fail error))
       (js/process.exit 1))))

+ 19 - 0
scripts/test/logseq/tasks/util_test.cljs

@@ -0,0 +1,19 @@
+(ns logseq.tasks.util-test
+  (:require [cljs.test :refer [deftest is]]
+            [logseq.tasks.util :as util]))
+
+(deftest display-width-handles-wide-and-combining-characters
+  (is (= 5 (util/display-width "hello")))
+  (is (= 4 (util/display-width "中文")))
+  (is (= 4 (util/display-width "한국")))
+  (is (= 1 (util/display-width "e\u0301"))))
+
+(deftest render-table-right-aligns-columns-using-display-width
+  (is (= (str "| :locale | :language |\n"
+              "|---------+-----------|\n"
+              "|     :en |   English |\n"
+              "|     :ja |    日本語 |\n"
+              "|     :ko |      한국 |\n")
+         (util/render-table [{:locale :en :language "English"}
+                             {:locale :ja :language "日本語"}
+                             {:locale :ko :language "한국"}]))))

+ 14 - 14
src/electron/electron/context_menu.cljs

@@ -1,5 +1,6 @@
 (ns electron.context-menu
-  (:require [electron.utils :as utils]
+  (:require [electron.i18n :refer [t]]
+            [electron.utils :as utils]
             ["electron" :refer [Menu MenuItem shell nativeImage clipboard] :as electron]
             ["electron-dl" :refer [download]]))
 
@@ -27,19 +28,18 @@
                                       #(. web-contents replaceMisspelling suggestion)}))))
             (when-let [misspelled-word (not-empty (.-misspelledWord params))]
               (. menu append
-                 (MenuItem. (clj->js {:label
-                                      "Add to dictionary"
+                 (MenuItem. (clj->js {:label (t :electron/add-to-dictionary)
                                       :click
                                       #(.. web-contents -session (addWordToSpellCheckerDictionary misspelled-word))})))
               (. menu append (MenuItem. #js {:type "separator"})))
 
             (when (and utils/mac? has-text? (not link-url))
               (. menu append
-                 (MenuItem. #js {:label (str "Look Up “" selection-text "”")
+                 (MenuItem. #js {:label (t :electron/look-up)
                                  :click #(. web-contents showDefinitionForSelection)})))
-            (when has-text?
-              (. menu append
-                 (MenuItem. #js {:label "Search with Google"
+             (when has-text?
+               (. menu append
+                 (MenuItem. #js {:label (t :electron/search-with-google)
                                  :click #(let [url (js/URL. "https://www.google.com/search")]
                                            (.. url -searchParams (set "q" selection-text))
                                            (.. shell (openExternal (.toString url))))}))
@@ -48,26 +48,26 @@
             (when editable?
               (when has-text?
                 (. menu append
-                   (MenuItem. #js {:label "Cut"
+                   (MenuItem. #js {:label (t :editor/cut)
                                    :enabled (.-canCut edit-flags)
                                    :role "cut"}))
                 (. menu append
-                   (MenuItem. #js {:label "Copy"
+                   (MenuItem. #js {:label (t :ui/copy)
                                    :enabled (.-canCopy edit-flags)
                                    :role "copy"})))
 
               (. menu append
-                 (MenuItem. #js {:label "Paste"
+                 (MenuItem. #js {:label (t :editor/paste)
                                  :enabled (.-canPaste edit-flags)
                                  :role "paste"}))
               (. menu append
-                 (MenuItem. #js {:label "Select All"
+                 (MenuItem. #js {:label (t :view.table/select-all)
                                  :enabled (.-canSelectAll edit-flags)
                                  :role "selectAll"})))
 
             (when (= media-type "image")
               (. menu append
-                 (MenuItem. #js {:label "Save Image"
+                 (MenuItem. #js {:label (t :electron/save-image)
                                  :click (fn [menu-item]
                                           (let [url (.-srcURL params)
                                                 url (if (.-transform menu-item)
@@ -76,7 +76,7 @@
                                             (download win url)))}))
 
               (. menu append
-                 (MenuItem. #js {:label "Save Image As..."
+                 (MenuItem. #js {:label (t :electron/save-image-as)
                                  :click (fn [menu-item]
                                           (let [url (.-srcURL params)
                                                 url (if (.-transform menu-item)
@@ -85,7 +85,7 @@
                                             (download win url #js {:saveAs true})))}))
 
               (. menu append
-                 (MenuItem. #js {:label "Copy Image"
+                 (MenuItem. #js {:label (t :electron/copy-image)
                                  :click (fn []
                                           (. clipboard writeImage (. nativeImage createFromPath (subs (.-srcURL params) 7))))})))
 

+ 7 - 5
src/electron/electron/core.cljs

@@ -9,6 +9,7 @@
             [electron.db :as db]
             [electron.exceptions :as exceptions]
             [electron.handler :as handler]
+            [electron.i18n :as i18n :refer [t]]
             [electron.logger :as logger]
             [electron.server :as server]
             [electron.updater :refer [init-updater] :as updater]
@@ -173,7 +174,7 @@
   (let [about-fn (fn []
                    (.showMessageBox dialog (clj->js {:title "Logseq"
                                                      :icon (node-path/join js/__dirname "icons/logseq.png")
-                                                     :message (str "Version " updater/electron-version)})))
+                                                     :message (t :electron/version updater/electron-version)})))
         template (if mac?
                    [{:label (.-name app)
                      :submenu [{:role "about"}
@@ -188,7 +189,7 @@
                    [])
         template (conj template
                        {:role "fileMenu"
-                        :submenu [{:label "New Window"
+                        :submenu [{:label (t :electron/new-window)
                                    :click (fn [] (handler/open-new-window! nil))
                                    :accelerator (if mac?
                                                   "CommandOrControl+N"
@@ -211,13 +212,13 @@
         template (conj template
                        (if mac?
                          {:role "help"
-                          :submenu [{:label "Official Documentation"
+                          :submenu [{:label (t :electron/official-docs)
                                      :click #(.openExternal shell "https://docs.logseq.com/")}]}
                          {:role "help"
-                          :submenu [{:label "Official Documentation"
+                          :submenu [{:label (t :electron/official-docs)
                                      :click #(.openExternal shell "https://docs.logseq.com/")}
                                     {:role "about"
-                                     :label "About Logseq"
+                                     :label (t :electron/about)
                                      :click about-fn}]}))
         ;; Enable Cmd/Ctrl+= Zoom In
         template (conj template
@@ -334,6 +335,7 @@
 
       (register-default-protocol-client! app)
       (set-app-menu!)
+      (i18n/on-locale-change! set-app-menu!)
       (setup-deeplink!)
 
       (.on app "second-instance"

+ 6 - 9
src/electron/electron/exceptions.cljs

@@ -1,21 +1,18 @@
 (ns electron.exceptions
   (:require [electron.logger :as logger]
-            [electron.utils :as utils]
-            [clojure.string :as string]))
+            [electron.utils :as utils]))
 
 (defonce uncaughtExceptionChan "uncaughtException")
 
-(defn show-error-tip
-  [& msg]
-  (utils/send-to-renderer "notification"
-                          {:type    "error"
-                           :payload (string/join "\n" msg)}))
-
 (defn- app-uncaught-handler
   [^js e]
   (let [msg (.-message e)
         stack (.-stack e)]
-    (show-error-tip "[Main Exception]" msg stack))
+    (utils/send-to-renderer "notification"
+                            {:type      "error"
+                             :payload   (str "[Main Exception]\n" msg "\n" stack)
+                             :i18n-key  :electron/main-exception
+                             :i18n-args [msg stack]}))
 
   ;; for debug log
   (logger/error uncaughtExceptionChan (str e)))

+ 20 - 12
src/electron/electron/handler.cljs

@@ -19,6 +19,7 @@
             [electron.db :as db]
             [electron.find-in-page :as find]
             [electron.handler-interface :refer [handle]]
+            [electron.i18n :as i18n]
             [electron.keychain :as keychain]
             [electron.logger :as logger]
             [electron.plugin :as plugin]
@@ -121,15 +122,16 @@
                             (backup-file/backup-file repo :backup-dir path (node-path/extname path) content)
                             (catch :default e
                               (logger/error ::write-file "backup file failed:" e)))]
-          (utils/send-to-renderer window "notification" {:type "error"
-                                                         :payload (str "Write to the file " path
-                                                                       " failed, "
-                                                                       e
-                                                                       (when backup-path
-                                                                         (str ". A backup file was saved to "
-                                                                              backup-path
-                                                                              ".")))}))))))
-
+          (utils/send-to-renderer window "notification"
+                                 (if backup-path
+                                   {:type "error"
+                                    :payload (str "Write to the file " path " failed, " e ". A backup file was saved to " backup-path ".")
+                                    :i18n-key :electron/write-file-error-with-backup
+                                    :i18n-args [path e backup-path]}
+                                   {:type "error"
+                                    :payload (str "Write to the file " path " failed, " e)
+                                    :i18n-key :electron/write-file-error
+                                    :i18n-args [path e]})))))))
 (defmethod handle :rename [_window [_ old-path new-path]]
   (logger/info ::rename "from" old-path "to" new-path)
   (fs/renameSync old-path new-path))
@@ -181,9 +183,12 @@
                                 :files (get-files path)}))
         (catch js/Error e
           (do
-            (utils/send-to-renderer window "notification" {:type "error"
-                                                           :payload (str "Opening the specified directory failed.\n"
-                                                                         (or (pretty-print-js-error e) (str "Unexpected error: " e)))})
+            (utils/send-to-renderer window "notification"
+                                   {:type "error"
+                                    :payload (str "Opening the specified directory failed.\n"
+                                                  (or (pretty-print-js-error e) (str "Unexpected error: " e)))
+                                    :i18n-key :electron/open-dir-error
+                                    :i18n-args [(or (pretty-print-js-error e) (str "Unexpected error: " e))]})
             (p/rejected e))))
 
       (p/rejected (js/Error "path empty")))))
@@ -313,6 +318,9 @@
   (when graph-name
     (set-current-graph! window (utils/get-graph-dir graph-name))))
 
+(defmethod handle :updateElectronLocale [_window [_ locale]]
+  (i18n/update-locale! locale))
+
 (defmethod handle :runCli [window [_ {:keys [command args returnResult]}]]
   (try
     (let [on-data-handler (fn [message]

+ 40 - 0
src/electron/electron/i18n.cljs

@@ -0,0 +1,40 @@
+(ns electron.i18n
+  "I18n support for the Electron main process.
+
+  The renderer only syncs the active locale. The main process loads dictionary
+  resources locally so it can translate with the same Tongue fallback behavior
+  as the renderer without shipping non-serializable translation values over
+  IPC."
+  (:require [frontend.dicts :as dicts]
+            [lambdaisland.glogi :as log]
+            [tongue.core :as tongue]))
+
+(def ^:private translate
+  (tongue/build-translate (assoc dicts/dicts :tongue/fallback :en)))
+
+(defonce ^:private *locale (atom :en))
+(defonce ^:private *on-locale-change (atom nil))
+
+(defn on-locale-change!
+  "Register a callback to be invoked when translations are updated"
+  [f]
+  (reset! *on-locale-change f))
+
+(defn update-locale!
+  "Update the active locale from the frontend renderer."
+  [language]
+  (reset! *locale (or (some-> language keyword) :en))
+  (when-let [f @*on-locale-change]
+    (f)))
+
+(defn t
+  "Translate `k` in the current Electron locale using Tongue fallback rules."
+  [& args]
+  (try
+    (apply translate @*locale args)
+    (catch :default e
+      (log/error :failed-translation {:error e
+                                      :arguments args
+                                      :lang @*locale})
+      (when (not= @*locale :en)
+        (apply translate :en args)))))

+ 2 - 2
src/electron/electron/plugin.cljs

@@ -57,7 +57,7 @@
               endpoint     (api url-suffix)
               ^js res      (fetch endpoint {:timeout (* 1000 5)})
               illegal-text (when-not (= 200 (.-status res)) (.text res))
-              _            (when-not (string/blank? illegal-text) (throw (js/Error. (str "Github API Failed(" (.-status res) ") " illegal-text))))
+              _            (when-not (string/blank? illegal-text) (throw (js/Error. (str "GitHub API Failed(" (.-status res) ") " illegal-text))))
               _            (debug "Release latest:" endpoint ":status" (.-status res))
               res          (response-transform res)
               res          (.json res)
@@ -184,7 +184,7 @@
   includes the following keys:
 * :only-check - When set to true, this only fetches the latest version without installing
 * :plugin-action - When set to 'install', installs the specific :version given
-* :repo - A Github repo, not a logseq repo, e.g. user/repo"
+* :repo - A GitHub repo, not a logseq repo, e.g. user/repo"
   [{:keys [version repo only-check plugin-action] :as item}]
   (if repo
     (let [action          (keyword plugin-action)

+ 11 - 4
src/electron/electron/url.cljs

@@ -25,9 +25,12 @@
   [graph-identifier]
   (if (not-empty graph-identifier)
     (send-to-renderer "notification" {:type "error"
-                                      :payload (str "Failed to open link. Cannot match graph identifier `" graph-identifier "` to any linked graph.")})
+                                      :payload (str "Failed to open link. Cannot match graph identifier `" graph-identifier "` to any linked graph.")
+                                      :i18n-key :electron/link-open-failed-no-graph
+                                      :i18n-args [graph-identifier]})
     (send-to-renderer "notification" {:type "error"
-                                      :payload "Failed to open link. Missing graph identifier after `logseq://graph/`."})))
+                                      :payload "Failed to open link. Missing graph identifier after `logseq://graph/`."
+                                      :i18n-key :electron/link-open-failed-missing-graph})))
 
 (defn local-url-handler
   "Given a URL with `graph identifier` as path, `page` (optional) and `block-id`
@@ -86,7 +89,9 @@
       (send-to-focused-renderer "notification" {:type "error"
                                                 :payload (str "Unimplemented x-callback-url action: `"
                                                               action
-                                                              "`.")} win))))
+                                                              "`.")
+                                                :i18n-key :electron/unimplemented-callback
+                                                :i18n-args [action]} win))))
 
 (defn logseq-url-handler
   "win - the main window"
@@ -112,4 +117,6 @@
       (send-to-renderer :notification
                         {:type    "error"
                          :payload (str "Failed to open link. Cannot match `" url-host
-                                       "` to any target.")}))))
+                                       "` to any target.")
+                         :i18n-key :electron/link-open-failed-no-target
+                         :i18n-args [url-host]}))))

+ 3 - 2
src/electron/electron/window.cljs

@@ -7,6 +7,7 @@
             [clojure.string :as string]
             [electron.configs :as cfgs]
             [electron.context-menu :as context-menu]
+            [electron.i18n :refer [t]]
             [electron.logger :as logger]
             [electron.state :as state]
             [electron.utils :refer [mac? win32? linux? dev? open] :as utils]))
@@ -126,10 +127,10 @@
         (when-let [^js res (and (fn? default-open)
                                 (.showMessageBoxSync dialog
                                                      #js {:type "warning"
-                                                          :message (str "Are you sure you want to open this link? \n\n" url)
+                                                          :message (t :electron/link-open-confirm url)
                                                           :defaultId 1
                                                           :cancelId 0
-                                                          :buttons #js ["Cancel" "OK"]}))]
+                                                          :buttons #js [(t :electron/cancel) (t :electron/ok)]}))]
           (when (= res 1)
             (default-open url)))))))
 

+ 11 - 4
src/main/electron/listener.cljs

@@ -5,6 +5,8 @@
             [clojure.string :as string]
             [dommy.core :as dom]
             [electron.ipc :as ipc]
+            [electron.locale :as electron-locale]
+            [frontend.context.i18n :as i18n]
             [frontend.db :as db]
             [frontend.db.async :as db-async]
             [frontend.handler.notification :as notification]
@@ -26,9 +28,13 @@
   []
   (safe-api-call "notification"
                  (fn [data]
-                   (let [{:keys [type payload]} (bean/->clj data)
+                   (let [{:keys [type payload i18n-key i18n-args]} (bean/->clj data)
                          type (keyword type)
-                         comp [:div (str payload)]]
+                         i18n-key (when i18n-key (keyword i18n-key))
+                         content (if i18n-key
+                                   (apply i18n/t i18n-key i18n-args)
+                                   (str payload))
+                         comp [:div content]]
                      (notification/show! comp type false))))
 
   (safe-api-call "rebuildSearchIndice"
@@ -67,7 +73,7 @@
                        (p/let [block (db-async/<get-block (state/get-current-repo) block-id {:children? false})]
                          (if block
                            (route-handler/redirect-to-page! block-id)
-                           (notification/show! (str "Open link failed. Block-id `" block-id "` doesn't exist in the graph.") :error false)))))))
+                           (notification/show! (i18n/t :electron/block-not-exist block-id) :error false)))))))
 
   (safe-api-call "foundInPage"
                  (fn [data]
@@ -133,4 +139,5 @@
 
 (defn listen!
   []
-  (listen-to-electron!))
+  (listen-to-electron!)
+  (electron-locale/push-locale! (state/sub :preferred-language)))

+ 7 - 0
src/main/electron/locale.cljs

@@ -0,0 +1,7 @@
+(ns electron.locale
+  "Electron locale synchronization helpers."
+  (:require [electron.ipc :as ipc]))
+
+(defn push-locale!
+  [language]
+  (ipc/ipc :updateElectronLocale (or (some-> language keyword) :en)))

+ 102 - 86
src/main/frontend/commands.cljs

@@ -1,6 +1,7 @@
 (ns frontend.commands
   "Provides functionality for commands and advanced commands"
   (:require [clojure.string :as string]
+            [frontend.context.i18n :refer [interpolate-sentence t]]
             [frontend.date :as date]
             [frontend.db :as db]
             [frontend.extensions.video.youtube :as youtube]
@@ -17,6 +18,7 @@
             [logseq.common.util :as common-util]
             [logseq.common.util.block-ref :as block-ref]
             [logseq.common.util.page-ref :as page-ref]
+            [logseq.db.frontend.property :as db-property]
             [logseq.graph-parser.property :as gp-property]
             [promesa.core :as p]))
 
@@ -27,9 +29,10 @@
 (defonce command-ask "\\")
 (defonce *current-command (atom nil))
 
-(def query-doc
+(defn query-doc
+  []
   [:div {:on-pointer-down (fn [e] (.stopPropagation e))}
-   [:div.font-medium.text-lg.mb-2 "Query examples:"]
+   [:div.font-medium.text-lg.mb-2 (t :query/examples-title)]
    [:ul.mb-1
     [:li.mb-1 [:code "{{query #tag}}"]]
     [:li.mb-1 [:code "{{query [[page]]}}"]]
@@ -39,38 +42,43 @@
     [:li.mb-1 [:code "{{query (and (between -7d +7d) (task Done))}}"]]
     [:li.mb-1 [:code "{{query (property key value)}}"]]
     [:li.mb-1 [:code "{{query (tags #tag)}}"]]]
-
-   [:p "Check more examples at "
-    [:a {:href "https://docs.logseq.com/#/page/queries"
-         :target "_blank"}
-     "Queries documentation"]
-    "."]])
+   [:p
+    (interpolate-sentence
+     (t :query/examples-desc)
+     :links [{:href "https://docs.logseq.com/#/page/queries"
+              :target "_blank"}])]])
 
 (defn link-steps []
   [[:editor/input (str command-trigger "link")]
    [:editor/show-input [{:command :link
                          :id :link
-                         :placeholder "Link"
+                         :placeholder (t :ui/link)
                          :autoFocus true}
                         {:command :link
                          :id :label
-                         :placeholder "Label"}]]])
+                         :placeholder (t :ui/label)}]]])
 
 (defn image-link-steps []
   [[:editor/input (str command-trigger "link")]
    [:editor/show-input [{:command :image-link
                          :id :link
-                         :placeholder "Link"
+                         :placeholder (t :ui/link)
                          :autoFocus true}
                         {:command :image-link
                          :id :label
-                         :placeholder "Label"}]]])
+                         :placeholder (t :ui/label)}]]])
 
 (def *extend-slash-commands (atom []))
 
 (defn register-slash-command [cmd]
   (swap! *extend-slash-commands conj cmd))
 
+(defn- resolve-slash-command
+  [command]
+  (if (fn? command)
+    (command)
+    command))
+
 (defn ->marker
   [marker]
   [[:editor/clear-current-slash]
@@ -92,8 +100,7 @@
 
 (defn db-based-statuses
   []
-  (map (fn [e] (:block/title e))
-       (db-pu/get-closed-property-values :logseq.property/status)))
+  (db-pu/get-closed-property-values :logseq.property/status))
 
 (defn db-based-embed-block
   []
@@ -142,38 +149,41 @@
 
 (defn get-statuses
   []
-  (let [result (->>
+  (let [group-label (t :editor.slash/group-task-status)
+        result (->>
                 (db-based-statuses)
-                (mapv (fn [command]
-                        (let [icon (case command
+                (mapv (fn [status]
+                        (let [command (:block/title status)
+                              label (db-property/built-in-display-title status t)
+                              icon (case command
                                      "Canceled" "Cancelled"
                                      "Doing" "InProgress50"
                                      command)]
-                          [command (->marker command) (str "Set status to " command) icon]))))]
+                          [label (->marker command) (t :editor.slash/status-desc label) icon]))))]
     (when (seq result)
-      (map (fn [v] (conj v "TASK STATUS")) result))))
+      (map (fn [v] (conj v group-label)) result))))
 
 (defn db-based-priorities
   []
-  (map (fn [e] (str "Priority " (:block/title e)))
-       (db-pu/get-closed-property-values :logseq.property/priority)))
+  (db-pu/get-closed-property-values :logseq.property/priority))
 
 (defn get-priorities
   []
-  (let [with-no-priority #(cons ["No priority" (->priority nil) "" :icon/priorityLvlNone] %)
+  (let [group-label (t :editor.slash/group-priority)
+        with-no-priority #(cons [(t :editor.slash/no-priority) (->priority nil) "" :icon/priorityLvlNone] %)
         result (->>
                 (db-based-priorities)
-                (mapv (fn [item]
-                        (let [command item
-                              item (string/replace item #"^Priority " "")]
-                          [command
-                           (->priority item)
-                           (str "Set priority to " item)
-                           (str "priorityLvl" item)])))
+                (mapv (fn [priority]
+                        (let [value (:block/title priority)
+                              label (db-property/built-in-display-title priority t)]
+                          [(t :editor.slash/priority-label label)
+                           (->priority value)
+                           (t :editor.slash/priority-desc label)
+                           (str "priorityLvl" value)])))
                 (with-no-priority)
                 (vec))]
     (when (seq result)
-      (map (fn [v] (into v ["PRIORITY"])) result))))
+      (map (fn [v] (into v [group-label])) result))))
 
 ;; Credits to roamresearch.com
 
@@ -185,10 +195,15 @@
 
 (defn- headings
   []
-  (into [["Normal text" (->heading nil) "Clear heading and set to normal text" :icon/text "Heading"]]
+  (into [[(t :editor.slash/normal-text)
+          (->heading nil)
+          (t :editor.slash/normal-text-desc)
+          :icon/text
+          (t :editor.slash/group-heading)]]
         (mapv (fn [level]
-                (let [heading (str "Heading " level)]
-                  [heading (->heading level) heading (str "h-" level) "Heading"])) (range 1 7))))
+                (let [heading (t :editor.slash/heading-label level)]
+                  [heading (->heading level) heading (str "h-" level) (t :editor.slash/group-heading)]))
+              (range 1 7))))
 
 (defonce *latest-matched-command (atom ""))
 (defonce *matched-commands (atom nil))
@@ -200,36 +215,36 @@
     (->>
      (concat
         ;; basic
-      [["Node reference"
+      [[(t :editor.slash/node-reference)
         [[:editor/input page-ref/left-and-right-brackets {:backward-pos 2}]
          [:editor/search-page]]
-        "Create a backlink to a node (a page or a block)"
+        (t :editor.slash/node-reference-desc)
         :icon/pageRef
-        "BASIC"]
-       ["Node embed"
+        (t :editor.slash/group-basic)]
+       [(t :editor.slash/node-embed)
         (embed-block)
-        "Embed a node here"
+        (t :editor.slash/node-embed-desc)
         :icon/blockEmbed]]
 
         ;; format
-      [["Link" (link-steps) "Create a HTTP link" :icon/link "FORMAT"]
-       ["Image link" (image-link-steps) "Create a HTTP link to a image" :icon/photoLink]
+      [[(t :ui/link) (link-steps) (t :editor.slash/link-desc) :icon/link (t :editor.slash/group-format)]
+       [(t :editor.slash/image-link) (image-link-steps) (t :editor.slash/image-link-desc) :icon/photoLink]
        (when (state/markdown?)
-         ["Underline" [[:editor/input "<ins></ins>"
-                        {:last-pattern command-trigger
-                         :backward-pos 6}]] "Create a underline text decoration"
+         [(t :editor.slash/underline) [[:editor/input "<ins></ins>"
+                                        {:last-pattern command-trigger
+                                         :backward-pos 6}]] (t :editor.slash/underline-desc)
           :icon/underline])
-       ["Code block"
+       [(t :editor.slash/code-block)
         (code-block-steps)
-        "Insert code block"
+        (t :editor.slash/code-block-desc)
         :icon/code]
-       ["Quote"
+       [(t :class.built-in/quote-block)
         (quote-block-steps)
-        "Create a quote block"
+        (t :editor.slash/quote-desc)
         :icon/quote]
-       ["Math block"
+       [(t :editor.slash/math-block)
         (math-block-steps)
-        "Create a latex block"
+        (t :editor.slash/math-block-desc)
         :icon/math]]
 
       (headings)
@@ -238,79 +253,80 @@
       (get-statuses)
 
       ;; task date
-      [["Deadline"
+      [[(t :property.built-in/deadline)
         [[:editor/clear-current-slash]
          [:editor/set-deadline]]
         ""
         :icon/calendar-stats
-        "TASK DATE"]
-       ["Scheduled"
+        (t :editor.slash/group-task-date)]
+       [(t :property.built-in/scheduled)
         [[:editor/clear-current-slash]
          [:editor/set-scheduled]]
         ""
         :icon/calendar-month
-        "TASK DATE"]]
+        (t :editor.slash/group-task-date)]]
 
       ;; priority
       (get-priorities)
 
       ;; time & date
-      [["Tomorrow"
-        #(get-page-ref-text (date/tomorrow))
-        "Insert the date of tomorrow"
+      [[(t :date.nlp/tomorrow)
+        #(get-page-ref-text (db/get-journal-page-title (date/tomorrow)))
+        (t :editor.slash/tomorrow-desc)
         :icon/tomorrow
-        "TIME & DATE"]
-       ["Yesterday" #(get-page-ref-text (date/yesterday)) "Insert the date of yesterday" :icon/yesterday]
-       ["Today" #(get-page-ref-text (date/today)) "Insert the date of today" :icon/calendar]
-       ["Current time" #(date/get-current-time) "Insert current time" :icon/clock]
-       ["Date picker" [[:editor/show-date-picker]] "Pick a date and insert here" :icon/calendar-dots]]
+        (t :editor.slash/group-time-and-date)]
+       [(t :date.nlp/yesterday) #(get-page-ref-text (db/get-journal-page-title (date/yesterday))) (t :editor.slash/yesterday-desc) :icon/yesterday]
+       [(t :date.nlp/today) #(get-page-ref-text (db/get-today-journal-title)) (t :editor.slash/today-desc) :icon/calendar]
+       [(t :editor.slash/current-time) #(date/get-current-time) (t :editor.slash/current-time-desc) :icon/clock]
+       [(t :editor.slash/date-picker) [[:editor/show-date-picker]] (t :editor.slash/date-picker-desc) :icon/calendar-dots]]
 
       ;; order list
-      [["Number list"
+      [[(t :editor.slash/number-list)
         [[:editor/clear-current-slash]
          [:editor/toggle-own-number-list]]
-        "Number list"
+        (t :editor.slash/number-list)
         :icon/numberedParents
-        "LIST TYPE"]
-       ["Number children" [[:editor/clear-current-slash]
-                           [:editor/toggle-children-number-list]]
-        "Number children"
+        (t :editor.slash/group-list-type)]
+       [(t :editor.slash/number-children) [[:editor/clear-current-slash]
+                                           [:editor/toggle-children-number-list]]
+        (t :editor.slash/number-children)
         :icon/numberedChildren]]
 
       ;; advanced
-      [["Query" (query-steps) query-doc :icon/query "ADVANCED"]
-       ["Advanced Query" (advanced-query-steps) "Create an advanced query block" :icon/query]
-       ["Query function" [[:editor/input "{{function }}" {:backward-pos 2}]] "Create a query function" :icon/queryCode]
-       ["Calculator"
+      [[(t :property.built-in/query) (query-steps) (query-doc) :icon/query (t :editor.slash/group-advanced)]
+       [(t :editor.slash/advanced-query) (advanced-query-steps) (t :editor.slash/advanced-query-desc) :icon/query]
+       [(t :editor.slash/query-function) [[:editor/input "{{function }}" {:backward-pos 2}]] (t :editor.slash/query-function-desc) :icon/queryCode]
+       [(t :editor.slash/calculator)
         (calc-steps)
-        "Insert a calculator" :icon/calculator]
+        (t :editor.slash/calculator-desc) :icon/calculator]
 
-       ["Upload an asset"
+       [(t :editor.slash/upload-asset)
         [[:editor/click-hidden-file-input :id]]
-        "Upload file types like image, pdf, docx, etc.)"
+        (t :editor.slash/upload-asset-desc)
         :icon/upload]
 
-       ["Template" [[:editor/input command-trigger nil]
-                    [:editor/search-template]] "Insert a created template here"
+       [(t :class.built-in/template) [[:editor/input command-trigger nil]
+                                      [:editor/search-template]] (t :editor.slash/template-desc)
         :icon/template]
 
-       ["Embed HTML " (->inline "html") "" :icon/htmlEmbed]
+       [(t :editor.slash/embed-html) (->inline "html") "" :icon/htmlEmbed]
 
-       ["Embed Video URL" [[:editor/input "{{video }}" {:last-pattern command-trigger
-                                                        :backward-pos 2}]] ""
+       [(t :editor.slash/embed-video-url) [[:editor/input "{{video }}" {:last-pattern command-trigger
+                                                                        :backward-pos 2}]] ""
         :icon/videoEmbed]
 
-       ["Embed Youtube timestamp" [[:youtube/insert-timestamp]] "" :icon/videoEmbed]
+       [(t :editor.slash/embed-youtube-timestamp) [[:youtube/insert-timestamp]] "" :icon/videoEmbed]
 
-       ["Embed Twitter tweet" [[:editor/input "{{tweet }}" {:last-pattern command-trigger
-                                                            :backward-pos 2}]] ""
+       [(t :editor.slash/embed-twitter-tweet) [[:editor/input "{{tweet }}" {:last-pattern command-trigger
+                                                                            :backward-pos 2}]] ""
         :icon/xEmbed]
 
-       ["Add new property" [[:editor/clear-current-slash]
-                            [:editor/new-property]] ""
+       [(t :command.editor/add-property) [[:editor/clear-current-slash]
+                                          [:editor/new-property]] ""
         :icon/cube-plus]]
 
       (let [commands (->> @*extend-slash-commands
+                          (map resolve-slash-command)
                           (remove (fn [command] (when (map? (last command))
                                                   (false? (:db-graph? (last command)))))))]
         commands)
@@ -320,7 +336,7 @@
       (state/get-commands)
       (when-let [plugin-commands (seq (some->> (state/get-plugins-slash-commands)
                                                (mapv #(vec (concat % [nil :icon/puzzle])))))]
-        (-> plugin-commands (vec) (update 0 (fn [v] (conj v "PLUGINS"))))))
+        (-> plugin-commands (vec) (update 0 (fn [v] (conj v (t :editor.slash/group-plugins)))))))
      (remove nil?)
      (util/distinct-by-last-wins first))))
 
@@ -650,7 +666,7 @@
        (contains? #{:scheduled :deadline} type)
        (string/blank? (gobj/get (state/get-input) "value")))
     (do
-      (notification/show! [:div "Please add some content first."] :warning)
+      (notification/show! [:div (t :editor/add-content-first-warning)] :warning)
       (restore-state))
     (do
       (state/set-timestamp-block! nil)

+ 2 - 3
src/main/frontend/components/all_pages.cljs

@@ -10,7 +10,7 @@
 (defn- columns
   []
   (->> [{:id :block/title
-         :name (t :block/name)
+         :name (t :page/name)
          :cell (fn [_table row _column]
                  (component-block/page-cp {:show-non-exists-page? true
                                            :skip-async-load? true
@@ -33,5 +33,4 @@
      (views/view {:view-parent (db/get-page common-config/views-page-name)
                   :view-feature-type :all-pages
                   :show-items-count? true
-                  :columns columns'
-                  :title-key :all-pages/table-title})]))
+                  :columns columns'})]))

+ 17 - 17
src/main/frontend/components/assets.cljs

@@ -69,18 +69,18 @@
                         (do (set-dir! val dir nil)
                             (shui/dialog-close!))
                         (notification/show!
-                         (util/format "Alias name of [%s] already exists!" val) :warning))))]
+                         (t :asset/alias-already-exists val) :warning))))]
 
     [:div.cp__assets-alias-name-content
-     [:h1.text-2xl.opacity-90.mb-6 "What's the alias name of this selected directory?"]
-     [:p [:strong "Directory path:"]
+     [:h1.text-2xl.opacity-90.mb-6 (t :asset/alias-name-dialog-title)]
+     [:p [:strong (t :asset/alias-directory-path-label)]
       [:a {:on-click #(when (util/electron?)
                         (js/apis.openPath dir))} dir]]
-     [:p [:strong "Alias name:"]
+     [:p [:strong (t :asset/alias-name-label)]
       [:input.px-1.border.rounded
        {:autoFocus   true
         :value       val
-        :placeholder "eg. Books"
+        :placeholder (t :asset/alias-name-placeholder)
         :on-change   (fn [^js e]
                        (set-val! (util/trim-safe (.. e -target -value))))
         :on-key-up   (fn [^js e]
@@ -90,7 +90,7 @@
 
      [:div.pt-6.flex.justify-end
       (ui/button
-       "Save"
+       (t :ui/save)
        :disabled (string/blank? val)
        :on-click on-submit)]]))
 
@@ -181,20 +181,20 @@
 
                                    :dune)))
                :input-opts   {:class       "cp__assets-alias-ext-input"
-                              :placeholder "E.g. mp3"
+                              :placeholder (t :asset/file-extension-placeholder)
                               :on-blur
                               #(reset! *ext-editing-dir nil)}})
 
              [:small.ext-label.is-plus
               {:on-click #(reset! *ext-editing-dir dir)}
-              (ui/icon "plus") "Acceptable file extensions"])]
+              (ui/icon "plus") (t :asset/acceptable-file-extensions)])]
 
           [:span.ctrls.flex.space-x-3.text-xs.opacity-30.hover:opacity-100.whitespace-nowrap.hidden.mt-1
            [:a {:on-click #(rm-dir dir)} (ui/icon "trash-x")]]]])]
 
      [:p.pt-2
       (ui/button
-       "+ Add directory"
+       (t :asset/add-directory)
        :on-click #(p/let [path (ipc/ipc :openDialog)]
                     (when-not (or (string/blank? path)
                                   (pick-exist path))
@@ -213,7 +213,7 @@
     [:div.cp__assets-settings.panel-wrap
      [:div.it
       [:label.block.text-sm.font-medium.leading-5.opacity-70
-       "Alias directories"]
+      (t :asset/alias-directories)]
       [:div (ui/toggle
              alias-enabled?
              #(state/set-assets-alias-enabled! (not alias-enabled?))
@@ -223,7 +223,7 @@
 
      (when alias-enabled?
        [:div.pt-4
-        [:h2.font-bold.opacity-80 "Selected directories:"]
+        [:h2.font-bold.opacity-80 (t :asset/selected-directories)]
         (alias-directories)])]))
 
 (rum/defc edit-external-url-form
@@ -261,9 +261,9 @@
                            (p/then #(when on-saved (on-saved asset-block false)))
                            (p/catch err-handle)
                            (p/finally #(set-saving? false))))))}
-     [:label [:span.block.pb-2.text-sm.opacity-60 "Asset title:"]
+     [:label [:span.block.pb-2.text-sm.opacity-60 (t :asset/title-label)]
       (shui/input {:small true :default-value title :name "title"})]
-     [:label [:span.block.pb-2.text-sm.opacity-60 "Asset external url:"]
+     [:label [:span.block.pb-2.text-sm.opacity-60 (t :asset/external-url-label)]
       [:span.flex.items-center.gap-2
        (shui/input {:small true :default-value url :name "src"})
        (when (util/electron?)
@@ -272,14 +272,14 @@
            :on-click (fn [^js e]
                        (.preventDefault e)
                        (p/let [^js ret (ipc/ipc :showOpenDialog {:properties ["openFile"]
-                                                                 :title "Select Asset File"})]
+                                                                 :title (t :asset/select-file)})]
                          (let [file-path (some-> ret (bean/->clj) :filePaths (first))]
                            (when (not (string/blank? file-path))
                              (let [^js input (-> (.-target e) (.closest "form") (.querySelector "input[name='src']"))]
                                (set! (.-value input) file-path))))))}
-          "Select from disk"))]]
+          (t :asset/select-from-disk)))]]
      [:div.flex.justify-end.pt-3
-      (ui/button (if create? "Create" "Save") {:disabled saving?})]]))
+      (ui/button (if create? (t :ui/create) (t :ui/save)) {:disabled saving?})]]))
 
 (rum/defc edit-external-url-content
   [asset-block pdf-current]
@@ -300,7 +300,7 @@
           (shui/alert
            {:variant "warning"}
            (shui/alert-description
-            "Creating a local asset from an external one. PDF annotations require a local asset to work properly."))
+            (t :asset/create-local-copy-warning)))
 
           (let [title (util/node-path.basename url)]
             (edit-external-url-form asset-block {:url url :title title :on-saved on-saved!}))])))])

+ 80 - 74
src/main/frontend/components/block.cljs

@@ -274,15 +274,15 @@
                                      "ico" "image/x-icon"}
                           mime (get ext->mime ext)]
                       (if-not mime
-                        (notification/show! (str "Copy image is not supported for ." ext " files") :warning)
+                        (notification/show! (t :asset/copy-image-unsupported-extension (str "." ext)) :warning)
                         (-> (p/let [binary (fs/read-file-raw nil image-src {})
                                     blob (js/Blob. (array binary) (clj->js {:type mime}))]
                               (util/copy-image-blob-to-clipboard blob))
-                            (p/then #(notification/show! "Copied!" :success))
+                            (p/then #(notification/show! (t :notification/copied) :success))
                             (p/catch (fn [error]
                                        (js/console.error error))))))
                     (-> (util/copy-image-to-clipboard src')
-                        (p/then #(notification/show! "Copied!" :success))
+                        (p/then #(notification/show! (t :notification/copied) :success))
                         (p/catch (fn [error]
                                    (js/console.error error))))))
                 handle-delete!
@@ -297,8 +297,10 @@
                                 {:default-checked @*local-selected?
                                  :on-checked-change #(reset! *local-selected? %)})
                                (t :asset/physical-delete)])]
-                           {:title (t :asset/confirm-delete (.toLocaleLowerCase (t :text/image)))
-                            :outside-cancel? true})
+                           {:title (t :asset/confirm-delete-image)
+                            :outside-cancel? true
+                            :cancel-label (t :ui/cancel)
+                            :ok-label (t :ui/confirm)})
                           (p/then (fn []
                                     (shui/dialog-close!)
                                     (editor-handler/delete-asset-of-block!
@@ -367,7 +369,7 @@
                                    (ipc/ipc "openFileInFolder" image-src)
                                    (js/window.apis.openExternal image-src)))}
                     [:span.flex.items-center.gap-1
-                     (ui/icon "folder-pin") (t (if local? :asset/show-in-folder :asset/open-in-browser))]))
+                     (ui/icon "folder-pin") (t (if local? :asset/show-file-in-folder :asset/open-in-browser))]))
 
                  (when-not config/publishing?
                    [:<>
@@ -594,40 +596,38 @@
          [:div.as-plain-image-link
           (resizable-image config title href metadata full_text false)])))))
 
-(def timestamp-to-string export-common-handler/timestamp-to-string)
-
 (defn timestamp [{:keys [active _date _time _repetition _wday] :as t} kind]
   (let [prefix (case kind
-                 "Scheduled"
+                 :scheduled
                  [:i {:class "fa fa-calendar"
                       :style {:margin-right 3.5}}]
-                 "Deadline"
+                 :deadline
                  [:i {:class "fa fa-calendar-times-o"
                       :style {:margin-right 3.5}}]
-                 "Date"
+                 :date
                  nil
-                 "Closed"
+                 :closed
                  nil
-                 "Started"
+                 :started
                  [:i {:class "fa fa-clock-o"
                       :style {:margin-right 3.5}}]
-                 "Start"
-                 "From: "
-                 "Stop"
-                 "To: "
+                 :start
+                 (t :ui/from)
+                 :stop
+                 (t :ui/to)
                  nil)
-        class (when (= kind "Closed")
+        class (when (= kind :closed)
                 "line-through")]
     [:span.timestamp (cond-> {:active (str active)}
                        class
                        (assoc :class class))
-     prefix (timestamp-to-string t)]))
+     prefix (export-common-handler/timestamp-to-string t)]))
 
 (defn range [{:keys [start stop]} stopped?]
   [:div {:class "timestamp-range"
          :stopped stopped?}
-   (timestamp start "Start")
-   (timestamp stop "Stop")])
+   (timestamp start :start)
+   (timestamp stop :stop)])
 
 (declare map-inline)
 (declare markup-element-cp)
@@ -702,7 +702,7 @@
                 recycled? (str " line-through opacity-70")
                 untitled? (str " opacity-50"))
        :data-ref page-name
-       :title (when recycled? "Deleted")
+       :title (when recycled? (t :ui/deleted))
        :draggable true
        :on-drag-start (fn [e]
                         (editor-handler/block->data-transfer! page-name e true))
@@ -765,7 +765,7 @@
 
           (ldb/page? page-entity)
           (if untitled?
-            (t :untitled)
+            (t :ui/untitled)
             (let [s (util/trim-safe (if show-unique-title?
                                       (block-handler/block-unique-title page-entity {:with-tags? with-tags?})
                                       (:block/title page-entity)))]
@@ -1053,9 +1053,9 @@
         percent (when in-progress?
                   (int (* 100 (/ loaded total))))
         label (case direction
-                :upload "Uploading"
-                :download "Downloading"
-                "Syncing")
+                :upload (t :asset/uploading)
+                :download (t :asset/downloading)
+                (t :asset/syncing))
         progress-view (when in-progress?
                         [:div.asset-transfer-progress
                          [:div.asset-transfer-progress-label (str label " " percent "%")]
@@ -1091,7 +1091,7 @@
     (if progress-view
       [:div.asset-transfer-shell
        (or content
-           [:div.asset-transfer-placeholder (str label " asset...")])
+           [:div.asset-transfer-placeholder (t :asset/transfer-placeholder label)])
        progress-view]
       content)))
 
@@ -1143,7 +1143,7 @@
               (and (string? uuid-or-title) (string/ends-with? uuid-or-title ".excalidraw"))
               [:div.draw {:on-click (fn [e]
                                       (.stopPropagation e))}
-               [:div.warning "Excalidraw is no longer supported by default, we plan to support it through plugins."]]
+               [:div.warning (t :block/excalidraw-no-longer-supported)]]
 
               :else
               (let [blank-title? (string/blank? (:block/title block))]
@@ -1230,7 +1230,7 @@
            [(assoc attributes :class "inline")
             (inline-text {:add-margin? false} format macro-content)]))
        [attributes
-        [:span.warning {:title (str "Unsupported macro name: " name)}
+        [:span.warning {:title (t :block.macro/unsupported-name name)}
          (macro->text name arguments)]]))))
 
 (rum/defc nested-link < rum/reactive
@@ -1314,7 +1314,7 @@
   [config url s label title metadata full_text]
   (cond
     (string/blank? s)
-    [:span.warning {:title "Invalid link"} full_text]
+    [:span.warning {:title (t :block/invalid-link)} full_text]
 
     (= \# (first s))
     (->elem :a {:on-click #(route-handler/jump-to-anchor! (mldoc/anchorLink (subs s 1)))} (subs s 1))
@@ -1372,7 +1372,7 @@
             {:keys [link-depth]} config
             link-depth (or link-depth 0)]
         (if (> link-depth max-depth-of-links)
-          [:p.warning.text-sm "Block ref nesting is too deep"]
+          [:p.warning.text-sm (t :block/ref-nesting-too-deep)]
           (block-reference (assoc config
                                   :reference? true
                                   :link-depth (inc link-depth)
@@ -1540,9 +1540,9 @@
                 :src src
                 :width width
                 :height height}]))))
-      [:span.warning.mr-1 {:title "Invalid URL"}
+      [:span.warning.mr-1 {:title (t :block/invalid-url)}
        (macro->text "video" arguments)])
-    [:span.warning.mr-1 {:title "Empty URL"}
+    [:span.warning.mr-1 {:title (t :block/empty-url)}
      (macro->text "video" arguments)]))
 
 (defn- macro-else-cp
@@ -1564,13 +1564,13 @@
                     arguments)]
     (cond
       (= name "query")
-      [:div.warning "{{query}} is deprecated. Use '/Query' command instead."]
+      [:div.warning (t :block.macro/query-deprecated)]
 
       (= name "function")
       (macro-function-cp config arguments)
 
       (= name "namespace")
-      [:div.warning (str "{{namespace}} is deprecated. Use the " common-config/library-page-name " feature instead.")]
+      [:div.warning (t :block.macro/namespace-deprecated (t :library/title))]
 
       (= name "youtube")
       (when-let [url (first arguments)]
@@ -1617,7 +1617,7 @@
               (ui/tweet-embed id)))))
 
       (= name "embed")
-      [:div.warning "{{embed}} is deprecated. Use '/Node embed' command instead."]
+      [:div.warning (t :block.macro/embed-deprecated)]
 
       (= name "renderer")
       (when config/lsp-enabled?
@@ -1644,7 +1644,7 @@
   [s]
   (let [result (common-util/safe-read-string s)
         result' (if (seq result) result
-                    [:div.warning {:title "Invalid hiccup"}
+                    [:div.warning {:title (t :block/invalid-hiccup)}
                      s])]
     (-> result'
         (hiccups.core/html)
@@ -1718,7 +1718,7 @@
 
     ["Inline_Hiccup" s]                                ;; String to hiccup
     (ui/catch-error
-     [:div.warning {:title "Invalid hiccup"} s]
+    [:div.warning {:title (t :block/invalid-hiccup)} s]
      [:span {:dangerouslySetInnerHTML
              {:__html (hiccup->html s)}}])
 
@@ -1733,15 +1733,15 @@
     ["Timestamp" [(:or "Scheduled" "Deadline") _timestamp]]
     nil
     ["Timestamp" ["Date" t]]
-    (timestamp t "Date")
+    (timestamp t :date)
     ["Timestamp" ["Closed" t]]
-    (timestamp t "Closed")
+    (timestamp t :closed)
     ["Timestamp" ["Range" t]]
     (range t false)
     ["Timestamp" ["Clock" ["Stopped" t]]]
     (range t true)
     ["Timestamp" ["Clock" ["Started" t]]]
-    (timestamp t "Started")
+    (timestamp t :started)
 
     ["Cookie" ["Percent" n]]
     [:span {:class "cookie-percent"}
@@ -2111,8 +2111,8 @@
              (when-let [created-by (and (ldb/get-graph-rtc-uuid (db/get-db))
                                         (:logseq.property/created-by-ref block))]
                [:div (:block/title created-by)])
-             [:div "Created: " (date/int->local-time-2 (:block/created-at block))]
-             [:div "Last edited: " (date/int->local-time-2 (:block/updated-at block))]]))))]))
+             [:div (t :block/created-label (date/int->local-time-2 (:block/created-at block)))]
+             [:div (t :block/last-edited-label (date/int->local-time-2 (:block/updated-at block)))]]))))]))
 
 (rum/defc dnd-separator
   [move-to]
@@ -2224,8 +2224,8 @@
                                               (when *show-query? (swap! *show-query? not)))}
                           (ui/icon "settings"))
                          [:div.opacity-75 (if show-query?
-                                            "Hide query"
-                                            "Set query")]))]
+                                            (t :block/hide-query)
+                                            (t :block/set-query))]))]
     [:div
      (merge
       {:class (if query?
@@ -2238,7 +2238,7 @@
           {:on-click on-title-click})))
      (cond
        (and query? blank? (or advanced-query? show-query?))
-       [:span.opacity-75.hover:opacity-100 "Untitled query"]
+      [:span.opacity-75.hover:opacity-100 (t :block/untitled-query)]
        (and query? blank?)
        (query-builder-component/builder query {})
        :else
@@ -2255,8 +2255,8 @@
            :on-click (fn [e]
                        (util/stop e)
                        (state/pub-event! [:modal/show-cards (:db/id block)]))}
-          "Practice")
-         [:div "Practice cards"])])
+          (t :block/practice))
+         [:div (t :block/practice-cards)])])
      (when-let [property (:logseq.property/created-from-property block)]
        (when-let [message (when (= :url (:logseq.property/type property))
                             (first (outliner-property/validate-property-value (db/get-db) property (:db/id block))))]
@@ -2272,16 +2272,23 @@
   [config block {:keys [*show-query?]}]
   (let [block' (db/entity (:db/id block))
         node-display-type (:logseq.property.node/display-type block')
+        display-title (:display-title config)
         db (db/get-db)
         query? (ldb/class-instance? (entity-plus/entity-memoized db :logseq.class/Query) block')]
     (cond
       (and (:page-title? config) (ldb/page? block) (string/blank? (:block/title block)))
-      [:div.opacity-75 "Untitled"]
+      [:div.opacity-75 (t :ui/untitled)]
 
       (and (ldb/asset? block)
            (= :pdf (some-> (:logseq.property.asset/type block) string/lower-case keyword)))
       (asset-cp config block)
 
+      display-title
+      (text-block-title (dissoc config :display-title)
+                        (-> block
+                            (assoc :block/title display-title)
+                            (dissoc :block.temp/ast-title :block.temp/ast-body)))
+
       (:raw-title? config)
       (text-block-title (dissoc config :raw-title?) block)
 
@@ -2500,11 +2507,11 @@
                                   (shui/dropdown-menu-item
                                    {:key "Remove tag"
                                     :on-click #(db-property-handler/delete-property-value! (:db/id block) :block/tags (:db/id tag))}
-                                   "Remove tag"))])
+                                   (t :block/remove-tag)))])
                              popup-opts))}
         (if (and @*hover? (not private-tag?) (not config/publishing?))
           [:a.inline-flex.text-muted-foreground
-           {:title "Remove this tag"
+             {:title (t :block/remove-this-tag)
             :style {:margin-top 1
                     :padding-left 2
                     :margin-right 2}
@@ -2554,7 +2561,7 @@
                                                      [:div.flex.flex-row.items-center.gap-1
                                                       (when-not (ldb/private-tags (:db/ident tag))
                                                         (shui/button
-                                                         {:title "Remove tag"
+                                                         {:title (t :block/remove-tag)
                                                           :variant :ghost
                                                           :class "!p-1 text-muted-foreground"
                                                           :size :sm
@@ -2665,7 +2672,7 @@
           {:variant :ghost
            :size :sm
            :class "px-1 py-0 h-6 text-muted-foreground hover:text-foreground"
-           :title "Add reaction"
+           :title (t :command.editor/add-reaction)
            :on-click open-picker!
            :on-pointer-down (fn [e]
                               (util/stop e))}
@@ -2676,9 +2683,9 @@
   (let [[sort-desc? set-sort-desc!] (rum/use-state true)]
     [:div.p-2.text-muted-foreground.text-sm.max-h-96
      [:div.font-medium.mb-2.flex.flex-row.gap-2.items-center
-      [:div "Status history"]
+      [:div (t :block/status-history)]
       (shui/button-ghost-icon (if sort-desc? :arrow-down :arrow-up)
-                              {:title "Sort order"
+                              {:title (t :block/sort-order)
                                :class "text-muted-foreground !h-4 !w-4"
                                :icon-props {:size 14}
                                :on-click #(set-sort-desc! (not sort-desc?))})]
@@ -2785,7 +2792,7 @@
       (when (and (> (count content) (state/block-content-max-length (state/get-current-repo)))
                  (not (contains? #{:code} (:logseq.property.node/display-type block))))
         [:div.warning.text-sm
-         "Large block will not be editable or searchable to not slow down the app, please use another editor to edit this block."])
+         (t :block/large-block-warning)])
       [:div.flex.flex-row.justify-between.block-content-inner
        (when-not plugin-slotted?
          [:div.block-head-wrap
@@ -2800,7 +2807,7 @@
   (when (> block-refs-count' 0)
     [:div.h-6
      (shui/button {:variant :ghost
-                   :title "Open block references"
+                   :title (t :block/open-block-references)
                    :class (str "px-1 py-0 w-5 h-5 opacity-70 hover:opacity-100" (when (and (util/mobile?)
                                                                                            (seq (:block/_parent block)))
                                                                                   " !pr-4"))
@@ -2839,10 +2846,9 @@
           {:on-click (fn []
                        (set-editing! true)
                        (editor-handler/edit-block! query :max {:container-id (:container-id config)}))}
-          "Click to fix query: "
-          (:block/title query)])
+          (t :block/click-to-fix-query (:block/title query))])
        [:div.flex.flex-1.flex-col.w-full.gap-2
-        (ui/block-error "Block Render Error:"
+        (ui/block-error (t :block/render-error)
                         {:content (or (:block/title query)
                                       (:block/title block))
                          :section-attrs
@@ -2916,7 +2922,7 @@
                         {:id editor-id
                          :class (util/classnames [{:opacity-50 (boolean (or (ldb/built-in? block) (ldb/journal? block)))}])}
                         (ui/catch-error
-                         (ui/block-error "Something wrong in the editor" {})
+                         (ui/block-error (t :sync/something-wrong) {})
                          (editor-box {:block block
                                       :block-id uuid
                                       :block-parent-id block-id
@@ -3388,7 +3394,7 @@
                             (let [element (dom/create-element "div")]
                               (-> element
                                   (dom/set-attr! "id" "dragging-ghost-element")
-                                  (dom/set-text! (str "Moving " (count blocks) " blocks"))
+                                  (dom/set-text! (t :editor/moving-blocks-count (count blocks)))
                                   (dom/set-class! "p-2 rounded text-sm"))
                               element))]
               (doseq [block blocks]
@@ -3498,7 +3504,7 @@
           (if advanced-query?
             (src-cp (assoc config :code-block query) {:language "clojure"})
             [:div
-             [:div.opacity-75.ml-5.text-sm.mb-1 "Set query:"]
+             [:div.opacity-75.ml-5.text-sm.mb-1 (t :block/set-query-label)]
              (block-container config query)])]))
 
      (when (and (not (or (:table? config) (:property? config)))
@@ -3796,7 +3802,7 @@
   (when-let [langs (map (fn [m] (.-name m)) js/window.CodeMirror.modeInfo)]
     (let [options (map (fn [lang] {:label lang :value lang}) langs)]
       (select/select {:items options
-                      :input-default-placeholder "Choose language"
+                      :input-default-placeholder (t :editor/code-language-placeholder)
                       :on-chosen
                       (fn [chosen _ _ e]
                         (let [lang (:value chosen)]
@@ -3854,7 +3860,7 @@
                                                                        (db-property-handler/set-block-property!
                                                                         (:db/id block) :logseq.property.code/lang lang))))
                                                  {:align :end})))}
-                (or language "Choose language")
+                (or language (t :editor/code-language-placeholder))
                 (ui/icon "chevron-down"))
                (shui/button
                 {:variant :text
@@ -3863,9 +3869,9 @@
                              (util/stop-propagation e)
                              (when-let [^js cm (util/get-cm-instance (util/rec-get-node (.-target e) "ls-block"))]
                                (util/copy-to-clipboard! (.getValue cm))
-                               (notification/show! "Copied!" :success)))}
+                               (notification/show! (t :notification/copied) :success)))}
                 (ui/icon "copy")
-                "Copy")]
+                (t :ui/copy))]
               (lazy-editor/editor config (str (d/squuid)) attr code options)
               (let [options (:options options) block (:block config)]
                 (when (and (= language "clojure") (contains? (set options) ":results"))
@@ -3933,7 +3939,7 @@
       [:pre.pre-wrap-white-space
        (join-lines l)]
       ["Quote" _l]
-      [:div.warning "#+BEGIN_QUOTE is deprecated. Use '/Quote' command instead."]
+      [:div.warning (t :block/deprecated-quote)]
       ["Raw_Html" content]
       (when (not html-export?)
         [:div.raw_html.inline-block
@@ -3945,7 +3951,7 @@
                            {:__html (security/sanitize-html content)}}])
       ["Hiccup" content]
       (ui/catch-error
-       [:div.warning {:title "Invalid hiccup"}
+      [:div.warning {:title (t :block/invalid-hiccup)}
         content]
        [:div.hiccup_html.inline
         {:dangerouslySetInnerHTML
@@ -3954,10 +3960,10 @@
       ["Export" "latex" _options content]
       (if html-export?
         (latex/html-export content true false)
-        [:div.warning "'#+BEGIN_EXPORT latex' is deprecated. Use '/Math block' command instead."])
+        [:div.warning (t :block/deprecated-latex-export)])
 
       ["Custom" "query" _options _result _content]
-      [:div.warning "#+BEGIN_QUERY is deprecated. Use '/Advanced Query' command instead."]
+      [:div.warning (t :block/deprecated-query-syntax)]
 
       ["Custom" "note" _options result _content]
       (ui/admonition "note" (markup-elements-cp config result))
@@ -4281,7 +4287,7 @@
          (ui/foldable
           [:div.with-foldable-page
            (page-cp config page)
-           (when alias? [:span.text-sm.font-medium.opacity-50 " Alias"])]
+           (when alias? [:span.text-sm.font-medium.opacity-50 (str " " (t :property.built-in/alias))])]
           items
           {:debug-id page})
          [:div.only-page-blocks items]))]))
@@ -4305,7 +4311,7 @@
              (ui/foldable
               [:div
                (page-cp config page)
-               (when alias? [:span.text-sm.font-medium.opacity-50 " Alias"])]
+               (when alias? [:span.text-sm.font-medium.opacity-50 (str " " (t :property.built-in/alias))])]
               (fn []
                 (let [{top-level-blocks true others false} (group-by
                                                             (fn [b] (= (:db/id page) (:db/id (first b))))
@@ -4354,7 +4360,7 @@
                  (ui/foldable
                   [:div
                    (page-cp config page)
-                   (when alias? [:span.text-sm.font-medium.opacity-50 " Alias"])]
+                   (when alias? [:span.text-sm.font-medium.opacity-50 (str " " (t :property.built-in/alias))])]
                   (fn []
                     (blocks-container config blocks))
                   {})])))))]

+ 28 - 23
src/main/frontend/components/bug_report.cljs

@@ -9,6 +9,10 @@
             [reitit.frontend.easy :as rfe]
             [rum.core :as rum]))
 
+(defn paste-shortcut-label
+  [mac?]
+  (if mac? "⌘+V" "Ctrl+V"))
+
 (defn parse-clipboard-data-transfer
   "parse dataTransfer
 
@@ -44,7 +48,7 @@
 
         copy-result-to-clipboard! (fn [result]
                                     (util/copy-to-clipboard! result)
-                                    (notification/show! (t :bug-report/inspector-page-copy-notif)))
+                                    (notification/show! (t :bug-report.inspector/copied)))
 
         reset-step! (fn []
                       (set-step! 0)
@@ -58,26 +62,27 @@
 
     [:div.flex.flex-col
      (when (= step 0)
-       (list [:div.mx-auto (t :bug-report/inspector-page-desc-1)]
-             [:div.mx-auto (t :bug-report/inspector-page-desc-2)]
+       (list (for [line (string/split-lines (t :bug-report.inspector/desc
+                                               (paste-shortcut-label util/mac?)))]
+               [:div.mx-auto line])
              ;; for mobile
-             [:input.form-input.is-large.transition.duration-150.ease-in-out {:type "text" :placeholder (t :bug-report/inspector-page-placeholder)}]
+             [:input.form-input.is-large.transition.duration-150.ease-in-out {:type "text" :placeholder (t :bug-report.inspector/placeholder)}]
              [:div.flex.justify-between.items-center.mt-2
-              [:div (t :bug-report/inspector-page-tip)]
-              (ui/button (t :bug-report/inspector-page-btn-back) :on-click #(util/open-url (rfe/href :bug-report)))]))
+              [:div (t :bug-report.inspector/tip)]
+              (ui/button (t :bug-report.inspector/back) :on-click #(util/open-url (rfe/href :bug-report)))]))
 
      (when (= step 1)
        (list
-        [:div (t :bug-report/inspector-page-desc-clipboard)]
+        [:div (t :bug-report.inspector/clipboard-desc)]
         [:div.flex.justify-between.items-center.mt-2
-         [:div (t :bug-report/inspector-page-desc-copy)]
-         (ui/button (t :bug-report/inspector-page-btn-copy) :on-click #(copy-result-to-clipboard! (js/JSON.stringify (clj->js result) nil 2)))]
+         [:div (t :bug-report.inspector/copy-desc)]
+         (ui/button (t :bug-report.inspector/copy) :on-click #(copy-result-to-clipboard! (js/JSON.stringify (clj->js result) nil 2)))]
         [:div.flex.justify-between.items-center.mt-2
-         [:div (t :bug-report/inspector-page-desc-create-issue)]
-         (ui/button (t :bug-report/inspector-page-btn-create-issue) :href (header/bug-report-url))]
+         [:div (t :bug-report.inspector/create-issue-desc)]
+         (ui/button (t :bug-report.inspector/create-issue) :href (header/bug-report-url))]
         [:div.flex.justify-between.items-center.mt-2
-         [:div (t :bug-report/inspector-page-tip)]
-         (ui/button (t :bug-report/inspector-page-btn-back) :on-click reset-step!)]
+         [:div (t :bug-report.inspector/tip)]
+         (ui/button (t :bug-report.inspector/back) :on-click reset-step!)]
 
         [:pre.whitespace-pre-wrap [:code (js/JSON.stringify (clj->js result) nil 2)]]))]))
 
@@ -87,7 +92,7 @@
     [:div.flex.flex-col ;; container
      (cond
        (= name "clipboard-data-inspector")
-       [:h1.text-2xl.mx-auto.mb-4 (ui/icon "clipboard") " " (-> (t :bug-report/clipboard-inspector-title) (string/capitalize))])
+       [:h1.text-2xl.mx-auto.mb-4 (ui/icon "clipboard") " " (-> (t :bug-report.inspector/title) (string/capitalize))])
      (cond
        (= name "clipboard-data-inspector")
        (clipboard-data-inspector))]))
@@ -106,17 +111,17 @@
    [:div.flex.flex-col.items-center
     [:div.flex.items-center.mb-2
      (ui/icon "bug")
-     [:h1.text-3xl.ml-2 (t :bug-report/main-title)]]
-    [:div.opacity-60 (t :bug-report/main-desc)]]
+     [:h1.text-3xl.ml-2 (t :bug-report/title)]]
+    [:div.opacity-60 (t :bug-report/desc)]]
    [:div.cp__bug-report-reporter.rounded-lg.p-8.mt-8
-    [:h1.text-2xl (t :bug-report/section-clipboard-title)]
-    [:div.opacity-60 (t :bug-report/section-clipboard-desc)]
-    (report-item-button (t :bug-report/section-clipboard-btn-title)
-                        (t :bug-report/section-clipboard-btn-desc)
+    [:h1.text-2xl (t :bug-report.clipboard/title)]
+    [:div.opacity-60 (t :bug-report.clipboard/desc)]
+    (report-item-button (t :bug-report.clipboard/action-title)
+                        (t :bug-report.clipboard/action-desc)
                         "clipboard"
                         {:on-click #(util/open-url (rfe/href :bug-report-tools {:tool "clipboard-data-inspector"}))})
     [:div.py-2] ;; divider
     [:div.flex.flex-col
-     [:h1.text-2xl (t :bug-report/section-issues-title)]
-     [:div.opacity-60 (t :bug-report/section-issues-desc)]
-     (report-item-button (t :bug-report/section-issues-btn-title) (t :bug-report/section-issues-btn-desc) "message-report" {:on-click #(util/open-url (header/bug-report-url))})]]])
+     [:h1.text-2xl (t :bug-report.issue/title)]
+     [:div.opacity-60 (t :bug-report.issue/desc)]
+     (report-item-button (t :bug-report.issue/action-title) (t :bug-report.issue/action-desc) "message-report" {:on-click #(util/open-url (header/bug-report-url))})]]])

+ 2 - 1
src/main/frontend/components/class.cljs

@@ -1,5 +1,6 @@
 (ns frontend.components.class
   (:require [frontend.components.block :as block]
+            [frontend.context.i18n :refer [t]]
             [frontend.db.model :as model]
             [frontend.state :as state]
             [frontend.ui :as ui]
@@ -30,7 +31,7 @@
           default-collapsed? (> (count children-pages) 30)]
       (ui/foldable
        [:div.font-medium.opacity-50
-        (str "Children (" (count children-pages) ")")]
+        (t :property/children-count (count children-pages))]
        [:div.ml-1.mt-2 (class-children-aux class {:default-collapsed? default-collapsed?})]
        {:default-collapsed? false
         :title-trigger? true}))))

+ 135 - 114
src/main/frontend/components/cmdk/core.cljs

@@ -7,7 +7,7 @@
             [frontend.components.cmdk.state :as cmdk-state]
             [frontend.components.icon :as icon-component]
             [frontend.config :as config]
-            [frontend.context.i18n :refer [t]]
+            [frontend.context.i18n :as i18n :refer [t]]
             [frontend.db :as db]
             [frontend.db.async :as db-async]
             [frontend.db.model :as model]
@@ -60,14 +60,28 @@
   (let [current-page (state/get-current-page)]
     (->>
      [(when current-page
-        {:filter {:group :current-page} :text "Search only current page" :info "Add filter to search" :icon-theme :gray :icon "file"})
-      {:filter {:group :nodes} :text "Search only nodes" :info "Add filter to search" :icon-theme :gray :icon "point-filled"}
-      {:filter {:group :code} :text "Search only code" :info "Add filter to search" :icon-theme :gray :icon "code"}
-      {:filter {:group :commands} :text "Search only commands" :info "Add filter to search" :icon-theme :gray :icon "command"}
-      {:filter {:group :files} :text "Search only files" :info "Add filter to search" :icon-theme :gray :icon "file"}
-      {:filter {:group :themes} :text "Search only themes" :info "Add filter to search" :icon-theme :gray :icon "palette"}]
+        {:filter {:group :current-page} :text (t :cmdk.filter/current-page) :info (t :cmdk.filter/add) :icon-theme :gray :icon "file"})
+      {:filter {:group :nodes} :text (t :cmdk.filter/nodes) :info (t :cmdk.filter/add) :icon-theme :gray :icon "point-filled"}
+      {:filter {:group :codes} :text (t :cmdk.filter/codes) :info (t :cmdk.filter/add) :icon-theme :gray :icon "code"}
+      {:filter {:group :commands} :text (t :cmdk.filter/commands) :info (t :cmdk.filter/add) :icon-theme :gray :icon "command"}
+      {:filter {:group :files} :text (t :cmdk.filter/files) :info (t :cmdk.filter/add) :icon-theme :gray :icon "file"}
+      {:filter {:group :themes} :text (t :cmdk.filter/themes) :info (t :cmdk.filter/add) :icon-theme :gray :icon "palette"}]
      (remove nil?))))
 
+(defn- group-label
+  [group]
+  (case group
+    :filters (t :cmdk.group/filters)
+    :current-page (t :cmdk.group/current-page)
+    :nodes (t :cmdk.group/nodes)
+    :codes (t :cmdk.group/codes)
+    :files (t :cmdk.group/files)
+    :create (t :cmdk.group/create)
+    :recently-updated-pages (t :cmdk.group/recently-updated)
+    :commands (t :cmdk.group/commands)
+    :themes (t :cmdk.group/themes)
+    (name group)))
+
 ;; The results are separated into groups, and loaded/fetched/queried separately
 (def default-results
   {:recently-updated-pages {:status :success :show :less :items nil}
@@ -75,7 +89,7 @@
    :favorites      {:status :success :show :less :items nil}
    :current-page   {:status :success :show :less :items nil}
    :nodes          {:status :success :show :less :items nil}
-   :code           {:status :success :show :less :items nil}
+   :codes          {:status :success :show :less :items nil}
    :files          {:status :success :show :less :items nil}
    :themes         {:status :success :show :less :items nil}
    :filters        {:status :success :show :less :items nil}})
@@ -94,18 +108,18 @@
                   (when (ldb/class? class)
                     class))]
       (->> [{:text (cond
-                     class "Configure tag"
-                     class? "Create tag"
-                     :else "Create page")
+                     class (t :cmdk.create/configure-tag)
+                     class? (t :cmdk.create/tag)
+                     :else (t :cmdk.create/page))
              :icon (if class "settings" "new-page")
              :icon-theme :gray
              :info (cond
                      class
-                     (str "Configure #" class-name)
+                     (t :cmdk.info/configure-tag class-name)
                      class?
-                     (str "Create tag called '" class-name "'")
+                     (t :cmdk.info/create-tag class-name)
                      :else
-                     (str "Create page called '" q "'"))
+                     (t :cmdk.info/create-page q))
              :source-create :page
              :class class}]
            (remove nil?)))))
@@ -143,41 +157,38 @@
                  []
 
                  start-with-slash?
-                 [["Filters" :filters (visible-items :filters)]
-                  ["Current page"   :current-page   (visible-items :current-page)]
-                  ["Nodes"          :nodes         (visible-items :nodes)]]
+                 [[(group-label :filters)        :filters       (visible-items :filters)]
+                  [(group-label :current-page)   :current-page  (visible-items :current-page)]
+                  [(group-label :nodes)          :nodes         (visible-items :nodes)]]
 
                  include-slash?
                  [(when-not node-exists?
-                    ["Create"         :create         (create-items input)])
+                    [(group-label :create)       :create        (create-items input)])
 
-                  ["Current page"   :current-page   (visible-items :current-page)]
-                  ["Nodes"         :nodes         (visible-items :nodes)]
-                  ["Files"          :files          (visible-items :files)]
-                  ["Filters" :filters (visible-items :filters)]]
+                  [(group-label :current-page)   :current-page  (visible-items :current-page)]
+                  [(group-label :nodes)          :nodes         (visible-items :nodes)]
+                  [(group-label :files)          :files         (visible-items :files)]
+                  [(group-label :filters)        :filters       (visible-items :filters)]]
 
                  filter-group
                  [(when (= filter-group :nodes)
-                    ["Current page"   :current-page   (visible-items :current-page)])
-                  [(cond
-                     (= filter-group :current-page) "Current page"
-                     (= filter-group :code) "Code"
-                     :else (name filter-group))
+                    [(group-label :current-page) :current-page  (visible-items :current-page)])
+                  [(group-label filter-group)
                    filter-group
                    (visible-items filter-group)]
                   (when-not node-exists?
-                    ["Create"         :create         (create-items input)])]
+                    [(group-label :create)         :create         (create-items input)])]
 
                  :else
                  (->>
                   [(when-not node-exists?
-                     ["Create"         :create       (create-items input)])
-                   ["Current page"     :current-page   (visible-items :current-page)]
-                   ["Nodes"            :nodes         (visible-items :nodes)]
-                   ["Recently updated" :recently-updated-pages (visible-items :recently-updated-pages)]
-                   ["Commands"         :commands       (visible-items :commands)]
-                   ["Files"            :files          (visible-items :files)]
-                   ["Filters"          :filters        (visible-items :filters)]]
+                     [(group-label :create)         :create         (create-items input)])
+                   [(group-label :current-page)     :current-page   (visible-items :current-page)]
+                   [(group-label :nodes)            :nodes          (visible-items :nodes)]
+                   [(group-label :recently-updated-pages) :recently-updated-pages (visible-items :recently-updated-pages)]
+                   [(group-label :commands)         :commands       (visible-items :commands)]
+                   [(group-label :files)            :files          (visible-items :files)]
+                   [(group-label :filters)          :filters        (visible-items :filters)]]
                   (remove nil?)))
         order (remove nil? order*)]
     (for [[group-name group-key group-items] order]
@@ -188,22 +199,29 @@
          (count (get-in results [group-key :items])))
        (mapv #(assoc % :group group-key :item-index (vswap! index inc)) group-items)])))
 
-(defn state->highlighted-item [state]
-  (or (some-> state ::highlighted-item deref)
-      (first @(::all-items-cache state))))
-
-(defn state->action [state]
-  (let [highlighted-item (state->highlighted-item state)
+(defn state->highlighted-item
+  ([state]
+   (state->highlighted-item state nil))
+  ([state fallback-item]
+   (or (some-> state ::highlighted-item deref)
+       fallback-item
+       (first @(::all-items-cache state)))))
+
+(defn state->action
+  ([state]
+   (state->action state nil))
+  ([state fallback-item]
+   (let [highlighted-item (state->highlighted-item state fallback-item)
         action (get-action)]
-    (cond (and (:source-block highlighted-item) (= action :move-blocks)) :trigger
-          (:source-block highlighted-item) :open
-          (:file-path highlighted-item) :open
-          (:source-search highlighted-item) :search
-          (:source-command highlighted-item) :trigger
-          (:source-create highlighted-item) :create
-          (:filter highlighted-item) :filter
-          (:source-theme highlighted-item) :theme
-          :else nil)))
+     (cond (and (:source-block highlighted-item) (= action :move-blocks)) :trigger
+           (:source-block highlighted-item) :open
+           (:file-path highlighted-item) :open
+           (:source-search highlighted-item) :search
+           (:source-command highlighted-item) :trigger
+           (:source-create highlighted-item) :create
+           (:filter highlighted-item) :filter
+           (:source-theme highlighted-item) :theme
+           :else nil))))
 
 ;; Each result group has it's own load-results function
 (defmulti load-results (fn [group _state] group))
@@ -347,14 +365,14 @@
           (swap! !results update group merge {:status :success :items items-on-current-page}))
         (swap! !results update group merge {:status :success :items items})))))
 
-(defmethod load-results :code [group state]
+(defmethod load-results :codes [group state]
   (let [!input (::input state)
         !results (::results state)
         repo (state/get-current-repo)
         current-page (when-let [id (page-util/get-current-page-id)]
                        (db/entity id))
         opts (cmdk-state/cmdk-block-search-options
-              {:filter-group :code
+              {:filter-group :codes
                :dev? config/dev?})]
     (swap! !results assoc-in [group :status] :loading)
     (p/let [blocks (search/block-search repo @!input opts)
@@ -385,7 +403,7 @@
         themes (if (string/blank? @!input)
                  themes
                  (search/fuzzy-search themes @!input :limit 100 :extract-fn :name))
-        themes (cons {:name "Logseq Default theme"
+        themes (cons {:name (t :theme/logseq-default)
                       :pid "logseq-classic-theme"
                       :mode (state/sub :ui/theme)
                       :url nil} themes)
@@ -583,7 +601,7 @@
         create-class? (string/starts-with? @!input "#")
         create-page? (= :page (:source-create item))
         class (when create-class? (get-class-from-input @!input))]
-    (if (and (= (:text item) "Configure tag") (:class item))
+    (if (:class item)
       (state/pub-event! [:dialog/show-block (:class item) {:tag-dialog? true}])
       (p/let [result (cond
                        create-class?
@@ -797,10 +815,10 @@
         can-show-more? (< (count visible-items) (count items))
         show-less #(swap! (::results state) assoc-in [group :show] :less)
         show-more #(swap! (::results state) assoc-in [group :show] :more)]
-    [:div {:class         (if (= title "Create")
+    [:div {:class         (if (= group :create)
                             "border-b border-gray-06 last:border-b-0"
                             "border-b border-gray-06 pb-1 last:border-b-0")}
-     (when-not (= title "Create")
+     (when-not (= group :create)
        [:div {:class "text-xs py-1.5 px-3 flex justify-between items-center gap-2 text-gray-11 bg-gray-02 h-8"}
         [:div {:class "font-bold text-gray-11 pl-0.5 cursor-pointer select-none"
                :on-click (fn [_e]
@@ -828,10 +846,10 @@
                         ((if (= show :more) show-less show-more)))}
            (if (= show :more)
              [:div.flex.flex-row.gap-1.items-center
-              "Show less"
+              (t :ui/show-less)
               (shui/shortcut "mod up" {:style :compact})]
              [:div.flex.flex-row.gap-1.items-center
-              "Show more"
+              (t :ui/show-more)
               (shui/shortcut "mod down" {:style :compact})])])])
 
      [:div.search-results
@@ -926,7 +944,7 @@
                          (:block/properties page'))]
         (if link
           (js/window.open link)
-          (notification/show! "No link found in this page's properties." :warning)))
+          (notification/show! (t :cmdk.error/no-page-link) :warning)))
 
       (:source-block item)
       (p/let [block-id (:block/uuid (:source-block item))
@@ -935,9 +953,9 @@
               link (re-find editor-handler/url-regex (:block/title block))]
         (if link
           (js/window.open link)
-          (notification/show! "No link found in this block's content." :warning)))
+          (notification/show! (t :cmdk.error/no-block-link) :warning)))
       :else
-      (notification/show! "No link for this search item." :warning))))
+      (notification/show! (t :cmdk.error/no-search-item-link) :warning))))
 
 (defn- keydown-handler
   [state e]
@@ -1023,16 +1041,16 @@
         action (get-action)]
     (cond
       (= action :move-blocks)
-      "Move blocks to"
+      (t :cmdk.input/move-blocks-placeholder)
 
       (= search-mode :graph)
-      "Add graph filter"
+      (t :cmdk.input/add-graph-filter-placeholder)
 
       (= action :new-page)
-      "Type a page name to create"
+      (t :cmdk.input/type-page-name-placeholder)
 
       :else
-      "What are you looking for?")))
+      (t :cmdk.input/default-placeholder))))
 
 (rum/defc input-row
   [state all-items opts]
@@ -1090,16 +1108,18 @@
        :on-composition-end debounced-composition-end
        :default-value input}]]))
 
+(defn- tip-with-shortcut
+  [template shortcut & [shortcut-opts]]
+  (into [:div.flex.flex-row.gap-1.items-center.opacity-50.hover:opacity-100]
+        (i18n/interpolate-rich-text
+         template
+         [(shui/shortcut shortcut shortcut-opts)])))
+
 (defn rand-tip
   []
   (rand-nth
-   [[:div.flex.flex-row.gap-1.items-center.opacity-50.hover:opacity-100
-     [:div "Type"]
-     (shui/shortcut "/")
-     [:div "to filter search results"]]
-    [:div.flex.flex-row.gap-1.items-center.opacity-50.hover:opacity-100
-     (shui/shortcut ["mod" "enter"] {:style :combo})
-     [:div "to open search in the sidebar"]]]))
+   [(tip-with-shortcut (t :cmdk.tip/filter-results) "/")
+    (tip-with-shortcut (t :cmdk.tip/open-sidebar) ["mod" "enter"] {:style :combo})]))
 
 (rum/defcs tip <
   {:init (fn [state]
@@ -1108,10 +1128,7 @@
   (let [filter' @(::filter state)]
     (cond
       filter'
-      [:div.flex.flex-row.gap-1.items-center.opacity-50.hover:opacity-100
-       [:div "Type"]
-       (shui/shortcut "esc")
-       [:div "to clear search filter"]]
+      (tip-with-shortcut (t :cmdk.tip/clear-filter) "esc")
 
       :else
       (::rand-tip inner-state))))
@@ -1137,49 +1154,53 @@
                                  :aria-hidden? true})))]))
 
 (rum/defc hints
-  [state]
-  (let [action (state->action state)
+  [state fallback-item]
+  (let [action (state->action state fallback-item)
         button-fn (fn [text shortcut & {:as opts}]
                     (hint-button text shortcut
                                  {:on-click #(handle-action action (assoc state :opts opts) %)
                                   :muted    true}))]
-    (when action
-      [:div.hints
-       [:div.text-sm.leading-6
-        [:div.flex.flex-row.gap-1.items-center
-         [:div.font-medium.text-gray-12 "Tip:"]
-         (tip state)]]
-
-       [:div.gap-2.hidden.md:flex {:style {:margin-right -6}}
-        (case action
-          :open
-          [:<>
-           (button-fn "Open" ["return"])
-           (button-fn "Open in sidebar" ["shift" "return"] {:open-sidebar? true})
-           (when (:source-block @(::highlighted-item state)) (button-fn "Copy ref" ["cmd" "c"]))]
-
-          :search
-          [:<>
-           (button-fn "Search" ["return"])]
-
-          :trigger
-          [:<>
-           (button-fn "Trigger" ["return"])]
-
-          :create
-          [:<>
-           (button-fn "Create" ["return"])]
-
-          :filter
-          [:<>
-           (button-fn "Filter" ["return"])]
-
-          nil)]])))
+    [:div.hints
+     [:div.text-sm.leading-6
+      [:div.flex.flex-row.gap-1.items-center]
+      [:div.font-medium.text-gray-12 (t :cmdk.tip/label)
+       (tip state)]]
+
+     [:div.gap-2.hidden.md:flex {:style {:margin-right -6}}
+      (case action
+        :open
+        [:<>
+         (button-fn (t :cmdk.action/open) ["return"])
+         (button-fn (t :cmdk.action/open-in-sidebar) ["shift" "return"] {:open-sidebar? true})
+         (when (:source-block (state->highlighted-item state fallback-item))
+           (button-fn (t :cmdk.action/copy-ref) ["cmd" "c"]))]
+
+        :search
+        [:<>
+         (button-fn (t :cmdk.action/search) ["return"])]
+
+        :trigger
+        [:<>
+         (button-fn (t :cmdk.action/trigger) ["return"])]
+
+        :create
+        [:<>
+         (button-fn (t :cmdk.action/create) ["return"])]
+
+        :filter
+        [:<>
+         (button-fn (t :cmdk.action/filter) ["return"])]
+
+        :theme
+        [:<>
+         (button-fn (t :cmdk.action/apply-theme) ["return"])]
+
+        nil)]]))
 
 (rum/defc search-only
   [state group-name]
   [:div.flex.flex-row.gap-1.items-center
-   [:div "Search only:"]
+   [:div (t :cmdk.filter/only-label)]
    [:div group-name]
    (shui/button
     {:variant  :ghost
@@ -1270,7 +1291,7 @@
 
       (when group-filter
         [:div.flex.flex-col.px-3.py-1.opacity-70.text-sm
-         (search-only state (string/capitalize (name group-filter)))])
+         (search-only state (group-label group-filter))])
 
       (let [items (filter
                    (fn [[_group-name group-key group-count _group-items]]
@@ -1288,8 +1309,8 @@
               (result-group state title group-key group-items first-item sidebar?)))
           [:div.flex.flex-col.p-4.opacity-50
            (when-not (string/blank? @*input)
-             "No matched results")]))]
-     (when-not sidebar? (hints state))]))
+             (t :search/no-result))]))]
+     (when-not sidebar? (hints state first-item))]))
 
 (rum/defc cmdk-modal [props]
   [:div {:class "cp__cmdk__modal rounded-lg w-[90dvw] max-w-4xl relative"

+ 39 - 30
src/main/frontend/components/container.cljs

@@ -13,7 +13,7 @@
             [frontend.components.theme :as theme]
             [frontend.components.window-controls :as window-controls]
             [frontend.config :as config]
-            [frontend.context.i18n :refer [t]]
+            [frontend.context.i18n :refer [interpolate-rich-text-node t]]
             [frontend.db :as db]
             [frontend.db-mixins :as db-mixins]
             [frontend.db.async :as db-async]
@@ -192,31 +192,35 @@
       {:on-click state/toggle-document-mode!}
       "D"]
      [:div.p-2
-      [:p.mb-2 [:b "Document mode"]]
+      [:p.mb-2 [:b (t :editor.document-mode/title)]]
       [:ul
        [:li
-        [:div.inline-block.mr-1 (ui/render-keyboard-shortcut (shortcut-dh/gen-shortcut-seq :editor/new-line)
-                                                             :shortcut-id :editor/new-line)]
-        [:p.inline-block "to create new block"]]
-       [:li
-        [:p.inline-block.mr-1 "Click `D` or type"]
-        [:div.inline-block.mr-1 (ui/render-keyboard-shortcut (shortcut-dh/gen-shortcut-seq :ui/toggle-document-mode)
-                                                             :shortcut-id :ui/toggle-document-mode)]
-        [:p.inline-block "to toggle document mode"]]]])))
+        [:p.inline-block.mr-1
+         (interpolate-rich-text-node
+          (t :editor.document-mode/new-block-hint)
+          [[:div.inline-block.mr-1 (ui/render-keyboard-shortcut (shortcut-dh/gen-shortcut-seq :editor/new-line)
+                                                                :shortcut-id :editor/new-line)]])]
+        [:li
+         [:p.inline-block.mr-1
+          (interpolate-rich-text-node
+           (t :editor.document-mode/toggle-desc)
+           [[:div.inline-block.mr-1
+             (ui/render-keyboard-shortcut (shortcut-dh/gen-shortcut-seq :ui/toggle-document-mode)
+                                          :shortcut-id :ui/toggle-document-mode)]])]]]]])))
 
 (def help-menu-items
-  [{:title "Handbook" :icon "book-2" :on-click #(handbooks/toggle-handbooks)}
-   {:title "Keyboard shortcuts" :icon "command" :on-click #(state/sidebar-add-block! (state/get-current-repo) "shortcut-settings" :shortcut-settings)}
-   {:title "Documentation" :icon "help" :href "https://docs.logseq.com/"}
+  [{:title (t :help/handbook) :icon "book-2" :on-click #(handbooks/toggle-handbooks)}
+   {:title (t :help.shortcuts/label) :icon "command" :on-click #(state/sidebar-add-block! (state/get-current-repo) "shortcut-settings" :shortcut-settings)}
+   {:title (t :help/docs) :icon "help" :href "https://docs.logseq.com/"}
    :hr
-   {:title "Report bug" :icon "bug" :on-click #(rfe/push-state :bug-report)}
-   {:title "Request feature" :icon "git-pull-request" :href "https://discuss.logseq.com/c/feedback/feature-requests/"}
-   {:title "Submit feedback" :icon "messages" :href "https://discuss.logseq.com/c/feedback/13"}
+   {:title (t :help/bug) :icon "bug" :on-click #(rfe/push-state :bug-report)}
+   {:title (t :help/feature) :icon "git-pull-request" :href "https://discuss.logseq.com/c/feedback/feature-requests/"}
+   {:title (t :help/submit-feedback) :icon "messages" :href "https://discuss.logseq.com/c/feedback/13"}
    :hr
-   {:title "Ask the community" :icon "brand-discord" :href "https://discord.com/invite/KpN4eHY"}
-   {:title "Support forum" :icon "message" :href "https://discuss.logseq.com/"}
+   {:title (t :help/ask-community) :icon "brand-discord" :href "https://discord.com/invite/KpN4eHY"}
+   {:title (t :help/support-forum) :icon "message" :href "https://discuss.logseq.com/"}
    :hr
-   {:title "Release notes" :icon "asterisk" :href "https://docs.logseq.com/#/page/changelog"}])
+   {:title (t :help/release-notes) :icon "asterisk" :href "https://docs.logseq.com/#/page/changelog"}])
 
 (rum/defc help-menu-popup
   []
@@ -258,13 +262,15 @@
         handbooks-open? (state/sub :ui/handbooks-open?)]
     [:<>
      [:div.cp__sidebar-help-btn
-      [:div.inner
-       {:title (t :help-shortcut-title)
-        :on-click #(state/toggle! :ui/help-open?)}
-       [:svg.scale-125 {:stroke "currentColor", :fill "none", :stroke-linejoin "round", :width "24", :view-box "0 0 24 24", :xmlns "http://www.w3.org/2000/svg", :stroke-linecap "round", :stroke-width "2", :class "icon icon-tabler icon-tabler-help-small", :height "24"}
-        [:path {:stroke "none", :d "M0 0h24v24H0z", :fill "none"}]
-        [:path {:d "M12 16v.01"}]
-        [:path {:d "M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"}]]]]
+      (ui/tooltip
+       [:div.inner
+        {:on-click #(state/toggle! :ui/help-open?)}
+        [:svg.scale-125 {:stroke "currentColor", :fill "none", :stroke-linejoin "round", :width "24", :view-box "0 0 24 24", :xmlns "http://www.w3.org/2000/svg", :stroke-linecap "round", :stroke-width "2", :class "icon icon-tabler icon-tabler-help-small", :height "24"}
+         [:path {:stroke "none", :d "M0 0h24v24H0z", :fill "none"}]
+         [:path {:d "M12 16v.01"}]
+         [:path {:d "M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"}]]]
+       (t :help.shortcuts/desc)
+       {:root-props {:delay-duration 100}})]
 
      (when help-open?
        (help-menu-popup))
@@ -304,7 +310,9 @@
                                                                        (when (context-menu-click-should-hide? target)
                                                                          (shui/popup-hide! id))))
                                                          :data-keep-selection true}
-                                                   content])
+                                                   (if (fn? content)
+                                                     (content {:id id})
+                                                     content)])
                                                 (merge
                                                  {:on-before-hide state/dom-clear-selection!
                                                   :on-after-hide state/state-clear-selection!
@@ -316,7 +324,8 @@
                             (cond
                               (and page (not block-id))
                               (do
-                                (show! (cp-content/page-title-custom-context-menu-content page-entity))
+                                (show! (fn [{:keys [id]}]
+                                         (cp-content/page-title-custom-context-menu-content page-entity id)))
                                 (state/set-state! :page-title/context nil))
 
                               block-ref
@@ -442,7 +451,7 @@
         :on-key-up (fn [e]
                      (when (= "Enter" (.-key e))
                        (ui/focus-element (ui/main-node))))}
-       (t :accessibility/skip-to-main-content)]
+       (t :nav/skip-to-main-content)]
       [:div.#app-container
        {:on-mouse-up on-mouse-up}
        [:div#left-container
@@ -459,7 +468,7 @@
 
         (if (state/sub :rtc/uploading?)
           [:div.flex.items-center.justify-center.full-height-without-header
-           (ui/loading "Creating remote graph...")]
+           (ui/loading (t :sync/creating-remote-graph))]
           (main {:route-match route-match
                  :margin-less-pages? margin-less-pages?
                  :logged? logged?

+ 35 - 29
src/main/frontend/components/content.cljs

@@ -18,6 +18,7 @@
             [frontend.handler.property :as property-handler]
             [frontend.handler.property.util :as pu]
             [frontend.handler.reaction :as reaction-handler]
+            [frontend.modules.shortcut.data-helper :as shortcut-dh]
             [frontend.state :as state]
             [frontend.ui :as ui]
             [frontend.util :as util]
@@ -95,7 +96,7 @@
      (shui/dropdown-menu-item
       {:key "copy"
        :on-click #(editor-handler/copy-selection-blocks true)}
-      (t :editor/copy)
+      (t :ui/copy)
       (ui/dropdown-shortcut :editor/copy))
 
      (shui/dropdown-menu-item
@@ -106,12 +107,12 @@
                             (shui/popup-hide!)
                             (shui/dialog-open!
                              #(export/export-blocks block-uuids {:export-type :selected-nodes}))))}
-      (t :content/copy-export-as))
+      (t :export/copy-or-export-as))
 
      (shui/dropdown-menu-item
       {:key "copy block refs"
        :on-click editor-handler/copy-block-refs}
-      (t :content/copy-block-ref))
+      (t :block/copy-ref))
 
      (shui/dropdown-menu-separator)
 
@@ -174,12 +175,12 @@
           {:key "Open in sidebar"
            :on-click (fn [_e]
                        (editor-handler/open-block-in-sidebar! block-id))}
-          (t :content/open-in-sidebar)
+          (t :sidebar.right/open)
           (ui/dropdown-shortcut "shift+click"))
 
          (shui/dropdown-menu-sub
           (shui/dropdown-menu-sub-trigger
-           "Add reaction")
+           (t :command.editor/add-reaction))
           (shui/dropdown-menu-sub-content
            [:div.p-1
             (icon-component/icon-search
@@ -191,8 +192,8 @@
                                  (reaction-handler/toggle-reaction! block-id emoji-id)
                                  (state/hide-custom-context-menu!)
                                  (shui/popup-hide!))
-                               (notification/show! "Please pick an emoji reaction." :warning))))
-              :tabs [[:emoji "Emojis"]]
+                               (notification/show! (t :block.reaction/emoji-required-warning) :warning))))
+              :tabs [[:emoji (t :icon/tab-emojis)]]
               :default-tab :emoji
               :show-used? true
               :icon-value nil})]))
@@ -230,7 +231,7 @@
           {:key "Copy block ref"
            :on-click (fn [_e]
                        (editor-handler/copy-block-ref! block-id ref/->block-ref))}
-          (t :content/copy-block-ref))
+          (t :block/copy-ref))
 
          ;; TODO Logseq protocol mobile support
          (when (util/electron?)
@@ -241,7 +242,7 @@
                                tap-f (fn [block-id]
                                        (url-util/get-logseq-graph-uuid-url nil current-repo block-id))]
                            (editor-handler/copy-block-ref! block-id tap-f)))}
-            (t :content/copy-block-url)))
+            (t :block/copy-url)))
 
          (when (and (util/electron?) (ldb/asset? block))
            (shui/dropdown-menu-item
@@ -258,7 +259,7 @@
            :on-click (fn [_]
                        (shui/dialog-open!
                         #(export/export-blocks [block-id] {:export-type :block})))}
-          (t :content/copy-export-as))
+          (t :export/copy-or-export-as))
 
          (when-not property-default-value?
            (shui/dropdown-menu-item
@@ -321,29 +322,29 @@
             (shui/dropdown-menu-separator)
             (shui/dropdown-menu-sub
              (shui/dropdown-menu-sub-trigger
-              "Developer tools")
+              (t :context-menu/developer-tools))
 
-             (shui/dropdown-menu-sub-content
-              (shui/dropdown-menu-item
-               {:key "(Dev) Show block data"
+              (shui/dropdown-menu-sub-content
+               (shui/dropdown-menu-item
+               {:key :dev/show-block-data
                 :on-click (fn []
                             (dev-common-handler/show-entity-data [:block/uuid block-id]))}
-               (t :dev/show-block-data))
+               (shortcut-dh/shortcut-desc-by-id :dev/show-block-data))
               (shui/dropdown-menu-item
-               {:key "(Dev) Show block AST"
+               {:key :dev/show-block-ast
                 :on-click (fn []
                             (let [block (db/entity [:block/uuid block-id])]
                               (dev-common-handler/show-content-ast (:block/title block)
                                                                    (get block :block/format :markdown))))}
-               (t :dev/show-block-ast))
+               (shortcut-dh/shortcut-desc-by-id :dev/show-block-ast))
               (shui/dropdown-menu-item
-               {:key "(Dev) Show block content history"
+               {:key :dev/show-block-content-history
                 :on-click
                 (fn []
                   (let [token (state/get-auth-id-token)
                         graph-uuid (ldb/get-graph-rtc-uuid (db/get-db))]
                     (p/let [blocks-versions (state/<invoke-db-worker :thread-api/rtc-get-block-content-versions token graph-uuid block-id)]
-                      (prn :Dev-show-block-content-history)
+                      (prn :dev/show-block-content-history)
                       (doseq [[block-uuid versions] blocks-versions]
                         (prn :block-uuid block-uuid)
                         (pp/print-table [:content :created-at]
@@ -351,7 +352,6 @@
                                                {:created-at (tc/from-long (* (:created-at version) 1000))
                                                 :content (:value version)})
                                              versions))))))}
-
                "(Dev) Show block content history")))])]))))
 
 (rum/defc block-ref-custom-context-menu-content
@@ -365,32 +365,38 @@
                     (state/get-current-repo)
                     block-ref-id
                     :block-ref))}
-      (t :content/open-in-sidebar)
+      (t :sidebar.right/open)
       (ui/dropdown-shortcut "shift+click"))
      (shui/dropdown-menu-item
       {:key "copy"
        :on-click (fn [] (editor-handler/copy-current-ref block-ref-id))}
-      (t :content/copy-ref))
+      (t :reference/copy))
      (shui/dropdown-menu-item
       {:key "delete"
        :on-click (fn [] (editor-handler/delete-current-ref! block block-ref-id))}
-      (t :content/delete-ref))
+      (t :reference/delete))
      (shui/dropdown-menu-item
       {:key "replace-with-text"
        :on-click (fn [] (editor-handler/replace-ref-with-text! block block-ref-id))}
-      (t :content/replace-with-text))
+      (t :reference/replace-with-text))
      (shui/dropdown-menu-item
       {:key "replace-with-embed"
        :on-click (fn [] (editor-handler/replace-ref-with-embed! block block-ref-id))}
-      (t :content/replace-with-embed))]))
+      (t :reference/replace-with-embed))]))
 
 (rum/defc page-title-custom-context-menu-content
-  [page]
+  [page popup-id]
   (when page
     (let [page-menu-options (page-menu/page-menu page)]
       [:<>
        (for [{:keys [title options]} page-menu-options]
-         (shui/dropdown-menu-item options title))])))
+         (let [on-click (:on-click options)]
+           (shui/dropdown-menu-item
+            (assoc options
+                   :on-click (fn [e]
+                               (when-not (false? (when on-click (on-click e)))
+                                 (shui/popup-hide! popup-id))))
+            title)))])))
 
 ;; TODO: content could be changed
 ;; Also, keyboard bindings should only be activated after
@@ -400,7 +406,7 @@
   [:div {:id id}
    (if hiccup
      hiccup
-     [:div.cursor (t :content/click-to-edit)])])
+     [:div.cursor (t :editor/click-to-edit)])])
 
 (rum/defc non-hiccup-content
   [id content on-click on-hide config format]
@@ -421,7 +427,7 @@
          {:id id
           :on-click on-click}
          (if (string/blank? content)
-           [:div.cursor (t :content/click-to-edit)]
+           [:div.cursor (t :editor/click-to-edit)]
            content)]))))
 
 (rum/defcs content < rum/reactive

+ 2 - 1
src/main/frontend/components/db_based/page.cljs

@@ -1,6 +1,7 @@
 (ns frontend.components.db-based.page
   "Page components only for DB graphs"
   (:require [frontend.components.property.config :as property-config]
+            [frontend.context.i18n :refer [t]]
             [frontend.db :as db]
             [frontend.db-mixins :as db-mixins]
             [frontend.util :as util]
@@ -23,4 +24,4 @@
                                      :align "start"
                                      :as-dropdown? true
                                      :dropdown-menu? true}))}
-     "Configure property")))
+     (t :property/configure))))

+ 11 - 17
src/main/frontend/components/e2ee.cljs

@@ -1,6 +1,7 @@
 (ns frontend.components.e2ee
   (:require [clojure.string :as string]
             [frontend.common.crypt :as crypt]
+            [frontend.context.i18n :refer [t]]
             [frontend.state :as state]
             [frontend.ui :as ui]
             [frontend.util :as util]
@@ -19,27 +20,20 @@
                     (shui/dialog-close!))]
     [:div.e2ee-password-modal-overlay
      [:div.encryption-password.max-w-2xl.e2ee-password-modal-content.flex.flex-col.gap-8.p-4
-      [:div.text-2xl.font-medium "Set password for remote graphs"]
+      [:div.text-2xl.font-medium (t :encryption/set-password-title)]
 
       [:div.init-remote-pw-tips.space-x-4.hidden.sm:flex
        [:div.flex-1.flex.items-center
         [:span.px-3.flex (ui/icon "key")]
-        [:p
-         [:span "Please make sure you "]
-         "remember the password you have set, as we are unable to reset or retrieve it in case you forget it, "
-         [:span "and we recommend you "]
-         "keep a secure backup "
-         [:span "of the password."]]]
+        [:p (t :encryption/remember-password-rich)]]
 
        [:div.flex-1.flex.items-center
         [:span.px-3.flex (ui/icon "lock")]
-        [:p
-         "If you lose your password, all of your data in the cloud can’t be decrypted. "
-         [:span "You will still be able to access the local version of your graph."]]]]
+        [:p (t :encryption/cloud-password-rich)]]]
 
       [:div.flex.flex-col.gap-4
        (shui/toggle-password
-        {:placeholder "Enter password"
+        {:placeholder (t :encryption/enter-password)
          :value password
          :on-change (fn [e] (set-password! (-> e .-target .-value)))
          :on-blur (fn []
@@ -48,20 +42,20 @@
 
        [:div.flex.flex-col.gap-2
         (shui/toggle-password
-         {:placeholder "Enter password again"
+         {:placeholder (t :encryption/enter-password-again)
           :value password-confirm
           :on-change (fn [e] (set-password-confirm! (-> e .-target .-value)))
           :on-blur (fn [] (set-matched! (= password-confirm password)))})
 
         (when (false? matched?)
           [:div.text-warning.text-sm
-           "Password not matched"])]
+           (t :encryption/password-not-matched)])]
 
        (shui/button
         {:on-click on-submit
          :disabled (or (string/blank? password)
                        (false? matched?))}
-        "Submit")]]]))
+        (t :ui/submit))]]]))
 
 (rum/defc e2ee-password-to-decrypt-private-key
   [encrypted-private-key private-key-promise refresh-token]
@@ -78,7 +72,7 @@
                                   (set-decrypt-fail! true))))))]
     [:div.e2ee-password-modal-overlay
      [:div.e2ee-password-modal-content.flex.flex-col.gap-8.p-4
-      [:div.text-2xl.font-medium "Enter password for remote graphs"]
+      [:div.text-2xl.font-medium (t :encryption/enter-password-title)]
       [:div.flex.flex-col.gap-4
        [:div.flex.flex-col.gap-1
         (shui/toggle-password
@@ -89,11 +83,11 @@
           :on-change (fn [e]
                        (set-decrypt-fail! false)
                        (set-password! (-> e .-target .-value)))})
-        (when decrypt-fail? [:p.text-warning.text-sm "Wrong password"])]
+          (when decrypt-fail? [:p.text-warning.text-sm (t :encryption/wrong-password)])]
        (shui/button
         {:on-click on-submit
          :disabled (string/blank? password)
          :on-key-press (fn [e]
                          (when (= "Enter" (util/ekey e))
                            (on-submit)))}
-        "Submit")]]]))
+         (t :ui/submit))]]]))

+ 21 - 20
src/main/frontend/components/editor.cljs

@@ -40,11 +40,14 @@
 (defn filter-commands
   [page? commands]
   (if page?
-    (filter (fn [item]
-              (or
-               (= "Add new property" (first item))
-               (when (= (count item) 5)
-                 (contains? #{"TASK STATUS" "TASK DATE" "PRIORITY"} (last item))))) commands)
+    (let [task-groups #{(t :editor.slash/group-task-status)
+                        (t :editor.slash/group-task-date)
+                        (t :editor.slash/group-priority)}]
+      (filter (fn [item]
+                (or
+                 (= (t :command.editor/add-property) (first item))
+                 (when (= (count item) 5)
+                   (contains? task-groups (last item))))) commands))
     commands))
 
 (defn node-render
@@ -70,8 +73,8 @@
              (:nlp-date? block')
              (ui/icon "calendar" {:size 14})
 
-             (or (string/starts-with? (str (:block/title block')) (t :new-tag))
-                 (string/starts-with? (str (:block/title block')) (t :new-page)))
+             (or (string/starts-with? (str (:block/title block')) (t :editor/new-tag))
+                 (string/starts-with? (str (:block/title block')) (t :editor/new-page)))
              (ui/icon "plus" {:size 14})
 
              :else
@@ -79,8 +82,8 @@
 
         (let [title (let [alias (get-in block' [:alias :block/title])]
                       (block-handler/block-unique-title block' {:alias alias}))]
-          (if (or (string/starts-with? title (t :new-tag))
-                  (string/starts-with? title (t :new-page)))
+          (if (or (string/starts-with? title (t :editor/new-tag))
+                  (string/starts-with? title (t :editor/new-page)))
             title
             (block-handler/block-title-with-icon block'
                                                  (search-handler/highlight-exact-query title q)
@@ -184,9 +187,9 @@
        ;; Don't show 'New tag' for an internal page because it already shows 'Convert ...'
          (when-not (let [entity (db/get-page q)]
                      (and (ldb/internal-page? entity) (= (:block/title entity) q)))
-           [{:block/title (str (t :new-tag) " " q)}])
+           [{:block/title (str (t :editor/new-tag) " " q)}])
          partial-matched-pages)
-        (cons {:block/title (str (t :new-page) " " q)}
+        (cons {:block/title (str (t :editor/new-page) " " q)}
               partial-matched-pages)))))
 
 (defn- search-pages
@@ -202,7 +205,7 @@
                                 :db/id (:db/id block)
                                 :block/uuid (:block/uuid block)
                                 :convert-page-to-tag? true
-                                :friendly-title (util/format "Convert \"%s\" to tag" q)} classes)
+                                :friendly-title (t :page.convert/page-to-tag-action q)} classes)
                          classes))
                      (editor-handler/<get-matched-blocks q {:nlp-pages? true
                                                             :page-only? false}))]
@@ -218,9 +221,7 @@
     (let [matched-pages' (if (string/blank? q)
                            (if db-tag?
                              (db-model/get-all-classes (state/get-current-repo) {:except-root-class? true})
-                             (->> (map (fn [title] {:block/title title
-                                                    :nlp-date? true})
-                                       date/nlp-pages)
+                             (->> (date/nlp-pages-i18n :nlp-date? true)
                                   (take 10)))
                            ;; reorder, shortest and starts-with first.
                            (if (and (seq matched-pages)
@@ -237,8 +238,8 @@
          :item-render (fn [block _chosen?]
                         (node-render block q {:db-tag? db-tag?}))
          :empty-placeholder [:div.text-gray-500.text-sm.px-4.py-2 (if db-tag?
-                                                                    "Search for a tag"
-                                                                    "Search for a node")]
+                                                                    (t :editor/search-for-tag)
+                                                                    (t :editor/search-for-node))]
          :class "black"})
 
        (when (and db-tag?
@@ -246,7 +247,7 @@
                   (not= "page" (string/lower-case q)))
          [:p.px-1.opacity-50.text-sm.flex.flex-row.items-center.gap-2
           (shui/shortcut "mod+enter")
-          [:span " to display this tag inline instead of at the end of this node."]])])))
+          [:span (t :editor/display-tag-inline-hint)]])])))
 
 (rum/defcs page-search < rum/reactive
   {:init (fn [state]
@@ -368,7 +369,7 @@
      matched-templates
      {:on-chosen   (editor-handler/template-on-chosen-handler id)
       :on-enter    (fn [_state] (state/clear-editor-action!))
-      :empty-placeholder [:div.text-gray-500.px-4.py-2.text-sm "Search for a template"]
+      :empty-placeholder [:div.text-gray-500.px-4.py-2.text-sm (t :editor/search-template-placeholder)]
       :item-render (fn [template]
                      (:block/title template))
       :class       "black"})))
@@ -471,7 +472,7 @@
                 placeholder
                 (assoc :placeholder placeholder))))
            (ui/button
-            "Submit"
+            (t :ui/submit)
             :on-click
             (fn [e]
               (util/stop e)

+ 54 - 53
src/main/frontend/components/export.cljs

@@ -3,7 +3,7 @@
             [cljs-time.core :as t]
             [cljs.pprint :as pprint]
             [frontend.config :as config]
-            [frontend.context.i18n :refer [t]]
+            [frontend.context.i18n :refer [interpolate-rich-text-node interpolate-sentence t]]
             [frontend.db :as db]
             [frontend.handler.block :as block-handler]
             [frontend.handler.db-based.export :as db-export-handler]
@@ -32,31 +32,31 @@
         repo (state/get-current-repo)]
     [:div.flex.flex-col.gap-4
      [:div.font-medium.opacity-50
-      "Schedule backup"]
+      (t :export.backup/schedule)]
      (if (utils/nfsSupported)
        [:<>
         (if backup-folder
           [:div.flex.flex-row.items-center.gap-1.text-sm
-               [:div.opacity-50 "Backup folder:"]
+           [:div.opacity-50 (t :export.backup/folder)]
            backup-folder
            (shui/button
             {:variant :ghost
              :class "!px-1 !py-1"
-             :title "Change backup folder"
+             :title (t :export.backup/cancel)
              :on-click (fn []
                          (p/do!
                           (db/transact! [[:db/retractEntity :logseq.kv/graph-backup-folder]])
                           (reset! *backup-folder nil)))
              :size :sm}
-            (ui/icon "edit"))]
+            (ui/icon "x"))]
           (shui/button
            {:variant :default
             :on-click (fn []
                         (p/let [[folder-name _handle] (export/choose-backup-folder repo)]
                           (reset! *backup-folder folder-name)))}
-           "Set backup folder first"))
+           (t :export.backup/set-folder-first)))
         [:div.opacity-50.text-sm
-         "Backup will be created every hour."]
+         (t :export.backup/hourly-note)]
 
         (when backup-folder
           (shui/button
@@ -66,67 +66,68 @@
                          (p/let [result (export/backup-db-graph repo)]
                            (case result
                              true
-                             (notification/show! "Backup successful!" :success)
+                             (notification/show! (t :export/backup-successful) :success)
                              :graph-not-changed
-                             (notification/show! "Graph has not been updated since last export." :success)
+                             (notification/show! (t :export/no-updates-since-last-export) :success)
                              nil)
                            (export/auto-db-backup! repo))
                          (p/catch (fn [error]
                                     (println "Failed to backup.")
                                     (js/console.error error)))))}
-           "Backup now"))]
+           (t :export.backup/backup-now)))]
        [:div
-        [:span "Your browser doesn't support "]
-        [:a
-         {:href "https://developer.chrome.com/docs/capabilities/web-apis/file-system-access"
-          :target "_blank"}
-         "The File System Access API"]
-        [:span ", please switch to a Chromium-based browser."]])]))
+        [:span
+         (interpolate-sentence
+          (t :export.backup/unsupported-desc)
+          :links [{:href "https://developer.chrome.com/docs/capabilities/web-apis/file-system-access"
+                   :target "_blank"}])]])]))
 
 (rum/defc export
   []
   (when-let [current-repo (state/get-current-repo)]
     [:div.export
-     [:h1.title.mb-8 (t :export)]
+     [:h1.title.mb-8 (t :export/title)]
 
      [:div.flex.flex-col.gap-4.ml-1
       [:div
        [:a.font-medium {:on-click #(export/export-repo-as-sqlite-db! current-repo)}
-        (t :export-sqlite-db)]
-       [:p.text-sm.opacity-70.mb-0 "Primary way to backup graph's content to a single .sqlite file."]]
+        (t :export/sqlite-db)]
+       [:p.text-sm.opacity-70.mb-0 (t :export.backup/sqlite-desc)]]
       [:div
        [:a.font-medium {:on-click #(export/export-repo-as-zip! current-repo)}
-        (t :export-zip)]
-       [:p.text-sm.opacity-70.mb-0 "Primary way to backup graph's content and assets to a .zip file."]]
+        (t :export/zip)]
+       [:p.text-sm.opacity-70.mb-0 (t :export.backup/zip-desc)]]
 
       (when-not (util/mobile?)
         [:div
          [:a.font-medium {:on-click #(db-export-handler/export-repo-as-db-edn! current-repo)}
-          (t :export-db-edn)]
-         [:p.text-sm.opacity-70.mb-0 "Exports to a readable and editable .edn file. Don't rely on this as a primary backup."]])
+          (t :export/db-edn)]
+         [:p.text-sm.opacity-70.mb-0 (t :export/edn-desc)]])
       (when-not (mobile-util/native-platform?)
         [:div
          [:a.font-medium {:on-click #(export-text/export-repo-as-markdown! current-repo)}
-          (t :export-markdown)]])
+          (t :export/markdown)]])
 
       (when (util/electron?)
         [:div
          [:a.font-medium {:on-click #(export/download-repo-as-html! current-repo)}
-          (t :export-public-pages)]])
+          (t :export/public-pages)]])
 
       [:div
        [:a.font-medium {:on-click #(export/export-repo-as-debug-transit! current-repo)}
-        "Export debug transit file"]
-       [:p.text-sm.opacity-70.mb-0 "Exports to a .transit file to send to us for debugging. Any sensitive data will be removed in the exported file."]]
+        (t :export/debug-transit-file)]
+       [:p.text-sm.opacity-70.mb-0 (t :export/debug-transit-desc)]]
 
       (if (util/electron?)
         [:div
          [:hr]
-         [:div "Hourly backups are enabled for this graph, "
-          [:a.ml-1 {:on-click (fn []
-                                (let [path (config/get-electron-backup-dir (state/get-current-repo))]
-                                  (js/window.apis.openPath path)))}
-           "open backups folder for this graph"]]]
+         [:div
+          (interpolate-rich-text-node
+           (t :export.backup/enabled-desc)
+           [[:a.ml-1 {:on-click (fn []
+                                  (let [path (config/get-electron-backup-dir (state/get-current-repo))]
+                                    (js/window.apis.openPath path)))}
+             (t :export.backup/open-folder)]])]]
         (when (and util/web-platform?
                    (not (util/mobile?)))
           [:div
@@ -135,11 +136,11 @@
 
 (def *export-block-type (atom :text))
 
-(def text-indent-style-options [{:label "dashes"
+(def text-indent-style-options [{:title-key :export/indent-style-dashes
                                  :selected false}
-                                {:label "spaces"
+                                {:title-key :export/indent-style-spaces
                                  :selected false}
-                                {:label "no-indent"
+                                {:title-key :export/indent-style-none
                                  :selected false}])
 
 (defn- export-helper
@@ -250,7 +251,7 @@
      {:class "-m-5"}
      [:div.p-6
       [:div.flex.pb-3
-       (ui/button "Text"
+       (ui/button (t :export/format-text)
                   :class "mr-4 w-20"
                   :on-click #(do (reset! *export-block-type :text)
                                  (reset! *content (export-helper top-level-uuids))))
@@ -280,26 +281,26 @@
       (if (= :png tp)
         [:div.flex.items-center.justify-center.relative
          (when (not @*content) [:div.absolute (ui/loading "")])
-         [:img {:alt "export preview" :id "export-preview" :class "my-4" :style {:visibility (when (not @*content) "hidden")}}]]
+        [:img {:alt (t :export/preview-alt) :id "export-preview" :class "my-4" :style {:visibility (when (not @*content) "hidden")}}]]
 
         [:textarea.overflow-y-auto.h-96 {:value @*content :read-only true}])
 
       (if (= :png tp)
         [:div.flex.items-center
-         [:div (t :export-transparent-background)]
+         [:div (t :export/transparent-background)]
          (ui/checkbox {:class "mr-2 ml-4"
                        :on-change (fn [e]
                                     (reset! *content nil)
                                     (get-image-blob top-level-uuids (merge options {:transparent-bg? e.currentTarget.checked}) (fn [blob] (reset! *content blob))))})]
         (let [options (->> text-indent-style-options
                            (mapv (fn [opt]
-                                   (if (= @*text-indent-style (:label opt))
+                                   (if (= @*text-indent-style (:title-key opt))
                                      (assoc opt :selected true)
                                      opt))))]
           [:div [:div.flex.items-center
                  [:label.mr-4
                   {:style {:visibility (if (= :text tp) "visible" "hidden")}}
-                  "Indentation style:"]
+                  (t :export/indent-style-label)]
                  [:select.block.my-2.text-lg.rounded.border.py-0.px-1
                   {:style {:visibility (if (= :text tp) "visible" "hidden")}
                    :on-change (fn [e]
@@ -307,13 +308,13 @@
                                   (state/set-export-block-text-indent-style! value)
                                   (reset! *text-indent-style value)
                                   (reset! *content (export-helper top-level-uuids))))}
-                  (for [{:keys [label value selected]} options]
+                  (for [{:keys [title-key value selected]} options]
                     [:option (cond->
-                              {:key label
-                               :value (or value label)}
+                              {:key title-key
+                               :value (or value (name title-key))}
                                selected
                                (assoc :selected selected))
-                     label])]]
+                     (t title-key)])]]
            [:div.flex.items-center
             (ui/checkbox {:class "mr-2"
                           :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
@@ -323,7 +324,7 @@
                                        (reset! *text-remove-options (state/get-export-block-text-remove-options))
                                        (reset! *content (export-helper top-level-uuids)))})
             [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
-             "[[text]] -> text"]
+             (t :export/page-ref-text)]
 
             (ui/checkbox {:class "mr-2 ml-4"
                           :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
@@ -334,7 +335,7 @@
                                        (reset! *content (export-helper top-level-uuids)))})
 
             [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
-             "remove emphasis"]
+             (t :export/remove-emphasis)]
 
             (ui/checkbox {:class "mr-2 ml-4"
                           :style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
@@ -345,7 +346,7 @@
                                        (reset! *content (export-helper top-level-uuids)))})
 
             [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
-             "remove #tags"]]
+             (t :export/remove-tags)]]
 
            [:div.flex.items-center
             (ui/checkbox {:class "mr-2"
@@ -357,7 +358,7 @@
                                        (reset! *text-other-options (state/get-export-block-text-other-options))
                                        (reset! *content (export-helper top-level-uuids)))})
             [:div {:style {:visibility (if (#{:text} tp) "visible" "hidden")}}
-             "newline after block"]
+             (t :export/newline-after-block)]
 
             (ui/checkbox {:class "mr-2 ml-4"
                           :style {:visibility (if (#{:text} tp) "visible" "hidden")}
@@ -367,7 +368,7 @@
                                        (reset! *text-remove-options (state/get-export-block-text-remove-options))
                                        (reset! *content (export-helper top-level-uuids)))})
             [:div {:style {:visibility (if (#{:text} tp) "visible" "hidden")}}
-             "remove properties"]]
+             (t :export/remove-properties)]]
 
            [:div.flex.items-center
             (ui/checkbox {:class "mr-2"
@@ -379,11 +380,11 @@
                                        (reset! *text-other-options (state/get-export-block-text-other-options))
                                        (reset! *content (export-helper top-level-uuids)))})
             [:div {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
-             "open blocks only (skip collapsed children)"]]
+             (t :export/open-blocks-only)]]
 
            [:div.flex.items-center
             [:label.mr-2 {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}}
-             "level <="]
+             (t :export/level-lte)]
             [:select.block.my-2.text-lg.rounded.border.px-2.py-0
              {:style {:visibility (if (#{:text :html :opml} tp) "visible" "hidden")}
               :value (or (:keep-only-level<=N @*text-other-options) :all)
@@ -398,14 +399,14 @@
 
       (when @*content
         [:div.mt-4.flex.flex-row.gap-2
-         (ui/button (if @*copied? (t :export-copied-to-clipboard) (t :export-copy-to-clipboard))
+         (ui/button (if @*copied? (t :export/copied-to-clipboard) (t :ui/copy-to-clipboard))
                     :class "mr-4"
                     :on-click (fn []
                                 (if (= tp :png)
                                   (js/navigator.clipboard.write [(js/ClipboardItem. #js {"image/png" @*content})])
                                   (util/copy-to-clipboard! @*content :html (when (= tp :html) @*content)))
                                 (reset! *copied? true)))
-         (ui/button (t :export-save-to-file)
+         (ui/button (t :export/save-to-file)
                     :on-click #(let [file-name (if (uuid? top-level-uuids)
                                                  (-> (db/get-page top-level-uuids)
                                                      (util/get-page-title))

+ 1 - 1
src/main/frontend/components/file.cljs

@@ -63,7 +63,7 @@
   []
   [:div.flex-1.overflow-hidden
    [:h1.title
-    (t :all-files)]
+    (t :nav/all-files)]
    (files-all)])
 
 ;; FIXME: misuse of rpath and fpath

+ 5 - 4
src/main/frontend/components/filepicker.cljs

@@ -1,9 +1,10 @@
 (ns frontend.components.filepicker
   "File picker"
-  (:require [rum.core :as rum]
+  (:require [cljs-drag-n-drop.core :as dnd]
+            [frontend.context.i18n :refer [t]]
+            [goog.dom :as gdom]
             [logseq.shui.ui :as shui]
-            [cljs-drag-n-drop.core :as dnd]
-            [goog.dom :as gdom]))
+            [rum.core :as rum]))
 
 (rum/defcs picker <
   (rum/local nil ::input)
@@ -43,4 +44,4 @@
                                              :height 28}})]
         [:div {:class "flex flex-col gap-px"}
          [:div {:class "font-medium text-muted-foreground"}
-          "Drag 'n' drop files here, or click to select files"]]]]]]))
+          (t :asset/drop-hint)]]]]]]))

+ 7 - 6
src/main/frontend/components/find_in_page.cljs

@@ -1,5 +1,6 @@
 (ns frontend.components.find-in-page
   (:require [rum.core :as rum]
+            [frontend.context.i18n :refer [t]]
             [frontend.ui :as ui]
             [frontend.state :as state]
             [frontend.util :as util]
@@ -27,8 +28,8 @@
     [:div.flex.w-48.relative
      [:input#search-in-page-input.form-input.block.sm:text-sm.sm:leading-5.my-2.border-none.mr-4.outline-none
       {:auto-focus true
-       :placeholder "Find in page"
-       :aria-label "Find in page"
+       :placeholder (t :search.find-in-page/input-placeholder)
+       :aria-label (t :search.find-in-page/input-placeholder)
        :value q
        :on-composition-start on-change-fn
        :on-composition-end on-change-fn
@@ -61,7 +62,7 @@
                 (debounced-search))
     :intent "link"
     :small? true
-    :title "Match case"
+      :title (t :search.find-in-page/match-case)
     :class (str (when match-case? "active ") "text-lg"))
 
    (ui/button
@@ -72,7 +73,7 @@
     :intent "link"
     :small? true
     :class "text-lg"
-    :title "Previous result")
+    :title (t :search.find-in-page/previous-result))
 
    (ui/button
     (ui/icon "caret-down")
@@ -82,7 +83,7 @@
     :intent "link"
     :small? true
     :class "text-lg"
-    :title "Next result")
+    :title (t :search.find-in-page/next-result))
 
    (ui/button
     (ui/icon "x")
@@ -91,7 +92,7 @@
     :intent "link"
     :small? true
     :class "text-lg"
-    :title "Close")])
+    :title (t :ui/close))])
 
 (rum/defc search < rum/reactive
   []

+ 68 - 63
src/main/frontend/components/header.cljs

@@ -16,7 +16,7 @@
             [frontend.components.settings :as settings]
             [frontend.components.svg :as svg]
             [frontend.config :as config]
-            [frontend.context.i18n :refer [t]]
+            [frontend.context.i18n :as i18n :refer [t]]
             [frontend.db :as db]
             [frontend.handler :as handler]
             [frontend.handler.db-based.rtc-flows :as rtc-flows]
@@ -44,7 +44,7 @@
   < {:key-fn #(identity "home-button")}
   []
   (shui/button-ghost-icon :home
-                          {:title (t :home)
+                          {:title (t :nav/home)
                            :on-click #(do
                                         (when (mobile-util/native-iphone?)
                                           (state/set-left-sidebar-open! false))
@@ -74,7 +74,7 @@
                                {:on-click #(shui/dialog-open!
                                             (fn []
                                               [:div.p-2.-mb-8
-                                               [:h1.text-3xl.-mt-2.-ml-2 "Collaborators:"]
+                                               [:h1.text-3xl.-mt-2.-ml-2 (t :collaboration/members)]
                                                (settings/settings-collaboration)])
                                             {:id :rtc-collaborators})})
 
@@ -98,9 +98,9 @@
   [{:keys [on-click]}]
   (ui/with-shortcut :ui/toggle-left-sidebar "bottom"
     [:button.#left-menu.cp__header-left-menu.button.icon
-     {:title (t :header/toggle-left-sidebar)
-      :on-click on-click}
-     (ui/icon "menu-2" {:size ui/icon-size})]))
+     {:on-click on-click}
+     (ui/icon "menu-2" {:size ui/icon-size})]
+    (t :header/toggle-left-sidebar)))
 
 (defn bug-report-url []
   (let [ua (.-userAgent js/navigator)
@@ -137,7 +137,7 @@
                                       (if favorited?
                                         (page-handler/<unfavorite-page! block-id-str)
                                         (page-handler/<favorite-page! block-id-str)))}}
-                         {:title   "Publish page"
+                         {:title   (t :publish/dialog-title)
                           :options {:on-click #(shui/dialog-open! (fn [] (page-menu/publish-page-dialog page))
                                                                   {:class "w-auto max-w-md"})}}])))
         page-menu-and-hr (concat page-menu [{:hr true}])
@@ -145,41 +145,41 @@
         items (fn []
                 (->>
                  [(when (state/enable-editing?)
-                    {:title (t :settings)
+                    {:title (t :nav/settings)
                      :options {:on-click state/open-settings!}
                      :icon (ui/icon "settings")})
 
                   (when config/lsp-enabled?
-                    {:title (t :plugins)
+                    {:title (t :nav/plugins)
                      :options {:on-click #(plugin-handler/goto-plugins-dashboard!)}
                      :icon (ui/icon "apps")})
 
-                  {:title (t :appearance)
+                  {:title (t :nav/appearance)
                    :options {:on-click #(state/pub-event! [:ui/toggle-appearance])}
                    :icon (ui/icon "color-swatch")}
 
                   (when (db/get-page common-config/recycle-page-name)
-                    {:title "Recycle"
+                    {:title (t :storage.recycle/title)
                      :options {:on-click page-handler/open-recycle!}
                      :icon (ui/icon "trash")})
 
                   (when current-repo
-                    {:title (t :export-graph)
+                    {:title (t :export/graph)
                      :options {:on-click #(shui/dialog-open! export/export)}
                      :icon (ui/icon "database-export")})
 
                   (when (and current-repo (state/enable-editing?))
-                    {:title (t :import)
+                    {:title (t :import/title)
                      :options {:href (rfe/href :import)}
                      :icon (ui/icon "file-upload")})
 
                   (when config/publishing?
-                    {:title (t :toggle-theme)
+                    {:title (t :ui/toggle-theme)
                      :options {:on-click #(state/toggle-theme!)}
                      :icon (ui/icon "bulb")})
 
                   (when-not (or config/publishing? login?)
-                    {:title (t :login)
+                    {:title (t :ui/login)
                      :options {:on-click #(state/pub-event! [:user/login])}
                      :icon (ui/icon "user")})
 
@@ -189,57 +189,61 @@
                             [:b.leading-none (user-handler/username)]
                             [:small.opacity-70 (user-handler/email)]
                             [:i.absolute.opacity-0.group-hover:opacity-100.text-red-rx-09
-                             {:class "right-1 top-3" :title (t :logout)}
+                             {:class "right-1 top-3" :title (t :ui/logout)}
                              (ui/icon "logout")]]
                      :options {:on-click #(user-handler/logout)
                                :class "w-full"}})]
                  (concat page-menu-and-hr)
                  (remove nil?)))]
 
-    (shui/button-ghost-icon :dots
-                            {:title (t :header/more)
-                             :class "toolbar-dots-btn"
-                             :on-pointer-down (fn [^js e]
-                                                (shui/popup-show! (.-target e)
-                                                                  (fn [{:keys [id]}]
-                                                                    (for [{:keys [hr item title options icon]} (items)]
-                                                                      (let [on-click' (:on-click options)
-                                                                            href (:href options)]
-                                                                        (if hr
-                                                                          (shui/dropdown-menu-separator)
-                                                                          (shui/dropdown-menu-item
-                                                                           (assoc options
-                                                                                  :on-click (fn [^js e]
-                                                                                              (when on-click'
-                                                                                                (when-not (false? (on-click' e))
-                                                                                                  (shui/popup-hide! id)))))
-                                                                           (or item
-                                                                               (if href
-                                                                                 [:a.flex.items-center.w-full
-                                                                                  {:href href :on-click #(shui/popup-hide! id)
-                                                                                   :style {:color "inherit"}}
-                                                                                  [:span.flex.items-center.gap-1.w-full
-                                                                                   icon [:div title]]]
-                                                                                 [:span.flex.items-center.gap-1.w-full
-                                                                                  icon [:div title]])))))))
-                                                                  {:align "end"
-                                                                   :as-dropdown? true
-                                                                   :content-props {:class "w-64"
-                                                                                   :align-offset -32}}))})))
+    (ui/tooltip
+     (shui/button-ghost-icon
+      :dots {:class "toolbar-dots-btn"
+             :on-pointer-down (fn [^js e]
+                                (shui/popup-show! (.-target e)
+                                                  (fn [{:keys [id]}]
+                                                    (for [{:keys [hr item title options icon]} (items)]
+                                                      (let [on-click' (:on-click options)
+                                                            href (:href options)]
+                                                        (if hr
+                                                          (shui/dropdown-menu-separator)
+                                                          (shui/dropdown-menu-item
+                                                           (assoc options
+                                                                  :on-click (fn [^js e]
+                                                                              (when on-click'
+                                                                                (when-not (false? (on-click' e))
+                                                                                  (shui/popup-hide! id)))))
+                                                           (or item
+                                                               (if href
+                                                                 [:a.flex.items-center.w-full
+                                                                  {:href href :on-click #(shui/popup-hide! id)
+                                                                   :style {:color "inherit"}}
+                                                                  [:span.flex.items-center.gap-1.w-full
+                                                                   icon [:div title]]]
+                                                                 [:span.flex.items-center.gap-1.w-full
+                                                                  icon [:div title]])))))))
+                                                  {:align "end"
+                                                   :as-dropdown? true
+                                                   :content-props {:class "w-64"
+                                                                   :align-offset -32}}))})
+     (t :header/more)
+     {:trigger-props {:as-child true}})))
 
 (rum/defc back-and-forward
   < {:key-fn #(identity "nav-history-buttons")}
   []
   [:div.flex.flex-row
    (ui/with-shortcut :go/backward "bottom"
-     (shui/button-ghost-icon :arrow-left
-                             {:title (t :header/go-back) :on-click #(js/window.history.back)
-                              :class "it navigation nav-left"}))
+     (shui/button-ghost-icon
+      :arrow-left {:on-click #(js/window.history.back)
+                   :class "it navigation nav-left"})
+     (t :header/go-back))
 
    (ui/with-shortcut :go/forward "bottom"
-     (shui/button-ghost-icon :arrow-right
-                             {:title (t :header/go-forward) :on-click #(js/window.history.forward)
-                              :class "it navigation nav-right"}))])
+     (shui/button-ghost-icon
+      :arrow-right {:on-click #(js/window.history.forward)
+                    :class "it navigation nav-right"})
+     (t :header/go-forward))])
 
 (rum/defc updater-tips-new-version
   [t]
@@ -268,7 +272,7 @@
 
     (when downloaded
       [:div.cp__header-tips
-       [:p (t :updater/new-version-install)
+       [:p (t :updater/update-ready-to-install)
         [:a.restart.ml-2
          {:on-click #(handler/quit-and-install-new-version!)}
          (svg/reload 16) [:strong (t :updater/quit-and-install)]]]])))
@@ -329,13 +333,13 @@
            :class "block h-4 w-4 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none"}))
         (shui/tooltip-content
          {:onPointerDownOutside (fn [e] (.preventDefault e))}
-         (str "Highlight recent blocks"
-              (when (not= recent-days 0)
-                (str ": " recent-days " days ago")))))))
+         (if (zero? recent-days)
+           (t :header/highlight-recent-blocks)
+           (t :header/highlight-recent-blocks-days-ago recent-days))))))
      (shui/button
       {:variant :ghost
        :size :sm
-       :title "Quit highlight recent blocks"
+       :title (t :header/quit-highlight-recent-blocks)
        :class "opacity-50 hover:opacity-100"
        :on-click (fn [] (state/toggle-highlight-recent-blocks!))}
       (ui/icon "x" {:size 16}))]))
@@ -373,7 +377,7 @@
     (when (and running? (= repo current-repo))
       [:div.search-index-progress
        [ui/loading ""]
-       [:span.search-index-progress__text (str "Indexing " progress' "%")]
+       [:span.search-index-progress__text (t :search/index-progress progress')]
        [:div.search-index-progress__bar
         [:div.search-index-progress__bar-fill {:style {:width (str progress' "%")}}]]])))
 
@@ -406,19 +410,20 @@
          (when-not (or (state/home?) custom-home-page?)
            (ui/with-shortcut :go/backward "bottom"
              [:button.it.navigation.nav-left.button.icon.opacity-70
-              {:title (t :header/go-back) :on-click #(js/window.history.back)}
-              (ui/icon "chevron-left" {:size 26})]))
+              {:on-click #(js/window.history.back)}
+              (ui/icon "chevron-left" {:size 26})]
+             (t :header/go-back)))
                  ;; search button for non-mobile
          (when current-repo
            (ui/with-shortcut :go/search "right"
              [:button.button.icon#search-button
               {:data-keep-selection true
-               :title (t :header/search)
                :on-click #(do (when (or (mobile-util/native-android?)
                                         (mobile-util/native-iphone?))
                                 (state/set-left-sidebar-open! false))
                               (state/pub-event! [:go/search]))}
-              (ui/icon "search" {:size ui/icon-size})])))]]
+              (ui/icon "search" {:size ui/icon-size})]
+             (t :nav/search))))]]
 
      [:div.r.flex.drag-region.justify-between.items-center.gap-2.overflow-x-hidden.w-full
       [:div.flex.flex-1
@@ -461,7 +466,7 @@
 
        (when config/publishing?
          [:a.text-sm.font-medium.button {:href (rfe/href :graph)}
-          (t :graph)])
+          (t :nav/graph)])
 
        (toolbar-dots-menu {:t            t
                            :current-repo current-repo

+ 16 - 10
src/main/frontend/components/icon.cljs

@@ -5,6 +5,7 @@
             [cljs-bean.core :as bean]
             [clojure.string :as string]
             [frontend.config :as config]
+            [frontend.context.i18n :refer [t]]
             [frontend.search :as search]
             [frontend.storage :as storage]
             [frontend.ui :as ui]
@@ -196,7 +197,9 @@
 
 (defn- normalize-tabs
   [tabs default-tab]
-  (let [tabs (or tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"]])
+  (let [tabs (or tabs [[:all (t :icon/tab-all)]
+                       [:emoji (t :icon/tab-emojis)]
+                       [:icon (t :icon/tab-icons)]])
         default-tab (or default-tab (ffirst tabs) :all)
         default-tab (if (some #(= (first %) default-tab) tabs)
                       default-tab
@@ -211,11 +214,11 @@
                            (filterv #(= :emoji (:type %)) used-items))
         sections (cond-> []
                    (and show-used? (seq emoji-used-items))
-                   (conj {:title "Frequently used"
+                   (conj {:title (t :ui/frequently-used)
                           :items emoji-used-items
                           :virtual-list? false})
                    true
-                   (conj {:title (util/format "Emojis (%s)" (count emojis*))
+                   (conj {:title (t :icon/emojis-count (count emojis*))
                           :items emojis*
                           :virtual-list? true}))]
     sections))
@@ -242,7 +245,7 @@
 (rum/defc icons-cp < rum/static
   [icons opts]
   (pane-section
-   (util/format "Icons (%s)" (count icons))
+   (t :icon/icons-count (count icons))
    icons
    opts))
 
@@ -254,11 +257,11 @@
         opts (assoc opts :virtual-list? false)]
     [:div.all-pane.pb-10
      (when (count used-items)
-       (pane-section "Frequently used" used-items opts))
-     (pane-section (util/format "Emojis (%s)" (count emojis))
+       (pane-section (t :ui/frequently-used) used-items opts))
+     (pane-section (t :icon/emojis-count (count emojis))
                    emoji-items
                    opts)
-     (pane-section (util/format "Icons (%s)" (count (get-tabler-icons)))
+     (pane-section (t :icon/icons-count (count (get-tabler-icons)))
                    icon-items
                    opts)]))
 
@@ -414,7 +417,10 @@
        [(shui/input
          {:auto-focus true
           :ref *input-ref
-          :placeholder (util/format "Search %ss" (string/lower-case (name tab)))
+          :placeholder (case tab
+                         :emoji (t :icon/search-emojis)
+                         :icon (t :icon/search-icons)
+                         (t :icon/search-all))
           :default-value ""
           :on-focus #(reset! *select-mode? false)
           :on-key-down (fn [^js e]
@@ -451,7 +457,7 @@
           (let [matched (concat (:emojis result) (:icons result))]
             (when (seq matched)
               (pane-section
-               (util/format "Matched (%s)" (count matched))
+               (t :icon/matched-count (count matched))
                matched
                opts)))]
          [:div.flex.flex-1.flex-col.gap-1
@@ -532,4 +538,4 @@
          (if (vector? icon-value)       ; hiccup
            icon-value
            (icon icon-value (merge {:color? true} icon-props)))
-         (or empty-label "Empty"))))))
+         (or empty-label (t :ui/empty)))))))

+ 58 - 66
src/main/frontend/components/imports.cljs

@@ -10,7 +10,7 @@
             [frontend.components.repo :as repo]
             [frontend.components.svg :as svg]
             [frontend.config :as config]
-            [frontend.context.i18n :refer [t]]
+            [frontend.context.i18n :refer [t t-en]]
             [frontend.db :as db]
             [frontend.fs :as fs]
             [frontend.handler.assets :as assets-handler]
@@ -69,7 +69,7 @@
   [& {:keys [reload?]
       :or {reload? true}}]
   (state/pub-event! [:graph/sync-context])
-  (notification/show! "Import finished!" :success)
+  (notification/show! (t :import/file-finished) :success)
   (shui/dialog-close! :import-indicator)
   (route-handler/redirect-to-home!)
   (if util/web-platform?
@@ -86,10 +86,10 @@
       (let [graph-name (string/trim graph-name)]
         (cond
           (string/blank? graph-name)
-          (notification/show! "Empty graph name." :error)
+          (notification/show! (t :import/empty-graph-name) :error)
 
           (repo-handler/graph-already-exists? graph-name)
-          (notification/show! "Please specify another name as another graph with this name already exists!" :error)
+          (notification/show! (t :import/graph-name-conflict) :error)
 
           :else
           (let [reader (js/FileReader.)]
@@ -108,10 +108,10 @@
       (let [graph-name (string/trim graph-name)]
         (cond
           (string/blank? graph-name)
-          (notification/show! "Empty graph name." :error)
+          (notification/show! (t :import/empty-graph-name) :error)
 
           (repo-handler/graph-already-exists? graph-name)
-          (notification/show! "Please specify another name as another graph with this name already exists!" :error)
+          (notification/show! (t :import/graph-name-conflict) :error)
 
           :else
           (db-import-handler/import-from-sqlite-zip! file graph-name
@@ -122,10 +122,10 @@
       (let [graph-name (string/trim graph-name)]
         (cond
           (string/blank? graph-name)
-          (notification/show! "Empty graph name." :error)
+          (notification/show! (t :import/empty-graph-name) :error)
 
           (repo-handler/graph-already-exists? graph-name)
-          (notification/show! "Please specify another name as another graph with this name already exists!" :error)
+          (notification/show! (t :import/graph-name-conflict) :error)
 
           :else
           (do
@@ -148,7 +148,7 @@
               (.readAsText reader file)))))
 
       :else
-      (notification/show! "Please choose an EDN or a JSON file."
+      (notification/show! (t :import/select-edn-or-json)
                           :error))))
 
 (rum/defcs set-graph-name-dialog
@@ -163,7 +163,7 @@
      [:div.sm:flex.sm:items-start
       [:div.mt-3.text-center.sm:mt-0.sm:text-left
        [:h3#modal-headline.leading-6.font-medium.pb-2
-        "New graph name:"]]]
+        (t :import/new-graph-name)]]]
 
      [:input.form-input.block.w-full.sm:text-sm.sm:leading-5.my-2.mb-4
       {:auto-focus true
@@ -174,7 +174,7 @@
                         (on-submit)))}]
 
      [:div.mt-5.sm:mt-4.flex
-      (ui/button "Submit"
+      (ui/button (t :ui/submit)
                  {:on-click on-submit})]]))
 
 (rum/defc import-file-graph-dialog
@@ -206,9 +206,9 @@
                           (shui/form-field {:name "graph-name"}
                                            (fn [field error]
                                              (shui/form-item
-                                              (shui/form-label "New graph name")
+                                              (shui/form-label (t :import/new-graph-name))
                                               (shui/form-control
-                                               (shui/input (merge {:placeholder "Graph name"} field)))
+                                               (shui/input (merge {:placeholder (t :import/graph-name-placeholder)} field)))
                                               (when error
                                                 (shui/form-description
                                                  [:b.text-red-800 (:message error)])))))
@@ -217,7 +217,7 @@
                                            (fn [field]
                                              (shui/form-item
                                               {:class "pt-3 flex justify-start items-center space-x-3 space-y-0 my-3 pr-3"}
-                                              (shui/form-label "Extract inline code snippets as child blocks")
+                                              (shui/form-label (t :import/extract-inline-code-snippets))
                                               (shui/form-control
                                                (shui/checkbox {:checked (:value field)
                                                                :on-checked-change (:onChange field)})))))
@@ -226,7 +226,7 @@
                                            (fn [field]
                                              (shui/form-item
                                               {:class "pt-3 flex justify-start items-center space-x-3 space-y-0 my-3 pr-3"}
-                                              (shui/form-label "Import all tags")
+                                              (shui/form-label (t :import/all-tags))
                                               (shui/form-control
                                                (shui/checkbox {:checked (:value field)
                                                                :on-checked-change (fn [e]
@@ -237,18 +237,18 @@
                                            (fn [field _error]
                                              (shui/form-item
                                               {:class "pt-3"}
-                                              (shui/form-label "Import specific tags")
+                                              (shui/form-label (t :import/specific-tags))
                                               (shui/form-control
                                                (shui/input (merge field
-                                                                  {:placeholder "tag 1, tag 2" :disabled convert-all-tags-input})))
-                                              (shui/form-description "Tags are case insensitive"))))
+                                                                  {:placeholder (t :import/tag-classes-placeholder) :disabled convert-all-tags-input})))
+                                              (shui/form-description (t :import/tags-case-insensitive)))))
 
                           (shui/form-field {:name "remove-inline-tags?"}
                                            (fn [field]
                                              (shui/form-item
                                               {:class "pt-3 flex justify-start items-center space-x-3 space-y-0 my-3 pr-3"}
-                                              (shui/form-label "Remove inline tags")
-                                              (shui/form-description "Default behavior for DB graphs")
+                                              (shui/form-label (t :import/remove-inline-tags))
+                                              (shui/form-description (t :import/default-db-graph-behavior))
                                               (shui/form-control
                                                (shui/checkbox {:checked (:value field)
                                                                :on-checked-change (:onChange field)})))))
@@ -257,64 +257,59 @@
                                            (fn [field _error]
                                              (shui/form-item
                                               {:class "pt-3"}
-                                              (shui/form-label "Import additional tags from property values")
+                                              (shui/form-label (t :import/property-value-tags))
                                               (shui/form-control
-                                               (shui/input (merge {:placeholder "e.g. type"} field)))
+                                               (shui/input (merge {:placeholder (t :import/property-classes-placeholder)} field)))
                                               (shui/form-description
-                                               "Properties are case insensitive and separated by commas"))))
+                                               (t :import/properties-case-insensitive-commas)))))
 
                           (shui/form-field {:name "property-parent-classes"}
                                            (fn [field _error]
                                              (shui/form-item
                                               {:class "pt-3"}
-                                              (shui/form-label "Import tag parents from property values")
+                                              (shui/form-label (t :import/property-value-tag-parents))
                                               (shui/form-control
-                                               (shui/input (merge {:placeholder "e.g. parent"} field)))
+                                               (shui/input (merge {:placeholder (t :import/property-parent-classes-placeholder)} field)))
                                               (shui/form-description
-                                               "Properties are case insensitive and separated by commas"))))
+                                               (t :import/properties-case-insensitive-commas)))))
 
-                          (shui/button {:type "submit" :class "right-0 mt-3"} "Submit")]))])
+                          (shui/button {:type "submit" :class "right-0 mt-3"} (t :ui/submit))]))])
 
 (defn- validate-imported-data
   [db import-state files]
   (when-let [org-files (seq (filter #(= "org" (path/file-ext (:path %))) files))]
     (log/info :org-files (mapv :path org-files))
-    (notification/show! (str "Imported " (count org-files) " org file(s) as markdown. Support for org files will be added later.")
+    (notification/show! (t :import/org-files-imported (count org-files))
                         :info false))
   (when-let [ignored-files (seq @(:ignored-files import-state))]
-    (notification/show! (str "Import ignored " (count ignored-files) " "
-                             (if (= 1 (count ignored-files)) "file" "files")
-                             ". See the javascript console for more details.")
+    (notification/show! (t :import/ignored-files (count ignored-files))
                         :info false)
     (log/error :import-ignored-files {:msg (str "Import ignored " (count ignored-files) " file(s)")})
     (pprint/pprint ignored-files))
   (when-let [ignored-assets (seq @(:ignored-assets import-state))]
-    (notification/show! (str "Import ignored " (count ignored-assets) " "
-                             (if (= 1 (count ignored-assets)) "asset" "assets")
-                             ". See the javascript console for more details.")
+    (notification/show! (t :import/ignored-assets (count ignored-assets))
                         :info false)
     (log/error :import-ignored-assets {:msg (str "Import ignored " (count ignored-assets) " asset(s)")})
     (pprint/pprint ignored-assets))
   (when-let [ignored-props (seq @(:ignored-properties import-state))]
     (notification/show!
      [:.mb-2
-      [:.text-lg.mb-2 (str "Import ignored " (count ignored-props) " "
-                           (if (= 1 (count ignored-props)) "property" "properties"))]
+      [:.text-lg.mb-2 (t :import/ignored-properties (count ignored-props))]
       [:span.text-xs
-       "To fix a property type, change the property value to the correct type and reimport the graph"]
+       (t :import/ignored-properties-fix)]
       (->> ignored-props
            (map (fn [{:keys [property value schema location]}]
                   [(str "Property " (pr-str property) " with value " (pr-str value))
                    (if (= property :icon)
                      (if (:page location)
-                       (str "Page icons can't be imported. Go to the page " (pr-str (:page location)) " to manually import it.")
-                       (str "Block icons can't be imported. Manually import it at the block: " (pr-str (:block location))))
+                       (t :import/page-icons-cannot-be-imported (pr-str (:page location)))
+                       (t :import/block-icons-cannot-be-imported (pr-str (:block location))))
                      (if (not= (get-in schema [:type :to]) (get-in schema [:type :from]))
-                       (str "Property value has type " (get-in schema [:type :to]) " instead of type " (get-in schema [:type :from]))
-                       "Property should be imported manually"))]))
+                       (t :import/property-type-mismatch (get-in schema [:type :to]) (get-in schema [:type :from]))
+                       (t :import/property-import-manually)))]))
            (map (fn [[k v]]
                   [:dl.my-2.mb-0
-               [:dt.m-0 [:strong k]]
+                   [:dt.m-0 [:strong k]]
                    [:dd {:class "text-warning"} v]])))]
      :warning false))
   (let [{:keys [errors]} (db-validate/validate-local-db! db {:verbose true})]
@@ -322,7 +317,7 @@
       (do
         (log/error :import-errors {:msg (str "Import detected " (count errors) " invalid block(s):")})
         (pprint/pprint errors)
-        (notification/show! (str "Import detected " (count errors) " invalid block(s). These blocks may be buggy when you interact with them. See the javascript console for more.")
+        (notification/show! (t :import/invalid-blocks-detected (count errors))
                             :warning false))
       (log/info :import-valid {:msg "Valid import!"}))))
 
@@ -337,14 +332,11 @@
 (defn- read-and-copy-asset [repo repo-dir file assets buffer-handler]
   (let [^js file-object (:file-object file)]
     (if (assets-handler/exceed-limit-size? file-object)
-      (do
-        (js/console.log (str "Skipped copying asset " (pr-str (:path file)) " because it is larger than the 100M max."))
+      (let [path (pr-str (:path file))]
+        (log/info :import-asset-skipped-too-large {:msg (t-en :import/asset-too-large-warning path)})
         ;; This asset will also be included in the ignored-assets count. Better to be explicit about ignoring
         ;; these so users are aware of this
-        (notification/show!
-         (str "Skipped copying asset " (pr-str (:path file)) " because it is larger than the 100M max.")
-         :info
-         false))
+        (notification/show! (t :import/asset-too-large-warning path) :info false))
       (p/let [buffer (.arrayBuffer file-object)
               bytes-array (js/Uint8Array. buffer)
               checksum (db-asset/<get-file-array-buffer-checksum buffer)
@@ -429,7 +421,7 @@
                                                              (ignored-path? original-graph-name (.-webkitRelativePath (:file-object %))))))]
                                 (if-let [config-file (first (filter #(= (:path %) "logseq/config.edn") files))]
                                   (import-file-graph files user-inputs config-file)
-                                  (notification/show! "Import failed as the file 'logseq/config.edn' was not found for a Logseq graph."
+                                  (notification/show! (t :import/logseq-config-missing)
                                                       :error)))))]
     (shui/dialog-open!
      #(import-file-graph-dialog original-graph-name
@@ -439,7 +431,7 @@
                                     (repo/invalid-graph-name-warning)
 
                                     (repo-handler/graph-already-exists? graph-name)
-                                    (notification/show! "Please specify another name as another graph with this name already exists!" :error)
+                                    (notification/show! (t :import/graph-name-conflict) :error)
 
                                     :else
                                     (import-graph-fn user-inputs)))))))
@@ -447,9 +439,9 @@
 (rum/defc indicator-progress < rum/reactive
   []
   (let [{:keys [total current-idx current-page label]} (state/sub :graph/importing-state)
-        label (or label (t :importing))
+        label (or label (t :import/loading))
         left-label (if (and current-idx total (= current-idx total))
-                     [:div.flex.flex-row.font-bold "Loading ..."]
+                     [:div.flex.flex-row.font-bold (t :ui/loading)]
                      [:div.flex.flex-row.font-bold
                       label
                       [:div.hidden.md:flex.flex-row
@@ -488,14 +480,14 @@
         [:article.flex.flex-col.items-center.importer.py-16.px-8
          (when-not (util/mobile?)
            [:section.c.text-center
-            [:h1 (t :on-boarding/importing-title)]
-            [:h2 (t :on-boarding/importing-desc)]])
+            [:h1 (t :onboarding.import/title)]
+            [:h2 (t :onboarding.import/desc)]])
          [:section.d.md:flex.flex-col
           [:label.action-input.flex.items-center.mx-2.my-2
            [:span.as-flex-center [:i (svg/logo 28)]]
            [:span.flex.flex-col
-            [[:strong "SQLite"]
-             [:small (t :on-boarding/importing-sqlite-desc)]]]
+              [[:strong "SQLite"]
+             [:small (t :onboarding.import/sqlite-desc)]]]
            [:input.absolute.hidden
             {:id "import-sqlite-db"
              :type "file"
@@ -506,8 +498,8 @@
           [:label.action-input.flex.items-center.mx-2.my-2
            [:span.as-flex-center [:i (svg/logo 28)]]
            [:span.flex.flex-col
-            [[:strong "SQLite + assets (.zip)"]
-             [:small "Import a zip containing db.sqlite and an assets folder"]]]
+              [[:strong (t :import/sqlite-and-assets-title)]
+               [:small (t :import/sqlite-and-assets-desc)]]]
            [:input.absolute.hidden
             {:id "import-sqlite-zip"
              :type "file"
@@ -520,8 +512,8 @@
             [:label.action-input.flex.items-center.mx-2.my-2
              [:span.as-flex-center [:i (svg/logo 28)]]
              [:span.flex.flex-col
-              [[:strong "File to DB graph"]
-               [:small "Import a file-based Logseq graph folder into a new DB graph"]]]
+              [[:strong (t :import/file-to-db-title)]
+               [:small (t :import/file-to-db-desc)]]]
              ;; Test form style changes
              #_[:a.button {:on-click #(import-file-to-db-handler nil {:import-graph-fn js/alert})} "Open"]
              [:input.absolute.hidden
@@ -535,8 +527,8 @@
           [:label.action-input.flex.items-center.mx-2.my-2
            [:span.as-flex-center [:i (svg/logo 28)]]
            [:span.flex.flex-col
-            [[:strong "Debug Transit"]
-             [:small "Import debug transit file into a new DB graph"]]]
+              [[:strong (t :import/debug-transit-title)]
+               [:small (t :import/debug-transit-desc)]]]
            [:input.absolute.hidden
             {:id "import-debug-transit"
              :type "file"
@@ -547,8 +539,8 @@
           [:label.action-input.flex.items-center.mx-2.my-2
            [:span.as-flex-center [:i (svg/logo 28)]]
            [:span.flex.flex-col
-            [[:strong "EDN to DB graph"]
-             [:small "Import a DB graph's EDN export into a new DB graph"]]]
+              [[:strong (t :import/db-edn-title)]
+               [:small (t :import/db-edn-desc)]]]
            [:input.absolute.hidden
             {:id "import-db-edn"
              :type "file"
@@ -558,4 +550,4 @@
 
          (when (= "picker" (:from query-params))
            [:section.e
-            [:a.button {:on-click #(route-handler/redirect-to-home!)} "Skip"]])]))]))
+            [:a.button {:on-click #(route-handler/redirect-to-home!)} (t :ui/skip)]])]))]))

+ 78 - 41
src/main/frontend/components/left_sidebar.cljs

@@ -6,7 +6,7 @@
             [frontend.components.icon :as icon]
             [frontend.components.repo :as repo]
             [frontend.config :as config]
-            [frontend.context.i18n :refer [t tt]]
+            [frontend.context.i18n :refer [t]]
             [frontend.db :as db]
             [frontend.db-mixins :as db-mixins]
             [frontend.db.model :as db-model]
@@ -15,12 +15,12 @@
             [frontend.handler.page :as page-handler]
             [frontend.handler.recent :as recent-handler]
             [frontend.handler.route :as route-handler]
+            [frontend.handler.ui :as ui-handler]
             [frontend.state :as state]
             [frontend.storage :as storage]
             [frontend.ui :as ui]
             [frontend.util :as util]
             [goog.object :as gobj]
-            [logseq.db :as ldb]
             [logseq.shui.hooks :as hooks]
             [logseq.shui.ui :as shui]
             [reitit.frontend.easy :as rfe]
@@ -37,13 +37,53 @@
         default-home
         (dissoc default-home :page)))))
 
+(rum/defc page-title-content
+  [page-id display-title tooltip-title untitled? left-sidebar-resized-at]
+  (let [*title-ref (rum/use-ref nil)
+        [truncated? set-truncated?!] (rum/use-state false)
+        sync-truncated! (fn []
+                          (if-let [^js el (rum/deref *title-ref)]
+                            (set-truncated?! (> (.-scrollWidth el)
+                                                (+ (.-clientWidth el) 1)))
+                            (set-truncated?! false)))
+        title-el [:span.page-title {:ref *title-ref
+                                    :class (when untitled? "opacity-50")}
+                  display-title]]
+    (hooks/use-effect!
+     (fn []
+       (if-let [^js el (rum/deref *title-ref)]
+         (let [observer (js/ResizeObserver. (fn [_] (sync-truncated!)))]
+           (.observe observer el)
+           (sync-truncated!)
+           #(.disconnect observer))
+         (do
+           (set-truncated?! false)
+           nil)))
+     [page-id display-title tooltip-title])
+    (hooks/use-effect!
+     (fn []
+       (let [raf-id (js/requestAnimationFrame sync-truncated!)]
+         #(js/cancelAnimationFrame raf-id)))
+     [left-sidebar-resized-at])
+    (if (and truncated? (not (string/blank? tooltip-title)))
+      (ui/tooltip title-el tooltip-title)
+      title-el)))
+
 (rum/defc ^:large-vars/cleanup-todo page-name < rum/reactive db-mixins/query
   [page recent?]
   (when-let [id (:db/id page)]
     (let [page (db/sub-block id)
+          left-sidebar-resized-at (rum/react ui-handler/*left-sidebar-resized-at)
           icon (icon/get-node-icon-cp page {:size 16})
           title (:block/title page)
           untitled? (db-model/untitled-page? title)
+          display-title (cond
+                          (not (db/page? page))
+                          (block/inline-text :markdown (string/replace (apply str (take 64 (:block/title page))) "\n" " "))
+                          untitled? (t :ui/untitled)
+                          :else (block-handler/block-unique-title page))
+          tooltip-title (or (block-handler/block-unique-title page)
+                            (when untitled? (t :ui/untitled)))
           ctx-icon #(shui/tabler-icon %1 {:class "scale-90 pr-1 opacity-80"})
           open-in-sidebar #(state/sidebar-add-block!
                             (state/get-current-repo)
@@ -58,12 +98,12 @@
                                   :on-click #(page-handler/<unfavorite-page! (str (:block/uuid page)))}
                                  (ctx-icon "star-off")
                                  (t :page/unfavorite)
-                                 (ui/dropdown-shortcut :command/toggle-favorite)))
+                                 (ui/dropdown-shortcut :page/toggle-favorite)))
                               (x-menu-item
                                {:key "open in sidebar"
                                 :on-click open-in-sidebar}
                                (ctx-icon "layout-sidebar-right")
-                               (t :content/open-in-sidebar)
+                               (t :sidebar.right/open)
                                (ui/dropdown-shortcut "shift+click"))]))]
 
     ;; TODO: move to standalone component
@@ -72,28 +112,19 @@
          {:on-pointer-down util/stop-propagation
           :on-pointer-up (fn [_e]
                            (route-handler/redirect-to-page! (:block/uuid page) {:click-from-recent? recent?}))}
-         (cond->
-          {:on-click
-           (fn [e]
-             (if (gobj/get e "shiftKey")
-               (open-in-sidebar)
-               (route-handler/redirect-to-page! (:block/uuid page) {:click-from-recent? recent?})))
-           :on-context-menu (fn [^js e]
-                              (shui/popup-show! e (x-menu-content)
-                                                {:as-dropdown? true
-                                                 :content-props {:on-click (fn [] (shui/popup-hide!))
-                                                                 :class "w-60"}})
-                              (util/stop e))}
-           (ldb/object? page)
-           (assoc :title (block-handler/block-unique-title page))))
+         {:on-click
+          (fn [e]
+            (if (gobj/get e "shiftKey")
+              (open-in-sidebar)
+              (route-handler/redirect-to-page! (:block/uuid page) {:click-from-recent? recent?})))
+          :on-context-menu (fn [^js e]
+                             (shui/popup-show! e (x-menu-content)
+                                               {:as-dropdown? true
+                                                :content-props {:on-click (fn [] (shui/popup-hide!))
+                                                                :class "w-60"}})
+                             (util/stop e))})
        [:span.page-icon {:key "page-icon"} icon]
-       [:span.page-title {:key "title"
-                          :class (when untitled? "opacity-50")}
-        (cond
-          (not (db/page? page))
-          (block/inline-text :markdown (string/replace (apply str (take 64 (:block/title page))) "\n" " "))
-          untitled? (t :untitled)
-          :else (block-handler/block-unique-title page))]
+       (page-title-content id display-title tooltip-title untitled? left-sidebar-resized-at)
 
      ;; dots trigger
        (shui/button
@@ -132,6 +163,15 @@
   [:div.sidebar-graphs
    (repo/graphs-selector)])
 
+(defn navigation-label-key
+  [nav]
+  (case nav
+    :flashcards :nav/flashcards
+    :all-pages :nav.all-pages/label
+    :graph-view :nav/graph-view
+    :tag/tasks :nav/tasks
+    :tag/assets :nav/assets))
+
 (rum/defc sidebar-navigations-edit-content
   [{:keys [_id navs checked-navs set-checked-navs!]}]
   (let [[local-navs set-local-navs!] (rum/use-state checked-navs)]
@@ -141,8 +181,7 @@
        (set-checked-navs! local-navs))
      [local-navs])
 
-    (for [nav navs
-          :let [name' (name nav)]]
+    (for [nav navs]
       (shui/dropdown-menu-checkbox-item
        {:checked (contains? (set local-navs) nav)
         :onCheckedChange (fn [v] (set-local-navs!
@@ -150,8 +189,7 @@
                                     (if v
                                       (conj local-navs nav)
                                       (filterv #(not= nav %) local-navs)))))}
-       (tt (keyword "left-side-bar" name')
-           (keyword "right-side-bar" name'))))))
+       (t (navigation-label-key nav))))))
 
 (rum/defc sidebar-content-group < rum/reactive
   [name {:keys [class count more header-props enter-show-more? collapsable?]} child]
@@ -186,7 +224,7 @@
      [checked-navs])
 
     (sidebar-content-group
-     [:a.wrap-th [:strong.flex-1 "Navigations"]]
+      [:a.wrap-th [:strong.flex-1 (t :sidebar.left/navigations)]]
      {:collapsable? false
       :enter-show-more? true
       :header-props {:on-click (fn [^js e] (when-let [^js _el (some-> (.-target e) (.closest ".as-edit"))]
@@ -218,7 +256,7 @@
              {:class "journals-nav"
               :active (and (not srs-open?)
                            (or (= route-name :all-journals) (= route-name :home)))
-              :title (t :left-side-bar/journals)
+              :title (t :nav/journals)
               :on-click-handler (fn [e]
                                   (if (gobj/get e "shiftKey")
                                     (route-handler/sidebar-journals!)
@@ -233,7 +271,7 @@
             (let [num (state/sub :srs/cards-due-count)]
               (sidebar-item
                {:class "flashcards-nav"
-                :title (t :right-side-bar/flashcards)
+                :title (t :nav/flashcards)
                 :icon "infinity"
                 :shortcut :go/flashcards
                 :active srs-open?
@@ -245,7 +283,7 @@
           (= nav :graph-view)
           (sidebar-item
            {:class "graph-view-nav"
-            :title (t :right-side-bar/graph-view)
+            :title (t :nav/graph-view)
             :href (rfe/href :graph)
             :active (and (not srs-open?) (= route-name :graph))
             :icon "hierarchy"
@@ -254,7 +292,7 @@
           (= nav :all-pages)
           (sidebar-item
            {:class "all-pages-nav"
-            :title (t :right-side-bar/all-pages)
+            :title (t :nav.all-pages/label)
             :href (rfe/href :all-pages)
             :active (and (not srs-open?) (= route-name :all-pages))
             :icon "files"})
@@ -265,8 +303,7 @@
             (when-let [tag-uuid (and class-ident (:block/uuid (db/entity class-ident)))]
               (sidebar-item
                {:class (str "tag-view-nav " name'')
-                :title (tt (keyword "left-side-bar" name'')
-                           (keyword "right-side-bar" name''))
+                :title (t (navigation-label-key nav))
                 :href (rfe/href :page {:name tag-uuid})
                 :active (= (str tag-uuid) (get-in route-match [:path-params :name]))
                 :icon "hash"})))))])))
@@ -277,7 +314,7 @@
         favorite-entities (page-handler/get-favorites)]
     (sidebar-content-group
      [:a.wrap-th
-      [:strong.flex-1 (t :left-side-bar/nav-favorites)]]
+      [:strong.flex-1 (t :sidebar.left/favorites)]]
 
      {:class "favorites"
       :count (count favorite-entities)
@@ -301,7 +338,7 @@
   []
   (let [pages (recent-handler/get-recent-pages)]
     (sidebar-content-group
-     [:a.wrap-th [:strong.flex-1 (t :left-side-bar/nav-recent-pages)]]
+     [:a.wrap-th [:strong.flex-1 (t :sidebar.left/recent-pages)]]
 
      {:class "recent"
       :count (count pages)}
@@ -309,8 +346,7 @@
      [:ul.text-sm
       (for [page pages]
         [:li.recent-item.select-none.font-medium
-         {:key (str "recent-" (:db/id page))
-          :title (block-handler/block-unique-title page)}
+         {:key (str "recent-" (:db/id page))}
          (page-name page true)])])))
 
 (rum/defc ^:large-vars/cleanup-todo sidebar-container
@@ -446,7 +482,8 @@
                                   (.. el-doc -classList (add "is-resizing-buf"))))
                (.on "dragend" (fn []
                                 (.. sidebar-el -classList (remove "is-resizing"))
-                                (.. el-doc -classList (remove "is-resizing-buf"))))))
+                                (.. el-doc -classList (remove "is-resizing-buf"))
+                                (reset! ui-handler/*left-sidebar-resized-at (js/Date.now))))))
          #()))
      [])
     [:span.left-sidebar-resizer {:ref *el-ref}]))

+ 3 - 2
src/main/frontend/components/library.cljs

@@ -2,6 +2,7 @@
   "Library page"
   (:require [clojure.string :as string]
             [frontend.components.select :as components-select]
+            [frontend.context.i18n :refer [t]]
             [frontend.db :as db]
             [frontend.handler.editor :as editor-handler]
             [frontend.search :as search]
@@ -47,7 +48,7 @@
                                      {:outliner-op :save-block})
                        (set-selected-choices! (disj selected-choices chosen)))))
       :multiple-choices? true
-      :input-default-placeholder "Add pages"
+      :input-default-placeholder (t :library/add-pages)
       :show-new-when-not-exact-match? false
       :on-input set-input!
       :input-opts {:class "!p-1 !text-sm"}
@@ -68,4 +69,4 @@
                      (select-pages library-page)])
                   {:align :start}))}
     (ui/icon "plus" {:size 16})
-    "Add existing pages to Library")])
+    (t :library/add-existing-pages))])

+ 3 - 2
src/main/frontend/components/objects.cljs

@@ -2,6 +2,7 @@
   "Provides table views for class objects and property related objects"
   (:require [frontend.components.filepicker :as filepicker]
             [frontend.components.views :as views]
+            [frontend.context.i18n :refer [t]]
             [frontend.db :as db]
             [frontend.db-mixins :as db-mixins]
             [frontend.db.react :as react]
@@ -28,7 +29,7 @@
 (defn- build-asset-file-column
   [config]
   {:id :file
-   :name "File"
+   :name (t :file/label)
    :type :string
    :header views/header-cp
    :cell (fn [_table row _column]
@@ -84,7 +85,7 @@
                               (shui/dialog-open!
                                (fn []
                                  [:div.flex.flex-col.gap-2
-                                  [:div.font-medium "Add assets"]
+                                  [:div.font-medium (t :asset/add-assets)]
                                   (filepicker/picker
                                    {:on-change (fn [_e files]
                                                  (p/let [entities (editor-handler/upload-asset! nil files :markdown editor-handler/*asset-uploading? true)]

+ 6 - 6
src/main/frontend/components/onboarding.cljs

@@ -10,31 +10,31 @@
                               [:span.mr-1 (t :help/forum-community)]
                               (ui/icon "message-circle" {:style {:font-size 20}})]
          list
-         [{:title (t :help/title-usage)
+         [{:title (t :help/usage-title)
            :children [[[:a
                         {:on-click (fn [] (state/sidebar-add-block! (state/get-current-repo) "shortcut-settings" :shortcut-settings))}
                         [:div.flex-row.inline-flex.items-center
-                         [:span.mr-1 (t :help/shortcuts)]
+                         [:span.mr-1 (t :help.shortcuts/label)]
                          (ui/icon "command" {:style {:font-size 20}})]]]
                       [(t :help/docs) "https://docs.logseq.com/"]
                       [(t :help/start) "https://docs.logseq.com/#/page/tutorial"]
                       ["FAQ" "https://docs.logseq.com/#/page/faq"]]}
 
-          {:title (t :help/title-community)
+          {:title (t :help/community-title)
            :children [[(t :help/awesome-logseq) "https://github.com/logseq/awesome-logseq"]
                       [(t :help/blog) "https://blog.logseq.com"]
                       [discourse-with-icon "https://discuss.logseq.com"]]}
 
-          {:title (t :help/title-development)
+          {:title (t :help/development-title)
            :children [[(t :help/roadmap) "https://discuss.logseq.com/t/logseq-product-roadmap/34267"]
                       [(t :help/bug) "https://github.com/logseq/logseq/issues/new?labels=from:in-app&template=bug_report.yaml"]
                       [(t :help/feature) "https://discuss.logseq.com/c/feedback/feature-requests/"]
                       [(t :help/changelog) "https://docs.logseq.com/#/page/changelog"]]}
 
-          {:title (t :help/title-about)
+          {:title (t :help/about-title)
            :children [[(t :help/about) "https://blog.logseq.com/about/"]]}
 
-          {:title (t :help/title-terms)
+          {:title (t :help/terms-title)
            :children [[(t :help/privacy) "https://blog.logseq.com/privacy-policy/"]
                       [(t :help/terms) "https://blog.logseq.com/terms/"]]}]]
 

+ 4 - 4
src/main/frontend/components/onboarding/setups.cljs

@@ -11,12 +11,12 @@
 
       [:h1.text-xl
        (if picker?
-         [:span (t :on-boarding/main-title)]
-         [:span (t :on-boarding/importing-main-title)])]
+         [:span (t :onboarding.setup/title)]
+         [:span (t :onboarding.import-option/title)])]
 
       [:h2
        (if picker?
-         (t :on-boarding/main-desc)
-         (t :on-boarding/importing-main-desc))]
+         (t :onboarding.setup/desc)
+         (t :onboarding.import-option/desc))]
 
       content])])

+ 56 - 60
src/main/frontend/components/page.cljs

@@ -218,7 +218,7 @@
              (shui/button {:variant :ghost
                            :class "text-muted-foreground w-full"
                            :on-click (fn [] (route-handler/redirect-to-page! (:block/uuid block)))}
-                          "Load more"))
+                          (t :ui/load-more)))
            (when-not more?
              (when-not hide-add-button?
                (add-button block config)))])))))
@@ -233,7 +233,7 @@
            (let [query' (assoc query :collapsed? true)]
              (rum/with-key
                (ui/catch-error
-                (ui/component-error "Failed default query:" {:content (pr-str query')})
+                (ui/component-error (t :page/default-query-error) {:content (pr-str query')})
                 (query/custom-query (component-block/wrap-query-components
                                      {:editor-box editor/box
                                       :page page-cp
@@ -255,7 +255,7 @@
                     (state/pub-event! [:editor/new-property {:property-key "Icon"
                                                              :block page
                                                              :target (.-target e)}]))}
-       "Add icon"))
+       (t :command.editor/add-property-icon)))
 
     (shui/button
      {:variant :ghost
@@ -277,14 +277,14 @@
                       (state/pub-event! [:editor/new-property opts]))))}
      (cond
        (ldb/class? page)
-       "Add tag property"
+       (t :class/add-property)
        (ldb/property? page)
-       "Configure"
+       (t :ui/configure)
        :else
-       "Set property"))]])
+       (t :property/set-property)))]])
 
 (rum/defc db-page-title
-  [page {:keys [sidebar? journals? container-id tag-dialog?]}]
+  [page {:keys [sidebar? journals? container-id tag-dialog? display-title]}]
   (let [with-actions? (not config/publishing?)]
     [:div.ls-page-title.flex.flex-1.w-full.content.items-start.title
      {:class "title"
@@ -305,6 +305,7 @@
         :hide-title? sidebar?
         :sidebar? sidebar?
         :tag-dialog? tag-dialog?
+        :display-title display-title
         :hide-children? true
         :container-id container-id
         :show-tag-and-property-classes? true
@@ -392,12 +393,12 @@
             (shui/tabs-trigger
              {:value "tag"
               :class "py-1 text-xs"}
-             "Tagged nodes"))
+             (t :class/tagged-nodes)))
           (when property?
             (shui/tabs-trigger
              {:value "property"
               :class "py-1 text-xs"}
-             "Nodes with property"))
+             (t :property/nodes-with-property)))
           (when property?
             (db-page/configure-property page)))])
 
@@ -422,7 +423,7 @@
         :size :sm
         :class "px-1 text-muted-foreground"
         :on-click #(set-collapsed! (not collapsed?))}
-       [:span.text-xs (str (if collapsed? "Open" "Hide")) " properties"])]
+       [:span.text-xs (t (if collapsed? :page/open-properties :page/hide-properties))])]
 
      (when-not collapsed?
        [:<>
@@ -451,11 +452,11 @@
         recycle-page? (and (ldb/page? page)
                            (= title common-config/recycle-page-name))
         fmt-journal? (boolean (date/journal-title->int title))
-        today? (and
-                journal?
-                (= title (date/journal-name)))
+        today? (model/today-journal-page? page)
         home? (= :home (state/get-current-route))
         recycled? (ldb/recycled? page)
+        page-display-title (when (ldb/page? page)
+                             (route-handler/built-in-page-title (:block/title page)))
         show-tabs? (and (or class-page? (ldb/property? page)) (not tag-dialog?))
         blocks-ready? (or journals?
                           (= page-id @linked-refs-blocks-ready-page-id))
@@ -469,7 +470,7 @@
         (if recycled?
           [:div.flex-1.page.relative.cp__page-inner-wrap
            [:div.relative.grid.gap-4.sm:gap-8.page-inner.mb-16
-            [:div.opacity-75 "Node has been moved to Recycle"]]]
+            [:div.opacity-75 (t :page/moved-to-recycle)]]]
           [:div.flex-1.page.relative.cp__page-inner-wrap
            (merge (if (seq (:block/tags page))
                     (let [page-names (map :block/title (:block/tags page))]
@@ -490,6 +491,7 @@
                                 {:sidebar? sidebar?
                                  :journals? journals?
                                  :container-id (:container-id state)
+                                 :display-title page-display-title
                                  :tag-dialog? tag-dialog?}))
                (lsp-pagebar-slot)])
 
@@ -551,7 +553,7 @@
                             class-page? property-page?)
                 [:div.fade-in.delay {:key "page-unlinked-references"}
                  (reference/unlinked-references page {:sidebar? sidebar?})])])]))
-      [:div.opacity-75 "Page not found"])))
+      [:div.opacity-75 (t :page/not-found)])))
 
 (rum/defcs page-aux < rum/reactive
   {:init (fn [state]
@@ -653,8 +655,8 @@
   [state]
   (let [*simulation-paused? pixi/*simulation-paused?]
     [:div.flex.flex-col.mb-2
-     [:p {:title "Pause simulation"}
-      "Pause simulation"]
+     [:p {:title (t :graph/pause-simulation)}
+      (t :graph/pause-simulation)]
      (ui/toggle
       (rum/react *simulation-paused?)
       (fn []
@@ -697,19 +699,14 @@
       [:div.shadow-xl.rounded-sm
        [:ul
         (graph-filter-section
-         [:span.font-medium "Nodes"]
+         [:span.font-medium (t :graph/nodes)]
          (fn [open?]
            (filter-expand-area
             open?
             [:div
              [:p.text-sm.opacity-70.px-4
-              (let [c1 (count (:nodes graph))
-                    s1 (if (> c1 1) "s" "")
-                      ;; c2 (count (:links graph))
-                      ;; s2 (if (> c2 1) "s" "")
-                    ]
-                  ;; (util/format "%d page%s, %d link%s" c1 s1 c2 s2)
-                (util/format "%d page%s" c1 s1))]
+              (let [c1 (count (:nodes graph))]
+                (t :graph/page-count c1))]
              [:div.p-6
                 ;; [:div.flex.items-center.justify-between.mb-2
                 ;;  [:span "Layout"]
@@ -725,7 +722,7 @@
                 ;;      (set-setting! :layout value))
                 ;;    {:class "graph-layout"})]
               [:div.flex.items-center.justify-between.mb-2
-               [:span (t :settings-page/enable-journals)]
+               [:span (t :settings.features/enable-journals)]
                  ;; FIXME: why it's not aligned well?
                [:div.mt-1
                 (ui/toggle journal?
@@ -735,7 +732,7 @@
                                (set-setting! :journal? value)))
                            true)]]
               [:div.flex.items-center.justify-between.mb-2
-               [:span "Orphan pages"]
+               [:span (t :graph/orphan-pages)]
                [:div.mt-1
                 (ui/toggle orphan-pages?
                            (fn []
@@ -744,7 +741,7 @@
                                (set-setting! :orphan-pages? value)))
                            true)]]
               [:div.flex.items-center.justify-between.mb-2
-               [:span "Built-in pages"]
+               [:span (t :graph/built-in-pages)]
                [:div.mt-1
                 (ui/toggle builtin-pages?
                            (fn []
@@ -753,7 +750,7 @@
                                (set-setting! :builtin-pages? value)))
                            true)]]
               [:div.flex.items-center.justify-between.mb-2
-               [:span "Excluded pages"]
+               [:span (t :graph/excluded-pages)]
                [:div.mt-1
                 (ui/toggle excluded-pages?
                            (fn []
@@ -763,7 +760,7 @@
                            true)]]
 
               [:div.flex.flex-col.mb-2
-               [:p "Created before"]
+               [:p (t :graph/created-before)]
                (when created-at-filter
                  [:div (.toDateString (js/Date. (+ created-at-filter (get-in graph [:all-pages :created-at-min]))))])
 
@@ -781,8 +778,8 @@
 
               (when (seq focus-nodes)
                 [:div.flex.flex-col.mb-2
-                 [:p {:title "N hops from selected nodes"}
-                  "N hops from selected nodes"]
+                 [:p {:title (t :graph/n-hops-from-selected-nodes)}
+                  (t :graph/n-hops-from-selected-nodes)]
                  (ui/tooltip
                   (ui/slider (or n-hops 10)
                              {:min 1
@@ -797,10 +794,10 @@
                                                       (reset! *created-at-filter nil)
                                                       (set-setting! :created-at-filter nil)
                                                       (state/clear-search-filters!))}
-               "Reset Graph"]]]))
+               (t :graph/reset)]]]))
          {})
         (graph-filter-section
-         [:span.font-medium "Search"]
+         [:span.font-medium (t :graph/search)]
          (fn [open?]
            (filter-expand-area
             open?
@@ -814,26 +811,25 @@
                     svg/close]])
 
                 [:a.opacity-70.opacity-100 {:on-click state/clear-search-filters!}
-                 "Clear All"]]
+                 (t :notification/clear-all)]]
                [:a.opacity-70.opacity-100 {:on-click #(route-handler/go-to-search! :graph)}
-                "Click to search"])]))
+                (t :graph/click-to-search)])]))
          {:search-filters search-graph-filters})
         (graph-filter-section
-         [:span.font-medium "Forces"]
+         [:span.font-medium (t :graph/forces)]
          (fn [open?]
            (filter-expand-area
-            open?
-            [:div
-             [:p.text-sm.opacity-70.px-4
-              (let [c2 (count (:links graph))
-                    s2 (if (> c2 1) "s" "")]
-                (util/format "%d link%s" c2 s2))]
+           open?
+           [:div
+            [:p.text-sm.opacity-70.px-4
+              (let [c2 (count (:links graph))]
+                (t :graph/link-count c2))]
              [:div.p-6
               (simulation-switch)
 
               [:div.flex.flex-col.mb-2
-               [:p {:title "Link Distance"}
-                "Link Distance"]
+               [:p {:title (t :graph/link-distance)}
+                (t :graph/link-distance)]
                (ui/tooltip
                 (ui/slider (/ link-dist 10)
                            {:min 1                                  ;; 10
@@ -844,8 +840,8 @@
                 [:div link-dist])]
 
               [:div.flex.flex-col.mb-2
-               [:p {:title "Charge Strength"}
-                "Charge Strength"]
+               [:p {:title (t :graph/charge-strength)}
+                (t :graph/charge-strength)]
                (ui/tooltip
                 (ui/slider (/ charge-strength 100)
                            {:min -10                                ;;-1000
@@ -856,8 +852,8 @@
                 [:div charge-strength])]
 
               [:div.flex.flex-col.mb-2
-               [:p {:title "Charge Range"}
-                "Charge Range"]
+               [:p {:title (t :graph/charge-range)}
+                (t :graph/charge-range)]
                (ui/tooltip
                 (ui/slider (/ charge-range 100)
                            {:min 5                                  ;;500
@@ -873,17 +869,17 @@
                             (reset! *link-dist 70)
                             (reset! *charge-strength -600)
                             (reset! *charge-range 600))}
-               "Reset Forces"]]]))
+               (t :graph/reset-forces)]]]))
          {})
         (graph-filter-section
-         [:span.font-medium "Export"]
+         [:span.font-medium (t :ui/export)]
          (fn [open?]
            (filter-expand-area
             open?
             (when-let [canvas (js/document.querySelector "#global-graph canvas")]
               [:div.p-6
                  ;; We'll get an empty image if we don't wrap this in a requestAnimationFrame
-               [:div [:a {:on-click #(.requestAnimationFrame js/window (fn [] (utils/canvasToImage canvas "graph" "png")))} "as PNG"]]])))
+               [:div [:a {:on-click #(.requestAnimationFrame js/window (fn [] (utils/canvasToImage canvas "graph" "png")))} (t :graph/as-png)]]])))
          {:search-filters search-graph-filters})]]]]))
 
 (defonce last-node-position (atom nil))
@@ -984,7 +980,7 @@
   (let [show-journals-in-page-graph? (rum/react *show-journals-in-page-graph?)]
     [:div.sidebar-item.flex-col
      [:div.flex.items-center.justify-between.mb-0
-      [:span (t :right-side-bar/show-journals)]
+      [:span (t :graph.page/show-journals)]
       [:div.mt-1
        (ui/toggle show-journals-in-page-graph? ;my-val;
                   (fn []
@@ -1018,7 +1014,7 @@
   (let [current-page (or
                       (and (= :page (state/sub [:route-match :data :name]))
                            (state/sub [:route-match :path-params :name]))
-                      (date/today))
+                      (model/get-today-journal-title))
         theme (:ui/theme @state/state)
         show-journals-in-page-graph (rum/react *show-journals-in-page-graph?)
         page-entity (db/get-page current-page)]
@@ -1038,7 +1034,7 @@
         (ui/icon "alert-triangle")]]
       [:div.mt-3.text-center.sm:mt-0.sm:ml-4.sm:text-left
        [:h3#modal-headline.text-lg.leading-6.font-medium
-        (t :page/batch-delete-confirmation)]]]
+        (t :page.delete/batch-confirm-title)]]]
 
      [:ol.p-2.pt-4
       (for [page-item pages]
@@ -1046,16 +1042,16 @@
          [:a {:href (rfe/href :page {:name (:block/uuid page-item)})}
           (component-block/page-cp {} page-item)]])]
 
-     [:p.px-2.opacity-50 [:small (str "Total: " (count pages))]]
+     [:p.px-2.opacity-50 [:small (t :page.delete/total (count pages))]]
 
      [:div.pt-6.flex.justify-end.gap-4
       (ui/button
-       (t :cancel)
+       (t :ui/cancel)
        :variant :outline
        :on-click close)
 
       (ui/button
-       (t :yes)
+       (t :ui/yes)
        :on-click (fn []
                    (close)
                    (let [failed-pages (atom [])]
@@ -1066,7 +1062,7 @@
                                                                        (swap! failed-pages conj (:block/name page)))}))
                                            pages))]
                        (if (seq @failed-pages)
-                         (notification/show! (t :all-pages/failed-to-delete-pages (string/join ", " (map pr-str @failed-pages)))
+                         (notification/show! (t :page.delete/warning (string/join ", " (map pr-str @failed-pages)))
                                              :warning false)
-                         (notification/show! (t :tips/all-done) :success))))
+                         (notification/show! (t :ui/all-done) :success))))
                    (js/setTimeout #(refresh-fn) 200)))]]))

+ 18 - 15
src/main/frontend/components/page_menu.cljs

@@ -10,6 +10,7 @@
             [frontend.handler.page :as page-handler]
             [frontend.handler.publish :as publish-handler]
             [frontend.mobile.util :as mobile-util]
+            [frontend.modules.shortcut.data-helper :as shortcut-dh]
             [frontend.state :as state]
             [frontend.util :as util]
             [logseq.db :as ldb]
@@ -33,11 +34,11 @@
      {:on-submit (fn [e]
                    (.preventDefault e)
                    (submit!))}
-     [:div.text-lg.font-medium "Publish page"]
+     [:div.text-lg.font-medium (t :publish/dialog-title)]
      [:div.text-sm.opacity-70
-      "Optionally protect this page with a password. Leave empty for public access."]
+      (t :publish/dialog-desc)]
      (shui/toggle-password
-      {:placeholder "Optional password"
+      {:placeholder (t :publish/password-optional-placeholder)
        :value password
        :on-change (fn [e]
                     (set-password! (util/evalue e)))})
@@ -46,20 +47,20 @@
        {:variant "ghost"
         :type "button"
         :on-click #(shui/dialog-close!)}
-       "Cancel")
+       (t :ui/cancel))
       (shui/button
        {:type "submit"
         :auto-focus true
         :disabled publishing?}
        (if publishing?
-         "Publishing..."
-         "Publish"))]]))
+         (t :publish/publishing)
+         (t :publish/action)))]]))
 
 (defn- delete-page!
   [page]
   (page-handler/<delete! (:block/uuid page)
                          (fn []
-                           (notification/show! (str "Page " (:block/title page) " was deleted successfully!")
+                           (notification/show! (t :page.delete/success (:block/title page))
                                                :success))
                          {:error-handler (fn [{:keys [msg]}]
                                            (notification/show! msg :warning))}))
@@ -72,10 +73,12 @@
                   [:span.top-1.relative
                    (shui/tabler-icon "alert-triangle")]
                   (if (or (ldb/class? page) (ldb/property? page))
-                    (t :page/permanently-delete-confirmation)
-                    (t :page/db-delete-confirmation))]
+                    (t :page.delete/permanent-confirm-title)
+                    (t :page.delete/confirm-title))]
           :content [:p.opacity-60 (str "- " (:block/title page))]
-          :outside-cancel? true})
+          :outside-cancel? true
+          :cancel-label (t :ui/cancel)
+          :ok-label (t :ui/confirm)})
         (p/then #(delete-page! page))
         (p/catch #()))))
 
@@ -104,7 +107,7 @@
 
           (when (or (util/electron?)
                     (mobile-util/native-platform?))
-            {:title   (t :page/copy-page-url)
+            {:title   (t :page/copy-url)
              :options {:on-click #(page-handler/copy-page-url (:block/uuid page))}})
 
           (when-not (or contents?
@@ -114,14 +117,14 @@
              :options {:on-click #(delete-page-confirm! page)}})
 
           (when page
-            {:title   (t :export-page)
+            {:title   (t :export/page)
              :options {:on-click #(shui/dialog-open!
                                    (fn []
                                      (export/export-blocks [(:block/uuid page)] {:export-type :page}))
                                    {:class "w-auto md:max-w-4xl max-h-[80vh] overflow-y-auto"})}})
 
           (when (and page (not config/publishing?))
-            {:title   "Publish page"
+            {:title   (t :publish/dialog-title)
              :options {:on-click #(shui/dialog-open! (fn [] (publish-page-dialog page))
                                                      {:class "w-auto max-w-md"})}})
 
@@ -145,12 +148,12 @@
                                    (db-page-handler/convert-page-to-tag! page))}})
 
           (when (and (ldb/class? page) (not (:logseq.property/built-in? page)))
-            {:title (t :page/convert-tag-to-page)
+            {:title (t :page.convert/tag-to-page-action)
              :options {:on-click (fn []
                                    (db-page-handler/convert-tag-to-page! page))}})
 
           (when developer-mode?
-            {:title   (t :dev/show-page-data)
+            {:title   (shortcut-dh/shortcut-desc-by-id :dev/show-page-data)
              :options {:on-click (fn []
                                    (dev-common-handler/show-entity-data (:db/id page)))}})]
          (flatten)

+ 73 - 65
src/main/frontend/components/plugins.cljs

@@ -5,7 +5,7 @@
             [frontend.components.plugins-settings :as plugins-settings]
             [frontend.components.svg :as svg]
             [frontend.config :as config]
-            [frontend.context.i18n :refer [t]]
+            [frontend.context.i18n :refer [interpolate-rich-text interpolate-rich-text-node t]]
             [frontend.handler.common.plugin :as plugin-common-handler]
             [frontend.handler.editor :as editor-handler]
             [frontend.handler.notification :as notification]
@@ -55,10 +55,13 @@
   (rum/local 0 ::cursor)
   (rum/local 0 ::total)
   {:did-mount (fn [state]
-                (let [*themes        (::themes state)
+                 (let [*themes        (::themes state)
                       *cursor        (::cursor state)
                       *total         (::total state)
                       mode           (state/sub :ui/theme)
+                      mode-title     (t (case mode
+                                          "dark" :settings.general/theme-dark
+                                          :settings.general/theme-light))
                       all-themes     (state/sub :plugin/installed-themes)
                       themes         (->> all-themes
                                           (filter #(= (:mode %) mode))
@@ -66,19 +69,19 @@
                       no-mode-themes (->> all-themes
                                           (filter #(= (:mode %) nil))
                                           (sort-by #(:name %))
-                                          (map-indexed (fn [idx opt] (assoc opt :group-first (zero? idx) :group-desc (if (zero? idx) "light & dark themes" nil)))))
+                                          (map-indexed (fn [idx opt] (assoc opt :group-first (zero? idx) :group-desc (if (zero? idx) (t :plugin.themes/light-and-dark) nil)))))
                       selected       (state/sub :plugin/selected-theme)
                       themes         (map-indexed (fn [idx opt]
                                                     (let [selected? (= (:url opt) selected)]
                                                       (when selected? (reset! *cursor (+ idx 1)))
                                                       (assoc opt :mode mode :selected selected?))) (concat themes no-mode-themes))
-                      themes         (cons {:name        (string/join " " ["Default" (string/capitalize mode) "Theme"])
+                      themes         (cons {:name        (t :plugin.themes/default-name (string/capitalize mode-title))
                                             :url         nil
-                                            :description (string/join " " ["Logseq default" mode "theme."])
+                                            :description (t :plugin.themes/default-desc mode-title)
                                             :mode        mode
                                             :selected    (nil? selected)
                                             :group-first true
-                                            :group-desc  (str mode " themes")} themes)]
+                                            :group-desc  (t :plugin.themes/group mode-title)} themes)]
                   (reset! *themes themes)
                   (reset! *total (count themes))
                   state))}
@@ -108,7 +111,7 @@
         *themes (::themes state)]
     [:div.cp__themes-installed
      {:tab-index -1}
-     [:h1.mb-4.text-2xl.p-1 (t :themes)]
+     [:h1.mb-4.text-2xl.p-1 (t :nav/themes)]
      (map-indexed
       (fn [idx opt]
         (let [current-selected? (:selected opt)
@@ -141,9 +144,9 @@
            (fn [^js e]
              (case (keyword (aget e "name"))
                :IllegalPluginPackageError
-                (plugin-handler/show-illegal-plugin-package-notification! e)
+               (plugin-handler/show-illegal-plugin-package-notification! e)
                :ExistedImportedPluginPackageError
-               (notification/show! (str "Existed plugin package (" (.-message e) ").") :error)
+               (notification/show! (t :plugin/existed-package (.-message e)) :error)
                :default)
              (plugin-handler/reset-unpacked-state))
            reg-handle #(plugin-handler/reset-unpacked-state)]
@@ -158,7 +161,7 @@
    [unpacked-pkg-path])
 
   (when unpacked-pkg-path
-    [:strong.inline-flex.px-3 "Loading ..."]))
+    [:strong.inline-flex.px-3 (t :ui/loading)]))
 
 (rum/defc category-tabs
   [t total-nums category on-action]
@@ -167,14 +170,14 @@
    (ui/button
     [:span.flex.items-center
      (ui/icon "puzzle")
-     (t :plugins) (when (vector? total-nums) (str " (" (first total-nums) ")"))]
+     (t :nav/plugins) (when (vector? total-nums) (str " (" (first total-nums) ")"))]
     :intent "link"
     :on-click #(on-action :plugins)
     :class (if (= category :plugins) "active" ""))
    (ui/button
     [:span.flex.items-center
      (ui/icon "palette")
-     (t :themes) (when (vector? total-nums) (str " (" (last total-nums) ")"))]
+     (t :nav/themes) (when (vector? total-nums) (str " (" (last total-nums) ")"))]
     :intent "link"
     :on-click #(on-action :themes)
     :class (if (= category :themes) "active" ""))])
@@ -260,7 +263,9 @@
       [:li {:on-click #(plugin-handler/open-report-modal! id name)} (t :plugin/report-security)]
       [:li {:on-click
             #(-> (shui/dialog-confirm!
-                  [:b (t :plugin/delete-alert name)])
+                  [:b (t :plugin/delete-alert name)]
+                  {:cancel-label (t :ui/cancel)
+                   :ok-label (t :ui/confirm)})
                  (p/then (fn []
                            (plugin-common-handler/unregister-plugin id)
 
@@ -315,7 +320,7 @@
    disabled? market? *search-key has-other-pending?
    installing-or-updating? installed? stat coming-update]
 
-  (let [name (or title name "Untitled")
+  (let [name (or title name (t :ui/untitled))
         web? (not (nil? webPkg))
         unpacked? (and (not web?) (not iir))
         new-version (state/coming-update-new-version? coming-update)]
@@ -356,18 +361,18 @@
                               (reset! *search-key (str "@" author))
                               (.select el))} author]
         [:small {:on-click #(do
-                              (notification/show! "Copied!" :success)
+                              (notification/show! (t :notification/copied) :success)
                               (util/copy-to-clipboard! id))}
          (str "ID: " id)]]]
 
-    ;; Github repo
+    ;; GitHub repo
       [:div.flag.is-top.flex.items-center.space-x-2
        (cond
          (false? (:supportsDB item))
-         [:a.flex.cursor-help {:title "Not supports DB graph"}
+         [:a.flex.cursor-help {:title (t :plugin/does-not-support-db)}
           (shui/tabler-icon "database-off" {:size 17})]
          (true? (:supportsDB item))
-         [:a.flex.cursor-help {:title "Supports DB graph"}
+         [:a.flex.cursor-help {:title (t :plugin/supports-db)}
           (shui/tabler-icon "database-heart" {:size 17})])
        (when repo
          [:a.flex {:target "_blank"
@@ -425,19 +430,19 @@
         *test-input (rum/create-ref)
         disabled?   (or (= (:type opts) "system") (= (:type opts) "direct"))]
     [:div.cp__settings-network-proxy-cnt
-     [:h1.mb-2.text-2xl.font-bold (t :settings-page/network-proxy)]
+     [:h1.mb-2.text-2xl.font-bold (t :settings.advanced/network-proxy)]
      [:div.p-2
-      [:p [:label [:strong (t :type)]
-           (ui/select [{:label "System" :value "system" :selected (= type "system")}
-                       {:label "Direct" :value "direct" :selected (= type "direct")}
-                       {:label "HTTP" :value "http" :selected (= type "http")}
-                       {:label "SOCKS5" :value "socks5" :selected (= type "socks5")}]
+      [:p [:label [:strong (t :ui/type)]
+             (ui/select [{:label (t :plugin.proxy/system) :value "system" :selected (= type "system")}
+                         {:label (t :plugin.proxy/direct) :value "direct" :selected (= type "direct")}
+                         {:label "HTTP" :value "http" :selected (= type "http")}
+                         {:label "SOCKS5" :value "socks5" :selected (= type "socks5")}]
                       (fn [_e value]
                         (set-opts! (assoc opts :type value :protocol value))))]]
       [:p.flex
        [:label.pr-4
         {:class (if disabled? "opacity-50" nil)}
-        [:strong (t :host)]
+        [:strong (t :ui/host)]
         [:input.form-input.is-small
          {:value     (:host opts)
           :disabled  disabled?
@@ -446,7 +451,7 @@
 
        [:label
         {:class (if disabled? "opacity-50" nil)}
-        [:strong (t :port)]
+        [:strong (t :ui/port)]
         [:input.form-input.is-small
          {:value     (:port opts) :type "number" :min 1 :max 65535
           :disabled  disabled?
@@ -471,7 +476,7 @@
          [:option "https://s3.amazonaws.com"]
          [:option "https://clients3.google.com/generate_204"]]]
 
-       (ui/button (if testing? (ui/loading "Testing") "Test URL")
+       (ui/button (if testing? (ui/loading (t :plugin.proxy/testing)) (t :plugin.proxy/test-url))
                   :intent "logseq"
                   :on-click #(let [val (util/trim-safe (.-value (rum/deref *test-input)))]
                                (when (and (not testing?) (not (string/blank? val)))
@@ -480,13 +485,13 @@
                                        (js->clj result :keywordize-keys true))
                                      (p/then (fn [{:keys [code response-ms]}]
                                                (notification/clear! :proxy-net-check)
-                                               (notification/show! (str "Success! Status " code " in " response-ms "ms.") :success)))
+                                               (notification/show! (t :plugin/proxy-check-success code response-ms) :success)))
                                      (p/catch (fn [e]
                                                 (notification/show! (str e) :error false :proxy-net-check)))
                                      (p/finally (fn [] (set-testing?! false)))))))]
 
       [:p.pt-2
-       (ui/button (t :save)
+       (ui/button (t :ui/save)
                   :on-click (fn []
                               (p/let [_ (ipc/ipc :setProxy opts)]
                                 (state/set-state! [:electron/user-cfgs :settings/agent] opts))))]]]))
@@ -498,7 +503,7 @@
         handle-submit! (fn []
                          (set-pending? true)
                          (-> (plugin-handler/load-plugin-from-web-url! url)
-                             (p/then #(do (notification/show! "New plugin registered!" :success)
+                             (p/then #(do (notification/show! (t :plugin/new-registered) :success)
                                           (shui/dialog-close!)))
                              (p/catch #(notification/show! (str %) :error))
                              (p/finally
@@ -512,13 +517,13 @@
                    :auto-focus true})
       [:span.text-gray-10
        (shui/tabler-icon "info-circle" {:size 13})
-       [:span "URLs support both GitHub repositories and local development servers.
-      (For examples: https://github.com/xyhp915/logseq-journals-calendar,
-      http://localhost:8080/<plugin-dir-root>)"]]]
+       [:span (t :plugin.install-from-web-url/supports-note
+                 "https://github.com/xyhp915/logseq-journals-calendar"
+                 "http://localhost:8080/<plugin-dir-root>")]]]
      [:div.flex.justify-end
       (shui/button {:disabled (or pending? (string/blank? url))
                     :on-click handle-submit!}
-                   (if pending? (ui/loading) "Install"))]]))
+                   (if pending? (ui/loading) (t :plugin/install)))]]))
 
 (rum/defc install-from-github-release-container
   []
@@ -527,7 +532,7 @@
         [pending set-pending!] (rum/use-state false)
         *input (rum/use-ref nil)]
     [:div.p-4.flex.flex-col.pb-0
-     (shui/input {:placeholder "GitHub repo url"
+     (shui/input {:placeholder (t :plugin.install-from-web-url/repo-url-placeholder)
                   :value url
                   :ref *input
                   :on-change #(set-url! (util/evalue %))
@@ -536,11 +541,11 @@
       [:label.flex.items-center.gap-2
        (shui/checkbox {:checked (:theme? opts)
                        :on-checked-change #(set-opts! (assoc opts :theme? %))})
-       [:span.opacity-60 "theme?"]]
+       [:span.opacity-60 (t :plugin.install-from-web-url/theme-label)]]
       [:label.flex.items-center.gap-2
        (shui/checkbox {:checked (:effect? opts)
                        :on-checked-change #(set-opts! (assoc opts :effect? %))})
-       [:span.opacity-60 "effect?"]]]
+       [:span.opacity-60 (t :plugin.install-from-web-url/effect-label)]]]
      [:div.flex.justify-end.pt-3
       (shui/button
        {:on-click (fn []
@@ -559,23 +564,26 @@
                                 (p/then #(shui/dialog-close!))
                                 (p/catch #(notification/show! (str %) :error))
                                 (p/finally #(set-pending! false))))
-                          (notification/show! "Invalid GitHub repo url" :error)))))
+                          (notification/show! (t :plugin/invalid-github-repo-url) :error)))))
         :disabled pending}
-       (if pending (ui/loading "Installing") "Install"))]]))
+        (if pending (ui/loading (t :plugin/installing)) (t :plugin/install)))]]))
 
 (rum/defc auto-check-for-updates-control
   []
   (let [[enabled, set-enabled!] (rum/use-state (plugin-handler/get-enabled-auto-check-for-updates?))
-        text (t :plugin/auto-check-for-updates)]
+        text (t :plugin/auto-update-check)]
 
     [:div.flex.items-center.justify-between.px-3.py-2
      {:on-click (fn []
-                  (let [t (not enabled)]
-                    (set-enabled! t)
-                    (plugin-handler/set-enabled-auto-check-for-updates t)
+                  (let [next-enabled (not enabled)]
+                    (set-enabled! next-enabled)
+                    (plugin-handler/set-enabled-auto-check-for-updates next-enabled)
                     (notification/show!
-                     [:span text [:strong.pl-1 (if t "ON" "OFF")] "!"]
-                     (if t :success :info))))}
+                     (into [:span]
+                           (interpolate-rich-text
+                            (t :plugin/auto-update-check-feedback)
+                            [[:strong.pl-1 (t (if next-enabled :ui/on :ui/off))]]))
+                     (if next-enabled :success :info))))}
      [:span.pr-3.opacity-80 text]
      (ui/toggle enabled #() true)]))
 
@@ -703,7 +711,7 @@
                               :options {:on-click #(plugin-handler/user-check-enabled-for-updates! (not= :plugins category))}}])
 
                           (when (util/electron?)
-                            [{:title   [:span.flex.items-center.gap-1 (ui/icon "world") (t :settings-page/network-proxy)]
+                            [{:title   [:span.flex.items-center.gap-1 (ui/icon "world") (t :settings.advanced/network-proxy)]
                               :options {:on-click #(state/pub-event! [:go/proxy-settings agent-opts])}}
 
                              {:title   [:span.flex.items-center.gap-1 (ui/icon "arrow-down-circle") (t :plugin.install-from-file/menu-title)]
@@ -927,7 +935,7 @@
        [:p.flex.justify-center.py-20 svg/loading]
 
        @*error
-       [:p.flex.justify-center.pt-20.opacity-50 (t :plugin/remote-error) (.-message @*error)]
+       [:p.flex.justify-center.pt-20.opacity-50 (t :plugin/remote-error (.-message @*error))]
 
        :else
        [:div.cp__plugins-marketplace-cnt
@@ -1051,7 +1059,7 @@
         (lazy-items-loader load-more-pages!)
         [:div.flex.items-center.justify-center.py-28.flex-col.gap-2.opacity-30
          (shui/tabler-icon "list-search" {:size 40})
-         [:span.text-sm "Nothing Found."]])]]))
+         [:span.text-sm (t :plugin/empty)]])]]))
 
 (rum/defcs waiting-coming-updates
   < rum/reactive
@@ -1093,7 +1101,7 @@
               (ui/tooltip [:span.opacity-30.hover:opacity-80 (ui/icon "info-circle")] [:p notes]))]])]
 
        ;; all done
-       [:div.py-4 [:strong.text-4xl (str "\uD83C\uDF89 " (t :plugin/all-updated))]])
+       [:div.py-4 [:strong.text-4xl (str "🎉 " (t :plugin/update-all-success))]])
 
      ;; actions
      (when (seq updates)
@@ -1142,7 +1150,7 @@
                                (plugin-config-handler/replace-plugins plugins)
                                (shui/dialog-close! "ls-plugins-from-file-modal")))]]
      ;; all done
-     [:div.py-4 [:strong.text-xl (str "\uD83C\uDF89 " (t :plugin.install-from-file/success))]])])
+     [:div.py-4 [:strong.text-xl (str "🎉 " (t :plugin.install-from-file/success))]])])
 
 (defn open-select-theme!
   []
@@ -1223,17 +1231,17 @@
                                                    (plugin-handler/op-pinned-toolbar-item! pkey (if pinned? :remove :add)))
                                                  true))}})
                       [{:hr true}
-                       {:title (t :plugins)
+                       {:title (t :nav/plugins)
                         :options {:on-click #(plugin-handler/goto-plugins-dashboard!)
                                   :class "extra-item mt-2"}
                         :icon (ui/icon "apps")}
 
-                       {:title (t :themes)
+                       {:title (t :nav/themes)
                         :options {:on-click #(plugin-handler/show-themes-modal!)
                                   :class "extra-item"}
                         :icon (ui/icon "palette")}
 
-                       {:title (t :settings)
+                       {:title (t :nav/settings)
                         :options {:on-click #(plugin-handler/goto-plugins-settings!)
                                   :class "extra-item"}
                         :icon (ui/icon "adjustments")}
@@ -1381,7 +1389,7 @@
       :class (when-not (util/electron?) "web-platform")
       :tab-index "-1"}
 
-     [:h1 (t :plugins)]
+     [:h1 (t :nav/plugins)]
 
      (when (util/electron?)
        [:<>
@@ -1488,7 +1496,7 @@
       (when nav?
         [:aside.md:w-64 {:style {:min-width "10rem"}}
          [:header.cp__settings-header
-          [:h1.cp__settings-modal-title (or title (t :settings-of-plugins))]]
+          [:h1.cp__settings-modal-title (or title (t :plugin.settings/title))]]
          (let [plugins (plugin-handler/get-enabled-plugins-if-setting-schema)]
            [:ul.settings-plugin-list
             (for [{:keys [id name title icon]} plugins]
@@ -1508,7 +1516,7 @@
         (when-let [^js pl (and focused (= @*cache focused)
                                (plugin-handler/get-plugin-inst focused))]
           (ui/catch-error
-           [:p.warning.text-lg.mt-5 "Settings schema Error!"]
+            [:p.warning.text-lg.mt-5 (t :plugin/settings-schema-error)]
            (plugins-settings/settings-container
             (bean/->clj (.-settingsSchema pl)) pl)))]]]]))
 
@@ -1526,16 +1534,15 @@
   [pid name url]
   [:div
    [:span.block.whitespace-normal
-    "This plugin "
-    [:strong.text-error "#" name]
-    " takes too long to load, affecting the application startup time and
-     potentially causing other plugins to fail to load."]
+    (interpolate-rich-text-node
+     (t :plugin/perf-tip)
+     [[:strong.text-error (str "#" name)]])]
 
    [:path.opacity-50
     [:small [:span.pr-1 (ui/icon "folder")] url]]
 
    [:p
-    (ui/button "Disable now"
+    (ui/button (t :plugin/disable-now)
                :small? true
                :on-click
                (fn []
@@ -1543,9 +1550,10 @@
                      (p/then #(do
                                 (notification/clear! pid)
                                 (notification/show!
-                                 [:span "The plugin "
-                                  [:strong.text-error "#" name]
-                                  " is disabled."] :success
+                                 (interpolate-rich-text-node
+                                  (t :plugin/disable-for-performance-feedback)
+                                  [[:strong.text-error (str "#" name)]])
+                                 :success
                                  true nil 3000 nil)))
                      (p/catch #(js/console.error %)))))]])
 
@@ -1576,7 +1584,7 @@
    (fn []
      [:div.settings-modal.of-plugins
       (focused-settings-content title)])
-   {:label   "plugin-settings-modal"
+   {:label   :plugin-settings-modal
     :align   :start
     :id      "ls-focused-settings-modal"}))
 

+ 7 - 6
src/main/frontend/components/plugins_settings.cljs

@@ -1,6 +1,7 @@
 (ns frontend.components.plugins-settings
   (:require [cljs-bean.core :as bean]
             [frontend.components.lazy-editor :as lazy-editor]
+            [frontend.context.i18n :refer [t]]
             [frontend.handler.notification :as notification]
             [frontend.handler.plugin :as plugin-handler]
             [frontend.security :as security]
@@ -25,8 +26,8 @@
                   (plugin-handler/open-settings-file-in-default-app! pid)
                   (set-edit-mode! #(if % nil :code))))}
    (if (= edit-mode :code)
-     "Exit code mode"
-     "Edit settings.json")])
+     (t :plugin.settings/exit-code-mode)
+     (t :plugin.settings/edit-settings-json))])
 
 (rum/defc render-item-input
   [val {:keys [key type title default description inputAs]} update-setting!]
@@ -104,7 +105,7 @@
 
 (rum/defc render-item-not-handled
   [s]
-  [:p.text-red-500 (str "#Not Handled# " s)])
+  [:p.text-red-500 (t :plugin/setting-not-handled s)])
 
 (rum/defc settings-container
   [schema ^js pl]
@@ -147,7 +148,7 @@
                                       (let [^js cm (util/get-cm-instance (-> (.-target e) (.closest ".code-mode-wrap")))
                                             content' (some-> (.toJSON plugin-settings) (js/JSON.stringify nil 2))]
                                         (.setValue cm content')))}
-                         "Reset")
+                         (t :ui/reset))
             (shui/button {:size :sm
                           :on-click (fn [^js e]
                                       (try
@@ -158,7 +159,7 @@
                                           (set-edit-mode! nil))
                                         (catch js/Error e
                                           (notification/show! (.-message e) :error))))}
-                         "Save")]]
+                         (t :ui/save))]]
 
           ;; render with gui items
           (for [desc schema
@@ -179,4 +180,4 @@
               key)))]]
 
       ;; no settings
-      [:h2.font-bold.text-lg.py-4.warning "No Settings Schema!"])))
+      [:h2.font-bold.text-lg.py-4.warning (t :plugin/no-settings-schema)])))

+ 20 - 16
src/main/frontend/components/profiler.cljs

@@ -2,6 +2,7 @@
   "Profiler UI"
   (:require [clojure.set :as set]
             [fipp.edn :as fipp]
+            [frontend.context.i18n :refer [t]]
             [frontend.handler.profiler :as profiler-handler]
             [frontend.util :as util]
             [logseq.shui.ui :as shui]
@@ -17,31 +18,32 @@
         *mem-leak-reports (get state ::mem-leak-reports)
         *register-fn-name (get state ::register-fn-name)]
     [:div
-     [:b "Profiling fns(Only support UI thread now):"]
-     [:div.pb-4
+     [:b "Profiling fns (Only support UI thread now):"]
+     [:div.pb-1
       (for [f-name profiling-fns]
         [:div.flex.flex-row.items-center.gap-2
          [:pre.select-text (str f-name)]
          [:a.inline.close.flex.transition-opacity.duration-300.ease-in
-          {:title "Unregister"
+          {:title (t :profiler/unregister)
            :on-pointer-down
            (fn [e]
              (util/stop e)
              (profiler-handler/unregister-fn! f-name))}
           (shui/tabler-icon "x")]])]
-     [:div.flex.flex-row.items-center.gap-2
+     [:div.flex.flex-row.items-center.gap-2.mb-2
       (shui/button
-       {:on-click (fn []
+       {:size :sm
+        :on-click (fn []
                     (when-let [fn-sym (some-> @*register-fn-name symbol)]
                       (profiler-handler/register-fn! fn-sym)))}
        "Register fn")
-      [:input.form-input.my-2.py-1
+      [:input.form-input.flex-1.h-8.leading-8.py-0.box-border
        {:on-change (fn [e] (reset! *register-fn-name (util/evalue e)))
         :on-focus (fn [e] (let [v (.-value (.-target e))]
-                            (when (= v "input fn name here")
+                            (when (= v (t :profiler/input-fn-placeholder))
                               (set! (.-value (.-target e)) ""))))
-        :placeholder "input fn name here"}]]
-     [:div.flex.gap-2.flex-wrap.items-center.pb-3
+        :placeholder (t :profiler/input-fn-placeholder)}]]
+     [:div.flex.gap-2.flex-wrap.items-center.pb-1
       (shui/button
        {:size :sm
         :on-click (fn [_] (reset! *reports (profiler-handler/profile-report)))}
@@ -53,23 +55,25 @@
        (shui/tabler-icon "x") "Reset reports")]
      (let [update-time-sum
            (fn [m] (update-vals m (fn [m2] (update-vals m2 #(.toFixed % 6)))))]
-       [:div.pb-4
+       [:div.pb-0
         [:pre.select-text
          (when @*reports
            (-> @*reports
                (update :time-sum update-time-sum)
                (fipp/pprint {:width 20})
                with-out-str))]])
-     [:hr]
-     [:b "Atom/Volatile Mem Leak Detect(Only support UI thread now):"]
-     [:pre "Only check atoms/volatiles with a value type of `coll`.
+     [:hr.my-2]
+     [:div.pb-1
+      [:b "Atom/Volatile Mem Leak Detect (Only support UI thread now):"]
+      [:pre.mb-2 "Only check atoms/volatiles with a value type of `coll`.
 The report shows refs with coll-size > 5k and atom's watches-count > 1k.
 `ref` means atom or volatile.
-`ref-hash` means `(hash ref)`."]
-     [:div.flex.flex-row.items-center.gap-2
+`ref-hash` means `(hash ref)`."]]
+     [:div.flex.flex-row.items-center.gap-2.pb-2
       (if (= 2 (count (set/difference #{'cljs.core/reset! 'cljs.core/vreset!} (set profiling-fns))))
         (shui/button
-         {:on-click (fn []
+         {:size :sm
+          :on-click (fn []
                       (profiler-handler/mem-leak-detect))}
          "Start to detect")
         (shui/button

+ 15 - 14
src/main/frontend/components/property.cljs

@@ -9,6 +9,7 @@
             [frontend.components.select :as select]
             [frontend.components.svg :as svg]
             [frontend.config :as config]
+            [frontend.context.i18n :refer [t]]
             [frontend.db :as db]
             [frontend.db-mixins :as db-mixins]
             [frontend.db.async :as db-async]
@@ -50,7 +51,7 @@
       (do
         (when (and (not (ldb/public-built-in-property? property))
                    (ldb/built-in? property))
-          (notification/show! "This is a private built-in property that can't be used." :error))
+          (notification/show! (t :property/private-built-in-not-usable) :error))
         property)
       ;; new property entered or converting page to property
       (if (db-property/valid-property-name? property-title)
@@ -62,7 +63,7 @@
                 _ (when add-class-property?
                     (pv/<add-property! entity (:db/ident property) "" {:class-schema? class-schema? :exit-edit? false}))]
           property)
-        (notification/show! "This is an invalid property name. A property name cannot start with page reference characters '#' or '[['." :error)))))
+        (notification/show! (t :property.validation/invalid-name) :error)))))
 
 ;; TODO: This component should be cleaned up as it's only used for new properties and used to be used for existing properties
 (rum/defcs property-type-select <
@@ -72,7 +73,7 @@
                           *show-class-select?
                           default-open? class-schema?]
                    :as opts}]
-  (let [property-name (or (and *property-name @*property-name) (:block/title property))
+  (let [property-name (or (and *property-name @*property-name) (db-property/built-in-display-title property t))
         property-schema (or (and *property-schema @*property-schema)
                             (select-keys property [:logseq.property/type]))
         schema-types (->> (concat db-property-type/user-built-in-property-types
@@ -134,7 +135,7 @@
       (shui/select-trigger
        {:class "!px-2 !py-0 !h-8"}
        (shui/select-value
-        {:placeholder "Select a property type"}))
+        {:placeholder (t :property/select-type-placeholder)}))
       (shui/select-content
        (shui/select-group
         (for [{:keys [label value disabled]} schema-types]
@@ -144,7 +145,7 @@
                                               (util/stop-propagation e)))} label)))))
      (when show-type-change-hints?
        (ui/tooltip (svg/info)
-                   [:span "Changing the property type clears some property configurations."]))]))
+                   [:span (t :property/type-change-warning)]))]))
 
 (rum/defc property-select
   [select-opts]
@@ -172,7 +173,7 @@
                  (map (fn [x]
                         (let [convert? (:convert-page-to-property? x)]
                           {:label (if convert?
-                                    (util/format "Convert \"%s\" to property" (:block/title x))
+                                    (t :property/convert-page-to-property (:block/title x))
                                     (let [ident (:db/ident x)
                                           ns' (some-> ident (namespace))
                                           plugin? (some-> ident (api-block/plugin-property-key?))
@@ -200,7 +201,7 @@
                          :new-case-sensitive? true
                          :show-new-when-not-exact-match? true
                          ;; :exact-match-exclude-items (fn [s] (contains? excluded-properties s))
-                         :input-default-placeholder "Add or change property"
+                         :input-default-placeholder (t :property/add-or-change)
                          :on-input set-q!}
                         select-opts))]])))
 
@@ -279,7 +280,7 @@
    :a
    {:tabIndex 0
     :title (or (:block/title (:logseq.property/description property))
-               (:block/title property))
+               (db-property/built-in-display-title property t))
     :class "property-k flex select-none jtrigger w-full"
     :on-pointer-down (fn [^js e]
                        (when (util/meta-key? e)
@@ -302,7 +303,7 @@
                                      :dropdown-menu? true
                                      :as-dropdown? true})))}
 
-   (:block/title property)))
+   (db-property/built-in-display-title property t)))
 
 (rum/defc property-key-cp < rum/static
   [block property {:keys [other-position? class-schema?]}]
@@ -339,7 +340,7 @@
      (if config/publishing?
        [:a.property-k.flex.select-none.jtrigger
         {:on-click #(route-handler/redirect-to-page! (:block/uuid property))}
-        (:block/title property)]
+        (db-property/built-in-display-title property t)]
        (property-key-title block property class-schema?))]))
 
 (defn- bidirectional-property-icon-cp
@@ -362,7 +363,7 @@
     (if (and blocks-container (seq entities))
       [:div.property-block-container.content.w-full
        (blocks-container config entities)]
-      [:span.opacity-60 "Empty"])))
+      [:span.opacity-60 (t :view.filter/empty)])))
 
 (rum/defc bidirectional-properties-section < rum/static
   [bidirectional-properties]
@@ -493,7 +494,7 @@
         [:div.flex.flex-row.items-center.shrink-0
          (ui/icon "plus" {:size 15 :class "opacity-50"})
          [:div.ml-1 {:style {:margin-top 1}}
-          "Add property"]]]])))
+          (t :property/add-new)]]]])))
 
 (defn- resolve-linked-block-if-exists
   "Properties will be updated for the linked page instead of the refed block.
@@ -684,7 +685,7 @@
     [:details.my-1
      [:summary.text-sm.opacity-50.hover:opacity-90.cursor-pointer
       {:style {:margin-left 11}}
-      [:span.ml-1 "Hidden properties"]]
+      [:span.ml-1 (t :property/hidden-properties)]]
      [:div.mt-1
       (properties-section block hidden-properties opts)]]))
 
@@ -854,7 +855,7 @@
                     [:div.property-key.text-sm
                      (property-key-cp block (db/entity :logseq.property.class/properties) {})]]
                    [:div.text-muted-foreground {:style {:margin-left 26}}
-                    "Tag properties are inherited by all nodes using the tag. For example, each #Task node inherits 'Status' and 'Priority'."]]
+                    (t :class/tag-properties-desc)]]
                   [:div.ml-4
                    (properties-section block properties opts')
                    (hidden-properties-cp block hidden-properties

+ 86 - 73
src/main/frontend/components/property/config.cljs

@@ -6,6 +6,7 @@
             [frontend.components.property.value :as pv]
             [frontend.components.select :as select]
             [frontend.config :as config]
+            [frontend.context.i18n :refer [t]]
             [frontend.db :as db]
             [frontend.db-mixins :as db-mixins]
             [frontend.db.async :as db-async]
@@ -100,12 +101,12 @@
                                    :value (:block/uuid class)})
                                 classes)
                    options (if no-class?
-                             (cons {:label "Skip choosing tag"
+                             (cons {:label (t :property/skip-choosing-tag)
                                     :value :no-tag}
                                    options)
                              options)
                    opts {:items options
-                         :input-default-placeholder (if multiple-choices? "Choose tags" "Choose tag")
+                         :input-default-placeholder (if multiple-choices? (t :property/choose-tags) (t :property/choose-tag))
                          :dropdown? false
                          :close-modal? false
                          :multiple-choices? multiple-choices?
@@ -182,13 +183,13 @@
       (shui/input {:ref *input-ref
                    :size "sm"
                    :default-value title
-                   :placeholder "name"
+                   :placeholder (t :property/name-placeholder)
                    :disabled disabled?
                    :on-key-down (fn [e]
                                   (when (contains? #{"ArrowLeft" "ArrowRight"} (util/ekey e))
                                     (util/stop-propagation e)))
                    :on-change (fn [^js e] (set-form-data! (assoc form-data :title (util/trim-safe (util/evalue e)))))})]
-     [:div.pt-2 (shui/textarea {:placeholder "description" :default-value description
+     [:div.pt-2 (shui/textarea {:placeholder (t :property/description-placeholder) :default-value description
                                 :disabled disabled? :on-change (fn [^js e] (set-form-data! (assoc form-data :description (util/trim-safe (util/evalue e)))))})]
 
      (let [dirty? (not= (rum/deref *form-data) form-data)]
@@ -208,7 +209,7 @@
                                       (p/then #(set-sub-open! false))
                                       (p/catch #(shui/toast! (str %) :error))
                                       (p/finally #(set-saving! false))))}
-                     "Save")])]))
+                     (t :ui/save))])]))
 
 (rum/defc choice-base-edit-form
   [own-property block owner-block]
@@ -239,9 +240,9 @@
       (shui/input {:ref *input-ref :size "sm"
                    :default-value (:value form-data)
                    :on-change (fn [^js e] (set-form-data! (assoc form-data :value (util/trim-safe (util/evalue e)))))
-                   :placeholder "title"})]
+                   :placeholder (t :property/title-placeholder)})]
      [:div.pt-2 (shui/textarea
-                 {:placeholder "description" :default-value (:description form-data)
+                 {:placeholder (t :property/description-placeholder) :default-value (:description form-data)
                   :on-change (fn [^js e] (set-form-data! (assoc form-data :description (util/trim-safe (util/evalue e)))))})]
      [:div.pt-2.flex.justify-end
       (let [dirty? (not= (rum/deref *form-data) form-data)]
@@ -257,7 +258,7 @@
                                       (p/then #(shui/popup-hide!))
                                       (p/catch #(shui/toast! (str %) :error))))
                       :variant (if dirty? :default :secondary)}
-                     "Save"))]]))
+                     (t :ui/save)))]]))
 
 (defn restore-root-highlight-item!
   [id]
@@ -335,18 +336,18 @@
         excluded-ids (set (keep :db/id (:logseq.property/choice-exclusions owner-block')))
         global-choice? (empty? (:logseq.property/choice-classes block))]
     [:li
-     (shui/button {:size :sm :variant :ghost :title "Drag && Drop to reorder"}
+     (shui/button {:size :sm :variant :ghost :title (t :property/drag-to-reorder)}
                   (shui/tabler-icon "grip-vertical" {:size 14}))
      (icon-component/icon-picker icon {:on-chosen (fn [_e icon] (update-icon! icon))
                                        :popup-opts {:align "start"}
                                        :del-btn? (boolean icon)
                                        :empty-label "?"
-                                       :button-opts {:title "Set Icon"}})
+                                       :button-opts {:title (t :property/set-icon)}})
      [:strong {:on-click (fn [^js e]
                            (shui/popup-show! (.-target e)
-                             (fn [] (choice-base-edit-form property block owner-block))
-                             {:id :ls-base-edit-form
-                              :align "start"}))
+                                             (fn [] (choice-base-edit-form property block owner-block))
+                                             {:id :ls-base-edit-form
+                                              :align "start"}))
                :title value}
       value]
      (shui/dropdown-menu
@@ -355,7 +356,7 @@
         :disabled disabled?}
        (shui/button
         {:size :sm :variant :ghost
-         :title "More settings"}
+         :title (t :property/more-settings)}
         (shui/tabler-icon "dots" {:size 16})))
       (shui/dropdown-menu-content
        ;; default choice
@@ -372,10 +373,10 @@
                                                                    value))}
             (shui/checkbox {:id "default value"
                             :size :sm
-                            :title "Set as default choice"
+                            :title (t :property/set-default-choice)
                             :class "mr-1 opacity-50 hover:opacity-100"
                             :checked default-value?})
-            "Set as default choice")))
+            (t :property/set-default-choice))))
 
        (when (and owner-class? owner-block' global-choice?)
          (let [excluded? (contains? excluded-ids (:db/id block))
@@ -389,24 +390,24 @@
              :on-click toggle-exclusion!}
             (shui/checkbox {:id "exclude for tag"
                             :size :sm
-                            :title "Hide choice for this tag"
+                            :title (t :property/hide-choice-for-tag)
                             :class "mr-1 opacity-50 hover:opacity-100"
                             :checked excluded?})
-            (str "Hide for #" tag-title))))
+            (t :property/hide-for-tag tag-title))))
 
        (when-not (and owner-class? global-choice?)
          (shui/dropdown-menu-item
           {:key "delete"
            :class "del"
            :on-click delete-choice!}
-           [:span.w-full.text-red-rx-09.opacity-90.flex.items-center.hover:opacity-100
-            (ui/icon "x" {:class "scale-90 pr-1"}) "Delete"]))))]))
+          [:span.w-full.text-red-rx-09.opacity-90.flex.items-center.hover:opacity-100
+           (ui/icon "x" {:class "scale-90 pr-1"}) (t :ui/delete)]))))]))
 
 (rum/defc add-existing-values
   [property values {:keys [toggle-fn]}]
   [:div.flex.flex-col.gap-1.w-64.p-4.overflow-y-auto
    {:class "max-h-[50dvh]"}
-   [:div "Existing values:"]
+   [:div (t :property/existing-values)]
    [:ol
     (for [value values]
       [:li (:label value)])]
@@ -416,7 +417,7 @@
                                                                                       (map (fn [{:keys [value]}]
                                                                                              (:block/uuid value)) values))]
                    (toggle-fn)))}
-    "Add choices")])
+    (t :property/add-choices))])
 
 (rum/defcs choices-sub-pane < rum/reactive db-mixins/query
   (rum/local false ::show-hidden?)
@@ -459,7 +460,7 @@
             :class "text-muted-foreground"
             :on-click (fn []
                         (swap! *show-hidden? not))}
-           (if @*show-hidden? "Hide hidden choices" "Show hidden choices")))
+           (if @*show-hidden? (t :property/hide-hidden-choices) (t :property/show-hidden-choices))))
         [:ul.choices-list
          (dnd/items choice-items
                     {:sort-by-inner-element? false
@@ -485,7 +486,7 @@
      ;; add choice
      (when-not disabled?
        (dropdown-editor-menuitem
-        {:icon :plus :title "Add choice"
+        {:icon :plus :title (t :property/add-choice)
          :item-props {:on-click
                       (fn [^js e]
                         (p/let [values (db-async/<get-property-values (:db/ident property) {})
@@ -521,7 +522,7 @@
                      opts
                      (shui/select-trigger
                       {:class "h-8"}
-                      (shui/select-value {:placeholder "Select a choice"}))
+                      (shui/select-value {:placeholder (t :property/select-choice)}))
                      (shui/select-content
                       (map (fn [choice]
                              (shui/select-item {:key (str (:db/id choice))
@@ -530,7 +531,7 @@
         unchecked-choice (some (fn [choice] (when (false? (:logseq.property/choice-checkbox-state choice)) choice)) choices)]
     [:div.flex.flex-col.gap-4.text-sm.p-2
      [:div.flex.flex-col.gap-2
-      [:div "Map unchecked to"]
+      [:div (t :property/map-unchecked-to)]
       (select-cp
        (cond->
         {:on-value-change
@@ -542,7 +543,7 @@
          unchecked-choice
          (assoc :default-value (:db/id unchecked-choice))))
 
-      [:div.mt-2 "Map checked to"]
+      [:div.mt-2 (t :property/map-checked-to)]
       (select-cp
        (cond->
         {:on-value-change
@@ -554,11 +555,12 @@
          checked-choice
          (assoc :default-value (:db/id checked-choice))))]]))
 
-(def position-labels
-  {:properties {:icon :layout-distribute-horizontal :title "Block properties"}
-   :block-left {:icon :layout-align-right :title "Beginning of the block"}
-   :block-right {:icon :layout-align-left :title "End of the block"}
-   :block-below {:icon :layout-align-top :title "Below the block"}})
+(defn position-labels
+  []
+  {:properties {:icon :layout-distribute-horizontal :title (t :property/ui-position-properties)}
+   :block-left {:icon :layout-align-right :title (t :property/ui-position-block-left)}
+   :block-right {:icon :layout-align-left :title (t :property/ui-position-block-right)}
+   :block-below {:icon :layout-align-top :title (t :property/ui-position-block-below)}})
 
 (rum/defc ui-position-sub-pane
   [property {:keys [id set-sub-open! _ui-position]}]
@@ -572,7 +574,7 @@
                            (restore-root-highlight-item! id)))
         item-props {:on-select handle-select!}]
     [:div.ls-property-dropdown.ls-property-ui-position-sub-pane
-     (for [[k v] position-labels]
+     (for [[k v] (position-labels)]
        (let [item-props (assoc item-props :data-value k)]
          (dropdown-editor-menuitem
           (assoc v :item-props item-props))))]))
@@ -580,10 +582,13 @@
 (defn property-type-label
   [property-type]
   (case property-type
-    :default
-    "Text"
-    :datetime
-    "DateTime"
+    :default (t :property/type-text)
+    :number (t :property/type-number)
+    :date (t :property/type-date)
+    :datetime (t :property/type-datetime)
+    :checkbox (t :property/type-checkbox)
+    :url (t :property/type-url)
+    :node (t :property/type-node)
     ((comp string/capitalize name) property-type)))
 
 (defn- handle-delete-property!
@@ -594,14 +599,20 @@
                    (property-handler/remove-block-property! (:block/uuid block) (:db/ident property)))]
     (if (and class? class-schema?)
       (-> (shui/dialog-confirm!
-           [:p "Are you sure you want to delete the property from this tag?"]
+           [:p (t :property/delete-from-tag-confirm)]
            {:id :delete-property-from-class
-            :data-reminder :ok})
+            :data-reminder :ok
+            :data-reminder-label (t :ui/dont-remind-me-again)
+            :cancel-label (t :ui/cancel)
+            :ok-label (t :ui/confirm)})
           (p/then remove!))
       (-> (shui/dialog-confirm!
-           "Are you sure you want to delete the property from this node?"
+           (t :property/delete-from-node-confirm)
            {:id :delete-property-from-node
-            :data-reminder :ok})
+            :data-reminder :ok
+            :data-reminder-label (t :ui/dont-remind-me-again)
+            :cancel-label (t :ui/cancel)
+            :ok-label (t :ui/confirm)})
           (p/then remove!)))))
 
 (rum/defc property-type-sub-pane
@@ -634,14 +645,14 @@
         option (if (= :checkbox property-type)
                  (let [default-value (:logseq.property/scalar-default-value property)]
                    {:icon :settings-2
-                    :title "Default value"
+                    :title (t :property/default-value)
                     :toggle-checked? (boolean default-value)
                     :checkbox? true
                     :on-toggle-checked-change (fn []
                                                 (db-property-handler/set-block-property! (:block/uuid property) :logseq.property/scalar-default-value (not default-value)))})
                  (let [default-value (:logseq.property/default-value property)]
-                   {:icon :settings-2 :title "Default value"
-                    :desc (if default-value (db-property/property-value-content default-value) "Set value")
+                   {:icon :settings-2 :title (t :property/default-value)
+                    :desc (if default-value (db-property/property-value-content default-value) (t :property/set-value))
                     :submenu-content (fn [] (pdv/default-value-config property))}))]
     (dropdown-editor-menuitem (assoc option :disabled? config/publishing?))))
 
@@ -649,7 +660,7 @@
   "property: block entity"
   [property owner-block values {:keys [class-schema? debug? with-title? more-options]
                                 :or {with-title? true}}]
-  (let [title (:block/title property)
+  (let [title (db-property/built-in-display-title property t)
         property-type (:logseq.property/type property)
         property-type-label' (some-> property-type (property-type-label))
         enable-closed-values? (contains? db-property-type/closed-value-property-types
@@ -664,18 +675,18 @@
     (->>
      [(when with-title?
         [:h3.font-medium.px-2.py-2.opacity-80.flex.items-center.gap-1
-         "Configure property"])
+         (t :property/configure)])
       (when-not special-built-in-prop?
-        (dropdown-editor-menuitem {:icon :pencil :title "Property name" :desc [:span.flex.items-center.gap-1 icon title]
+        (dropdown-editor-menuitem {:icon :pencil :title (t :property/name) :desc [:span.flex.items-center.gap-1 icon title]
                                    :submenu-content (fn [ops] (name-edit-pane property (assoc ops :disabled? disabled?)))}))
       (let [disabled?' (or disabled? (and property-type (seq values)))]
         (dropdown-editor-menuitem {:icon :letter-t
-                                   :title "Property type"
+                                   :title (t :property/type)
                                    :desc (if disabled?'
                                            (ui/tooltip
                                             [:span (str property-type-label')]
                                             [:div.w-96
-                                             "The type of this property is locked once you start using it. This is to make sure all your existing information stays correct if the property type is changed later. To unlock, all uses of a property must be deleted."])
+                                             (t :property/type-locked-help)])
                                            (str property-type-label'))
                                    :disabled? disabled?'
                                    :submenu-content (fn [ops]
@@ -685,7 +696,7 @@
                  (not (contains? #{:logseq.property.class/extends} (:db/ident property))))
         (dropdown-editor-menuitem {:icon :hash
                                    :disabled? disabled?
-                                   :title "Specify node tags"
+                                   :title (t :property/specify-node-tags)
                                    :desc ""
                                    :submenu-content (fn [_ops]
                                                       [:div.px-4
@@ -700,8 +711,8 @@
 
       (when enable-closed-values?
         (let [values (:property/closed-values property)]
-          (dropdown-editor-menuitem {:icon :list :title "Available choices"
-                                     :desc (when (seq values) (str (count values) " choices"))
+          (dropdown-editor-menuitem {:icon :list :title (t :property/available-choices)
+                                     :desc (when (seq values) (t :property/choices-count (count values)))
                                      :submenu-content (fn []
                                                         (choices-sub-pane property
                                                                           {:disabled? config/publishing?
@@ -713,14 +724,14 @@
           (when (>= (count values) 2)
             (dropdown-editor-menuitem
              {:icon :checkbox
-              :title "Checkbox state mapping"
+              :title (t :property/checkbox-state-mapping)
               :disabled? config/publishing?
               :submenu-content (fn []
                                  (checkbox-state-mapping values))}))))
 
       (when (and (contains? db-property-type/cardinality-property-types property-type) (not disabled?))
         (let [many? (db-property/many? property)]
-          (dropdown-editor-menuitem {:icon :checks :title "Multiple values"
+          (dropdown-editor-menuitem {:icon :checks :title (t :property/multiple-values)
                                      :toggle-checked? many?
                                      :on-toggle-checked-change
                                      (fn []
@@ -730,7 +741,9 @@
                                       ;; Only show dialog for existing values as it can be reversed for unused properties
                                          (if (and (seq values) (not many?))
                                            (-> (shui/dialog-confirm!
-                                                "This action cannot be undone. Do you want to change this property to have multiple values?")
+                                                (t :property/multiple-values-confirm)
+                                                {:cancel-label (t :ui/cancel)
+                                                 :ok-label (t :ui/confirm)})
                                                (p/then update-cardinality-fn))
                                            (update-cardinality-fn))))})))
 
@@ -744,20 +757,20 @@
                                             (empty? (:property/closed-values property))
                                             (contains? #{nil :properties} (:logseq.property/ui-position property)))))
                              (let [position (:logseq.property/ui-position property)]
-                               (dropdown-editor-menuitem {:icon :float-left :title "UI position" :desc (some->> position (get position-labels) (:title))
+                               (dropdown-editor-menuitem {:icon :float-left :title (t :property/ui-position) :desc (some-> (position-labels) (get position) :title)
                                                           :item-props {:class "ui__position-trigger-item"}
                                                           :disabled? config/publishing?
                                                           :submenu-content (fn [ops] (ui-position-sub-pane property (assoc ops :ui-position position)))})))
 
                            (when (not (contains? #{:logseq.property.class/extends :logseq.property.class/properties} (:db/ident property)))
-                             (dropdown-editor-menuitem {:icon :eye-off :title "Hide by default" :toggle-checked? (boolean (:logseq.property/hide? property))
+                             (dropdown-editor-menuitem {:icon :eye-off :title (t :property/hide-by-default) :toggle-checked? (boolean (:logseq.property/hide? property))
                                                         :disabled? config/publishing?
                                                         :on-toggle-checked-change #(db-property-handler/set-block-property! (:db/id property)
                                                                                                                             :logseq.property/hide?
                                                                                                                             %)}))
                            (when (not (contains? #{:logseq.property.class/extends :logseq.property.class/properties} (:db/ident property)))
                              (dropdown-editor-menuitem
-                              {:icon :eye-off :title "Hide empty value"
+                              {:icon :eye-off :title (t :property/hide-empty-value)
                                :toggle-checked? (boolean (:logseq.property/hide-empty-value property))
                                :disabled? config/publishing?
                                :on-toggle-checked-change (fn []
@@ -772,7 +785,7 @@
         [:<>
          (shui/dropdown-menu-separator)
          (dropdown-editor-menuitem
-          {:icon :share-3 :title "Go to this property" :desc ""
+          {:icon :share-3 :title (t :property/go-to-this-property) :desc ""
            :item-props {:class "opacity-90 focus:opacity-100"
                         :on-select (fn []
                                      (shui/popup-hide-all!)
@@ -784,19 +797,19 @@
                             (set (map :db/id (:logseq.property/checkbox-display-properties owner-block)))
                             (:db/id property))]
               (dropdown-editor-menuitem
-               {:icon :checkbox
-                :title (if class-schema? "Show as checkbox on tagged nodes" "Show as checkbox on node")
-                :disabled? config/publishing?
-                :desc (when owner-block
-                        (shui/switch
-                         {:id "show as checkbox" :size "sm"
-                          :checked checked?
-                          :on-click util/stop-propagation
-                          :on-checked-change
-                          (fn [value]
-                            (if value
-                              (db-property-handler/set-block-property! (:db/id owner-block) :logseq.property/checkbox-display-properties (:db/id property))
-                              (db-property-handler/delete-property-value! (:db/id owner-block) :logseq.property/checkbox-display-properties (:db/id property))))}))})))))
+               {:icon :checkbox})
+              :title (if class-schema? (t :property/show-as-checkbox-on-tagged-nodes) (t :property/show-as-checkbox-on-node))
+              :disabled? config/publishing?
+              :desc (when owner-block
+                      (shui/switch
+                       {:id "show as checkbox" :size "sm"
+                        :checked checked?
+                        :on-click util/stop-propagation
+                        :on-checked-change
+                        (fn [value]
+                          (if value
+                            (db-property-handler/set-block-property! (:db/id owner-block) :logseq.property/checkbox-display-properties (:db/id property))
+                            (db-property-handler/delete-property-value! (:db/id owner-block) :logseq.property/checkbox-display-properties (:db/id property))))}))))))
 
       (when (and owner-block
                 ;; Any property should be removable from Tag Properties
@@ -806,7 +819,7 @@
 
         (dropdown-editor-menuitem
          {:id :delete-property :icon :x
-          :title (if class-schema? "Delete property from tag" "Delete property from node")
+          :title (if class-schema? (t :property/delete-from-tag) (t :property/delete-from-node))
           :desc "" :disabled? false
           :item-props {:class "opacity-60 focus:!text-red-rx-09 focus:opacity-100"
                        :on-select (fn [^js e]

+ 51 - 51
src/main/frontend/components/property/value.cljs

@@ -7,6 +7,7 @@
             [frontend.components.icon :as icon-component]
             [frontend.components.select :as select]
             [frontend.config :as config]
+            [frontend.context.i18n :refer [t]]
             [frontend.date :as date]
             [frontend.db :as db]
             [frontend.db-mixins :as db-mixins]
@@ -54,16 +55,11 @@
 
 (rum/defc property-empty-btn-value
   [property & opts]
-  (let [text (cond
-               (= (:db/ident property) :logseq.property/description)
-               "Add description"
-               :else
-               "Empty")]
-    (if (= text "Empty")
-      (shui/button (merge {:class "empty-btn" :variant :text} opts)
-                   text)
-      (shui/button (merge {:class "empty-btn" :variant :text} opts)
-                   text))))
+  (let [text (if (= (:db/ident property) :logseq.property/description)
+               (t :property/add-description)
+               (t :ui/empty))]
+    (shui/button (merge {:class "empty-btn" :variant :text} opts)
+                 text)))
 
 (rum/defc property-empty-text-value
   [property {:keys [property-position table-view?]}]
@@ -74,7 +70,7 @@
        (if-let [icon (:logseq.property/icon property)]
          (icon-component/icon icon {:color? true})
          (ui/icon "line-dashed"))
-       "Empty"))])
+       (t :ui/empty)))])
 
 (defn- get-selected-blocks
   []
@@ -204,7 +200,7 @@
                                                            (:db/id (db/entity :block/page))
                                                            {:entity-id? entity-id?})))))
         (when (seq (:view/selected-blocks @state/state))
-          (notification/show! "Property updated!" :success))
+          (notification/show! (t :property/update-success) :success))
         (when-not many?
           (cond
             exit-edit?
@@ -264,11 +260,13 @@
                                                       (db-property-handler/remove-block-property! (:db/id block)
                                                                                                   :logseq.property.repeat/temporal-property)))))]
        (if (#{:logseq.property/deadline :logseq.property/scheduled} (:db/ident property))
-         [:div "Repeat task"]
-         [:div "Repeat " (if (= :date (:logseq.property/type property)) "date" "datetime")])]]
+           [:div (t :property.repeat/task)]
+           [:div (t (if (= :date (:logseq.property/type property))
+                      :property.repeat/date
+                      :property.repeat/datetime))])]]
      [:div.flex.flex-row.gap-2.ls-repeat-task-frequency
       [:div.flex.text-muted-foreground
-       "Every"]
+         (t :property.repeat/every)]
 
       ;; recur frequency
       [:div.w-10.mr-2
@@ -292,7 +290,7 @@
                         (db/entity :logseq.property/status.done))]
        [:div.flex.flex-col.gap-2
         [:div.text-muted-foreground
-         "When"]
+         (t :property.repeat/when)]
         (shui/select
          (cond->
           {:on-value-change (fn [v]
@@ -302,16 +300,16 @@
            property-id
            (assoc :default-value property-id))
          (shui/select-trigger
-          (shui/select-value {:placeholder "Select a property"}))
+          (shui/select-value {:placeholder (t :property/select-property-placeholder)}))
          (shui/select-content
           (map (fn [choice]
                  (shui/select-item {:key (str (:db/id choice))
-                                    :value (:db/id choice)} (:block/title choice))) properties)))
+                                    :value (:db/id choice)} (db-property/built-in-display-title choice t))) properties)))
         [:div.flex.flex-row.gap-1
          [:div.text-muted-foreground
-          "is:"]
+          (t :property.repeat/is-label)]
          (when done-choice
-           (db-property/property-value-content done-choice))]])]))
+           (db-property/built-in-display-title done-choice t))]])]))
 
 (defn- <resolve-journal-page-for-date
   ([^js d]
@@ -329,6 +327,15 @@
        journal-page
        (create-page-f journal {:redirect? false})))))
 
+(defn- focus-selected-day!
+  [id remaining]
+  (when (pos? remaining)
+    (if-let [selected-day (some-> id
+                                  (js/document.getElementById)
+                                  (.querySelector "[aria-selected=true]"))]
+      (.focus selected-day)
+      (js/setTimeout #(focus-selected-day! id (dec remaining)) 16))))
+
 (rum/defcs calendar-inner < rum/reactive db-mixins/query
   (rum/local (str "calendar-inner-" (js/Date.now)) ::identity)
   {:init (fn [state]
@@ -336,10 +343,7 @@
            state)
    :will-mount (fn [state]
                  (js/setTimeout
-                  #(some-> @(::identity state)
-                           (js/document.getElementById)
-                           (.querySelector "[aria-selected=true]")
-                           (.focus)) 16)
+                  #(focus-selected-day! @(::identity state) 10) 16)
                  state)
    :will-unmount (fn [state]
                    (shui/popup-hide!)
@@ -405,7 +409,7 @@
     (let [overdue? (when date (t/after? current-time (t/plus date (t/seconds 59))))]
       [:div
        (cond-> {} overdue? (assoc :class "overdue"
-                                  :title "Overdue"))
+                                  :title (t :property/overdue)))
        content])))
 
 (defn- start-of-local-day [^js d]
@@ -421,9 +425,9 @@
         tomorrow   (js/Date. (+ (.getTime today) ms-in-day))
         yesterday  (js/Date. (- (.getTime today) ms-in-day))]
     (cond
-      (= (.getTime given-date) (.getTime yesterday)) "Yesterday"
-      (= (.getTime given-date) (.getTime today))     "Today"
-      (= (.getTime given-date) (.getTime tomorrow))  "Tomorrow"
+      (= (.getTime given-date) (.getTime yesterday)) (t :date.nlp/yesterday)
+      (= (.getTime given-date) (.getTime today))     (t :date.nlp/today)
+      (= (.getTime given-date) (.getTime tomorrow))  (t :date.nlp/tomorrow)
       :else nil)))
 
 (rum/defc datetime-value
@@ -623,7 +627,7 @@
                               (remove nil?)
                               (remove #(= :logseq.property/empty-placeholder %))
                               set)
-        clear-value (str "No " (:block/title property))
+        clear-value (t :property/clear-value)
         clear-value-label [:div.flex.flex-row.items-center.gap-1.text-sm
                            (ui/icon "x" {:size 14})
                            [:div clear-value]]
@@ -795,13 +799,7 @@
                  :items options
                  :selected-choices selected-choices
                  :dropdown? dropdown?
-                 :input-default-placeholder (cond
-                                              tags?
-                                              "Set tags"
-                                              alias?
-                                              "Set alias"
-                                              :else
-                                              (str "Set " (:block/title property)))
+                 :input-default-placeholder (t :property/set-placeholder (db-property/built-in-display-title property t))
                  :show-new-when-not-exact-match? (not
                                                   (or (and extends-property?
                                                            (or (contains? (set children-pages) (:db/id block))
@@ -953,7 +951,8 @@
                                    (remove (fn [b] (contains? #{:logseq.property.repeat/recur-unit.minute :logseq.property.repeat/recur-unit.hour} (:db/ident b)))))]
                       (keep (fn [block]
                               (let [icon (pu/get-block-property-value block :logseq.property/icon)
-                                    value (db-property/closed-value-content block)]
+                                    value (or (db-property/built-in-display-title block t)
+                                              (db-property/closed-value-content block))]
                                 {:label (if icon
                                           [:div.flex.flex-row.gap-1.items-center
                                            (icon-component/icon icon {:color? true})
@@ -969,9 +968,9 @@
                          (distinct)))
             items (->> (cond
                          (= :checkbox type)
-                         [{:label "True"
+                         [{:label (t :ui/true)
                            :value true}
-                          {:label "False"
+                          {:label (t :ui/false)
                            :value false}]
                          (= :date type)
                          (map (fn [m] (let [label (:block/title (db/entity (:value m)))]
@@ -997,7 +996,7 @@
                      :selected-choices selected-choices
                      :dropdown? dropdown?
                      :show-new-when-not-exact-match? (not (or closed-values? (= :date type)))
-                     :input-default-placeholder (str "Set " (:block/title property))
+                     :input-default-placeholder (t :property/set-placeholder (db-property/built-in-display-title property t))
                      :extract-chosen-fn :value
                      :extract-fn (fn [x] (or (:label-value x) (:label x)))
                      :content-props content-props
@@ -1070,7 +1069,7 @@
           :style {:min-height 20 :margin-left 3}
           :on-click #(<create-new-block! block property "")}
          (when (:class-schema? opts)
-           "Add description")]))))
+           (t :property/add-description))]))))
 
 (rum/defc property-block-value
   [value block property page-cp opts]
@@ -1156,7 +1155,8 @@
     (let [eid (if (entity-map? value) (:db/id value) [:block/uuid value])
           block (or (db/sub-block (:db/id (db/entity eid))) value)
           property-block? (db-property/property-created-block? block)
-          value' (db-property/closed-value-content block)
+          value' (or (db-property/built-in-display-title block t)
+                     (db-property/closed-value-content block))
           icon (pu/get-block-property-value block :logseq.property/icon)]
       (cond
         icon
@@ -1177,7 +1177,7 @@
         [:span.inline-flex.w-full
          (let [value' (str value')
                value' (if (string/blank? value')
-                        "Empty"
+                        (t :ui/empty)
                         value')]
            (inline-text {} :markdown value'))]))))
 
@@ -1215,12 +1215,12 @@
                                             (shui/dropdown-menu-item
                                              {:key "open"
                                               :on-click #(route-handler/redirect-to-page! (:block/uuid value))}
-                                             (str "Open " (:block/title value)))
+                                             (t :ui/open-named (:block/title value)))
 
                                             (shui/dropdown-menu-item
                                              {:key "open sidebar"
                                               :on-click #(state/sidebar-add-block! (state/get-current-repo) (:db/id value) :page)}
-                                             "Open in sidebar")])
+                                             (t :sidebar.right/open))])
                                          {:as-dropdown? true
                                           :content-props {:on-click (fn [] (shui/popup-hide!))}
                                           :align "start"}))}]
@@ -1312,7 +1312,7 @@
        (and (= :logseq.property/default-value (:db/ident property)) (nil? (:block/title value)))
        [:div.jtrigger.cursor-pointer.text-sm.px-2
         {:on-click #(<create-new-block! block property "")}
-        "Set default value"]
+        (t :property/set-default-value)]
 
        (= (:db/ident property) :logseq.property.publish/published-url)
        [:div.flex.items-center.gap-2.w-full
@@ -1328,7 +1328,7 @@
             :on-click (fn [e]
                         (util/stop e)
                         (publish-handler/unpublish-page! block))}
-           "Unpublish"))]
+           (t :publish/unpublish)))]
 
        text-ref-type?
        (property-block-value value block property page-cp opts)
@@ -1600,7 +1600,7 @@
   [state block property {:keys [show-tooltip? p-block p-property editing?]
                          :as opts}]
   (ui/catch-error
-   (ui/block-error "Something wrong" {})
+  (ui/block-error (t :sync/something-wrong) {})
    (let [block-cp (state/get-component :block/blocks-container)
          opts (merge opts
                      {:page-cp (state/get-component :block/page-cp)
@@ -1627,7 +1627,7 @@
               (not= :logseq.class/Tag
                     (:db/ident (db/entity (:db/id block)))))
        [:div.flex.flex-row.items-center.gap-1
-        [:div.warning "Self reference"]
+        [:div.warning (t :property/self-reference)]
         (shui/button {:variant :outline
                       :size :sm
                       :class "h-5"
@@ -1635,7 +1635,7 @@
                                   (db-property-handler/remove-block-property!
                                    (:db/id block)
                                    (:db/ident property)))}
-                     "Fix it!")]
+                     (t :ui/fix))]
        (let [empty-value? (when (coll? v) (= :logseq.property/empty-placeholder (:db/ident (first v))))
              closed-values? (seq (:property/closed-values property))
              value-cp [:div.property-value-inner
@@ -1667,5 +1667,5 @@
                :as-child true}
               value-cp)
              (shui/tooltip-content
-              (str "Change " (:block/title property)))))
+              (t :property/change-tooltip (db-property/built-in-display-title property t)))))
            value-cp))))))

+ 42 - 18
src/main/frontend/components/query.cljs

@@ -12,13 +12,24 @@
             [frontend.util :as util]
             [lambdaisland.glogi :as log]
             [logseq.db :as ldb]
+            [logseq.shui.ui :as shui]
             [rum.core :as rum]))
 
 (defn- built-in-custom-query?
-  [title]
+  [{:keys [title-key]}]
   (let [queries (get-in (state/sub-config) [:default-queries :journals])]
     (when (seq queries)
-      (boolean (some #(= % title) (map :title queries))))))
+      (boolean
+       (some (fn [built-in-query]
+               (and title-key
+                    (= (:title-key built-in-query) title-key)))
+             queries)))))
+
+(defn- resolve-built-in-query?
+  [built-in-query? q]
+  (boolean
+   (or built-in-query?
+       (built-in-custom-query? q))))
 
 (defn- grouped-by-page-result?
   [result group-by-page?]
@@ -47,7 +58,7 @@
     (if @*query-error
       (do
         (log/error :exception @*query-error)
-        [:div.warning.my-1 "Query failed: "
+        [:div.warning.my-1 (t :query/error)
          [:p (.-message @*query-error)]])
       [:div.custom-query-results
        (cond
@@ -57,8 +68,7 @@
                         (catch :default error
                           (log/error :custom-view-failed {:error error
                                                           :result result})
-                          [:div "Custom view failed: "
-                           (str error)]))]
+                          [:div (t :query/custom-view-error (str error))]))]
            (util/hiccup-keywordize result))
 
          (not (:built-in-query? config))
@@ -102,21 +112,33 @@
          nil
 
          :else
-         [:div.text-sm.mt-2.opacity-90 (t :search-item/no-result)])])))
+         [:div.text-sm.mt-2.opacity-90 (t :search/no-result)])])))
 
 (rum/defc query-title
-  [config title {:keys [result-count]}]
+  [config {:keys [title title-key title-icon]} {:keys [result-count]}]
   (let [inline-text (:inline-text config)]
     [:div.custom-query-title.flex.justify-between.w-full
-     [:span.title-text (cond
-                         (vector? title) title
-                         (string? title) (inline-text config
-                                                      (get-in config [:block :block/format] :markdown)
-                                                      title)
-                         :else title)]
+     [:span.title-text
+      (cond
+        title-key
+        [:span
+         (when title-icon
+           (shui/tabler-icon title-icon {:class "align-middle pr-1"}))
+         [:span.align-middle (t title-key)]]
+
+        (vector? title)
+        title
+
+        (string? title)
+        (inline-text config
+                     (get-in config [:block :block/format] :markdown)
+                     title)
+
+        :else
+        title)]
      (when result-count
        [:span.opacity-60.text-sm.ml-2.results-count
-        (str result-count (if (> result-count 1) " results" " result"))])]))
+        (t :search/result-count result-count)])]))
 
 (defn- calculate-collapsed?
   [current-block current-block-uuid {:keys [collapsed? container-id]}]
@@ -167,7 +189,9 @@
               :group-by-page? (query-result/get-group-by-page q {:table? table?})}]
     (if (:custom-query? config)
       ;; Don't display recursive results when query blocks are a query result
-      [:code (if dsl-query? (str "Results for " (pr-str query)) "Advanced query results")]
+      [:code (if dsl-query?
+               (t :query/results-for (pr-str query))
+               (t :query/advanced-results))]
       (when-not (and built-in-query? (empty? result))
         [:div.custom-query (get config :attr {})
          (when (and dsl-query? builder) builder)
@@ -175,7 +199,7 @@
          (if built-in-query?
            [:div {:style {:margin-left 2}}
             (ui/foldable
-             (query-title config (:title q) {:result-count (count result)})
+             (query-title config q {:result-count (count result)})
              (fn []
                (custom-query-inner config q opts))
              {:default-collapsed? collapsed?
@@ -191,7 +215,7 @@
   [state {:keys [built-in-query?] :as config}
    {:keys [collapsed?] :as q}]
   (ui/catch-error
-   (ui/block-error "Query Error:" {:content (:query q)})
+   (ui/block-error (t :query/error) {:content (:query q)})
    (let [*query-error (:query-error state)
          current-block-uuid (or (:block/uuid (:block config))
                                 (:block/uuid config))
@@ -205,7 +229,7 @@
                         :current-block current-block
                         :current-block-uuid current-block-uuid
                         :collapsed? collapsed?'
-                        :built-in-query? (built-in-custom-query? (:title q))
+                        :built-in-query? (resolve-built-in-query? built-in-query? q)
                         :*query-error *query-error)]
      (when (or built-in-collapsed? (not collapsed?'))
        (custom-query* config' q)))))

+ 51 - 24
src/main/frontend/components/query/builder.cljs

@@ -8,6 +8,7 @@
             [frontend.db.async :as db-async]
             [frontend.db.model :as db-model]
             [frontend.db.query-dsl :as query-dsl]
+            [frontend.context.i18n :refer [t]]
             [frontend.handler.editor :as editor-handler]
             [frontend.handler.query.builder :as query-builder]
             [frontend.mixins :as mixins]
@@ -42,6 +43,23 @@
   (swap! *tree #(query-builder/append-element % loc x))
   (when toggle? (toggle-fn)))
 
+(defn- filter-label
+  [value]
+  (case value
+    "tags" (t :property.built-in/tags)
+    "page reference" (t :query.builder/filter-page-reference-label)
+    "property" (t :class.built-in/property)
+    "task" (t :class.built-in/task)
+    "priority" (t :property.built-in/priority)
+    "page" (t :query.builder/filter-page-label)
+    "full text search" (t :query.builder/filter-full-text-search-label)
+    "between" (t :view.filter/operator-between)
+    "sample" (t :query.builder/filter-sample-label)
+    "and" (t :query.builder/operator-and-label)
+    "or" (t :view.filter/or)
+    "not" (t :query.builder/operator-not-label)
+    value))
+
 (rum/defcs search < (rum/local nil ::input-value)
   (mixins/event-mixin
    (fn [state]
@@ -63,8 +81,8 @@
   (let [*input-value (::input-value state)]
     [:input#query-builder-search.form-input.block.sm:text-sm.sm:leading-5
      {:auto-focus true
-      :placeholder "Full text search"
-      :aria-label "Full text search"
+      :placeholder (t :search/full-text-placeholder)
+      :aria-label (t :search/full-text-placeholder)
       :on-change #(reset! *input-value (util/evalue %))}]))
 
 (defonce *between-dates (atom {}))
@@ -102,15 +120,15 @@
   [state {:keys [tree loc] :as opts}]
   [:div.between-date.p-4 {:on-pointer-down (fn [e] (util/stop-propagation e))}
    [:div.flex.flex-row.items-center.gap-2
-    [:div.font-medium "Between: "]
-    (datepicker :start "Start date"
+    (datepicker :start (t :query.builder/between-start-label)
                 (merge opts {:on-select (fn []
                                           (when-let [^js end-input (js/document.querySelector ".query-builder-datepicker[data-key=end]")]
                                             (when (string/blank? (.-value end-input))
                                               (.focus end-input))))}))
-    (datepicker :end "End date" opts)]
+    "~"
+    (datepicker :end (t :query.builder/between-end-label) opts)]
    [:p.pt-2
-    (ui/button "Submit"
+    (ui/button (t :ui/submit)
                :on-click (fn []
                            (let [{:keys [start end]} @*between-dates]
                              (when (and start end)
@@ -134,7 +152,7 @@
      [:div.flex.flex-row.justify-between.gap-1.items-center.px-1.pb-1.border-b
       [:label.opacity-50.cursor.select-none.text-sm
        {:for "built-in"}
-       "Show built-in properties"]
+       (t :query.builder/show-built-in-properties)]
       (shui/checkbox
        {:id "built-in"
         :value @*private-property?
@@ -149,8 +167,9 @@
 (rum/defc property-value-select-inner
   < rum/reactive db-mixins/query
   [*property *private-property? *tree opts loc values]
-  (let [values' (cons {:label "Select all"
-                       :value "Select all"}
+  (let [select-all-label (t :view.table/select-all)
+        values' (cons {:label select-all-label
+                       :value select-all-label}
                       (map #(hash-map :value (str (:value %))
                                       ;; Preserve original-value as non-string values like boolean do not display in select
                                       :original-value (:value %))
@@ -158,7 +177,7 @@
     (select values'
             (fn [{:keys [value original-value]}]
               (let [k (if @*private-property? :private-property :property)
-                    x (if (= value "Select all")
+                    x (if (= value select-all-label)
                         [k @*property]
                         [k @*property original-value])]
                 (reset! *property nil)
@@ -292,14 +311,18 @@
   (let [*mode (::mode state)
         filters query-builder/db-based-block-filters
         filters-and-ops (concat filters query-builder/operators)
-        operator? #(contains? query-builder/operators-set (keyword %))]
+        operator? #(contains? query-builder/operators-set (keyword %))
+        select-items (mapv (fn [value]
+                             {:value value
+                              :label (filter-label value)})
+                           (map name filters-and-ops))]
     [:div.query-builder-picker
      (if @*mode
        (when-not (operator? @*mode)
          (db-based-query-filter-picker state *tree loc clause opts))
        [:div
         (select
-         (map name filters-and-ops)
+         select-items
          (fn [{:keys [value]}]
            (cond
              (operator? value)
@@ -307,7 +330,11 @@
 
              :else
              (reset! *mode value)))
-         {:input-default-placeholder "Add filter/operator"})])]))
+         {:extract-fn (fn [{:keys [label value]}]
+                        (if label
+                          (str label " " value)
+                          value))
+          :input-default-placeholder (t :query.builder/add-filter-or-operator-placeholder)})])]))
 
 (rum/defc add-filter
   [*tree loc clause]
@@ -322,7 +349,7 @@
                                     (picker *tree loc clause {:toggle-fn #(shui/popup-hide! id)}))
                                   {:align :start}))}
    (ui/icon "plus" {:size 14})
-   (when (= [0] loc) "Filter")))
+   (when (= [0] loc) (t :query.builder/filter))))
 
 (declare clauses-group)
 
@@ -340,7 +367,7 @@
       (str clause)
 
       (string? clause)
-      (str "Search: " clause)
+      (t :query.builder/search-label clause)
 
       (= (keyword f) :page-ref)
       (ref/->page-ref (uuid->page-title (second clause)))
@@ -384,9 +411,9 @@
                   (second end))]
         (str (cond
                (= k :block/created-at)
-               "Created"
+               (t :query.builder/created-label)
                (= k :block/updated-at)
-               "Updated"
+               (t :query.builder/updated-label)
                :else
                (or (:block/title (db/entity k)) (name k)))
              " " start
@@ -403,7 +430,7 @@
                         (symbol? (last clause)))
                   (name (last  clause))
                   (second (last clause)))]
-        (str "between: " (uuid->page-title start) " ~ " (uuid->page-title end)))
+        (t :query.builder/between-journal-label (uuid->page-title start) (uuid->page-title end)))
 
       (contains? #{:task :priority} (keyword f))
       (str (name f) ": "
@@ -423,24 +450,24 @@
 (rum/defc clause-inner
   [*tree loc clause & {:keys [operator?]}]
   (let [popup [:div.p-4.flex.flex-col.gap-2
-               [:a {:title "Delete"
+               [:a {:title (t :ui/delete)
                     :on-click (fn []
                                 (swap! *tree (fn [q]
                                                (let [loc' (if operator? (vec (butlast loc)) loc)]
                                                  (query-builder/remove-element q loc'))))
                                 (shui/popup-hide!))}
-                "Delete"]
+                (t :ui/delete)]
 
                (when operator?
-                 [:a {:title "Unwrap this operator"
+                 [:a {:title (t :query.builder/unwrap-operator)
                       :on-click (fn []
                                   (swap! *tree (fn [q]
                                                  (let [loc' (vec (butlast loc))]
                                                    (query-builder/unwrap-operator q loc'))))
                                   (shui/popup-hide!))}
-                  "Unwrap"])
+                  (t :query.builder/unwrap-operator)])
 
-               [:div.font-medium.text-sm "Wrap this filter with: "]
+               [:div.font-medium.text-sm (t :query.builder/wrap-filter-with-label)]
                [:div.flex.flex-row.gap-2
                 (for [op query-builder/operators]
                   (ui/button (string/upper-case (name op))
@@ -454,7 +481,7 @@
 
                (when operator?
                  [:div
-                  [:div.font-medium.text-sm "Replace with: "]
+                  [:div.font-medium.text-sm (t :query.builder/replace-with-label)]
                   [:div.flex.flex-row.gap-2
                    (for [op (remove #{(keyword (string/lower-case clause))} query-builder/operators)]
                      (ui/button (string/upper-case (name op))

+ 1 - 1
src/main/frontend/components/query/view.cljs

@@ -32,7 +32,7 @@
     [:div.query-result.w-full
      (views/view
       {:config (assoc {:custom-query? true} :sidebar? (:sidebar? config))
-       :title-key :views.table/live-query-title
+       :title-key :view.table/live-query-title
        :view-entity view-entity
        :view-feature-type :query-result
        :data ids

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio