diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml
index f7b1a8d..bfc4805 100644
--- a/.github/workflows/development.yml
+++ b/.github/workflows/development.yml
@@ -15,6 +15,18 @@ permissions:
jobs:
build:
name: OS ${{matrix.os}} / Elixir ${{matrix.elixir}} / OTP ${{matrix.otp}}
+ services:
+ db:
+ image: postgres:16
+ ports: ["5432:5432"]
+ env:
+ POSTGRES_USER: ${{ vars.POSTGRES_USERNAME }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
strategy:
matrix:
elixir: ['1.14', '1.15', '1.16', '1.17']
@@ -48,16 +60,16 @@ jobs:
path: deps
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- - name: Set up Postgres
- run: |
- sudo apt-get update
- sudo apt-get install -y postgresql
- sudo service postgresql start
- sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD 'postgres';"
- name: Install dependencies
run: mix deps.get
- - name: Create database
- run: mix do ecto.create, ecto.migrate
+ - name: Reset database and run migrations
+ env:
+ POSTGRES_USERNAME: ${{ vars.POSTGRES_USERNAME }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ POSTGRES_HOSTNAME: ${{ vars.POSTGRES_HOSTNAME }}
+ SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }}
+ MIX_ENV: test
+ run: mix ecto.drop && mix ecto.create && mix ecto.migrate
- name: Compile code
run: mix compile --warnings-as-errors
- name: Check Formatting
@@ -67,4 +79,10 @@ jobs:
- name: Credo
run: mix credo
- name: Run tests
- run: MIX_ENV=test mix test
+ env:
+ POSTGRES_USERNAME: ${{ vars.POSTGRES_USERNAME }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ POSTGRES_HOSTNAME: ${{ vars.POSTGRES_HOSTNAME }}
+ SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }}
+ MIX_ENV: test
+ run: mix test
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23c59bc..b9aa880 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
-## Unreleased
+## [v0.5.0]
+
+### Breaking Changes
+- **Gettext 0.26.0 Migration**: This version requires updating your Gettext module definition to use the new backend adapter system. You must define `use Kanta.Gettext.Backend` in your Gettext module and configure the adapter.
+
+### Added
+- Adds Gettext 0.26.0 compatibility with a custom backend adapter system
## [v0.4.2]
### Fixed
diff --git a/README.md b/README.md
index 9d3cac9..f470105 100644
--- a/README.md
+++ b/README.md
@@ -80,6 +80,12 @@ If you're working on an Elixir/Phoenix project and need to manage translations,
Roadmap
+
+ Development
+
+
Contributing
License
Contact
@@ -116,13 +122,10 @@ by adding `kanta` to your list of dependencies in `mix.exs`:
def deps do
[
{:kanta, "~> 0.4.2"},
- {:gettext, git: "git@github.com:ravensiris/gettext.git", branch: "runtime-gettext"}
]
end
```
-The dependency on this specific `gettext` version is because this library depends on an in-progress feature, to be included in a future release of `gettext` (see discussion in elixir-gettext/gettext#280 and pull request elixir-gettext/gettext#305). As of March 2023, this has been approved by an Elixir core team member, so we are eagerly awaiting for it being merged upstream.
-
## Configuration
Add to `config/config.exs` file:
@@ -148,6 +151,12 @@ mix ecto.gen.migration add_kanta_translations_table
Open the generated migration file and set up `up` and `down` functions.
+**Current Migration Versions:**
+- PostgreSQL: **v4** (adds default context support for Gettext 0.26 backend)
+- SQLite: **v3** (adds default context support for Gettext 0.26 backend)
+
+If you're upgrading from an earlier version of Kanta, update your migration version to the latest.
+
### PostgreSQL
```elixir
@@ -160,12 +169,29 @@ defmodule MyApp.Repo.Migrations.AddKantaTranslationsTable do
# We specify `version: 1` because we want to rollback all the way down including the first migration.
def down do
- Kanta.Migration.down(version: 4, prefix: prefix()) # Prefix is needed if you are using multitenancy with i.e. triplex
+ Kanta.Migration.down(version: 1, prefix: prefix()) # Prefix is needed if you are using multitenancy with i.e. triplex
end
end
```
-after that run
+### SQLite
+
+```elixir
+defmodule MyApp.Repo.Migrations.AddKantaTranslationsTable do
+ use Ecto.Migration
+
+ def up do
+ Kanta.Migration.up(version: 3)
+ end
+
+ # We specify `version: 1` because we want to rollback all the way down including the first migration.
+ def down do
+ Kanta.Migration.down(version: 1)
+ end
+end
+```
+
+After that run:
```bash
mix ecto.migrate
@@ -173,12 +199,22 @@ mix ecto.migrate
## Gettext module
-We now need to pass information to our project's `Gettext` module that we want Kanta to manage translations. To do this add `Kanta.Gettext.Repo` as a default translation repository inside your `Gettext` module.
+Configuring Gettext requires just a single change.
+
+Wherever you have:
```elixir
-use Gettext, ..., repo: Kanta.Gettext.Repo
+use Gettext, backend: YourApp.Gettext
```
+replace it with:
+
+```elixir
+use Kanta.Gettext, backend: YourApp.Gettext
+```
+
+If you're using a Gettext version lower than 0.26, refer to the [official documentation](https://github.com/elixir-gettext/gettext) for migration instructions.
+
## Kanta Supervisor
In the `application.ex` file of our project, we add Kanta and its configuration to the list of processes.
@@ -222,7 +258,9 @@ Kanta is based on the Phoenix Framework's default localization tool, GNU gettext
-Messages and translations from .po files are stored in tables created by the Kanta.Migration module. This allows easy viewing and modification of messages from the Kanta UI or directly from database tools. The caching mechanism prevents constant requests to the database when downloading translations, so you don't have to worry about a delay in application performance.
+Messages and translations from .po files are stored in tables created by the Kanta.Migration module. This allows easy viewing and modification of messages from the Kanta UI or directly from database tools.
+
+With Gettext 0.26+, Kanta uses a custom backend adapter system (`Kanta.Backend.Adapter.CachedDB`) that fetches translations from the database/cache at runtime instead of compiled PO files. The caching mechanism prevents constant requests to the database when downloading translations, so you don't have to worry about a delay in application performance.
## Translation progress
@@ -323,6 +361,25 @@ See the [open issues](https://github.com/curiosum-dev/kanta/issues) for a full l
(back to top)
+# Development
+
+## Running Tests
+
+If you're contributing to Kanta development, you'll need to run the test suite. The tests require a PostgreSQL database.
+
+### Prerequisites for Development
+- PostgreSQL 15+ (for running tests)
+- All prerequisites listed in [Getting Started](#prerequisites)
+
+### Test Setup
+
+First-time setup (or if tests are failing due to database issues):
+
+```bash
+# Setup test database and run migrations
+MIX_ENV=test mix ecto.drop && MIX_ENV=test mix ecto.create && MIX_ENV=test mix ecto.migrate
+```
+
## Contributing
diff --git a/config/config.exs b/config/config.exs
index 9c5ec29..bddde38 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -54,3 +54,17 @@ if config_env() == :dev do
tag_template: "v{version}",
tag_message_template: "Release v{version}"
end
+
+if config_env() == :test do
+ config :kanta,
+ ecto_repos: [Kanta.Test.Repo]
+
+ config :kanta, Kanta.Test.Repo,
+ username: System.get_env("POSTGRES_USERNAME", "postgres"),
+ password: System.get_env("POSTGRES_PASSWORD", "postgres"),
+ hostname: System.get_env("POSTGRES_HOSTNAME", "localhost"),
+ database: "kanta_test",
+ port: 5432,
+ pool: Ecto.Adapters.SQL.Sandbox,
+ pool_size: 10
+end
diff --git a/lib/kanta/backend.ex b/lib/kanta/backend.ex
new file mode 100644
index 0000000..cd93b2d
--- /dev/null
+++ b/lib/kanta/backend.ex
@@ -0,0 +1,116 @@
+defmodule Kanta.Backend do
+ @moduledoc """
+ Kanta.Backend is a module that provides an enhanced Gettext backend with database support.
+
+ It extends the standard Gettext functionality by:
+ 1. First checking for translations in the database
+ 2. Falling back to PO file translations if not found in the database
+
+ ## Usage
+
+ ```elixir
+ defmodule MyApp.Gettext do
+ use Kanta.Backend, otp_app: :my_app
+ end
+ ```
+
+ ## Options
+
+ * `:otp_app` - The OTP application that contains the backend
+ * `:priv` - The directory where the translations are stored (defaults to "priv/YOUR_MODULE")
+ * `:kanta_adapter` - The adapter module to use for database lookups (defaults to `Kanta.Backend.Adapter.CachedDB`)
+
+ it also accepts all the Gettext.Backend options. See the official Gettext documentation for more details.
+
+
+ """
+ alias Kanta.Utils.ModuleFolder
+ require Logger
+
+ defmacro __using__(opts) do
+ quote bind_quoted: [opts: opts] do
+ require Logger
+ @flag_file Path.join([Mix.Project.build_path(), "kanta_recompile", ".gettext_recompiled"])
+ @adapter Keyword.get(opts, :kanta_adapter, Kanta.Backend.Adapter.CachedDB)
+ opts = Keyword.drop(opts, [:kanta_adapter])
+ # Generate fallback Gettext backend form PO files
+ use Kanta.Backend.GettextFallback, opts
+
+ # When `mix gettext extract` create POT/PO files based on this backend usage (ex. getext(...) call) across the application codebase.
+ if Gettext.Extractor.extracting?() do
+ use Gettext.Backend, opts
+
+ Kanta.Utils.GettextRecompiler.setup_recompile_flag(@flag_file)
+ else
+ opts = Keyword.merge(opts, priv: "priv/#{ModuleFolder.safe_folder_name(__MODULE__)}")
+ use Gettext.Backend, opts
+ end
+
+ def __mix_recompile__?() do
+ Kanta.Utils.GettextRecompiler.needs_recompile?(@flag_file)
+ end
+
+ def __gettext__(:known_locales) do
+ backend = fallback_backend()
+ Gettext.known_locales(backend)
+ end
+
+ def handle_missing_translation(locale, domain, msgctxt, msgid, bindings) do
+ case Kanta.Backend.Adapter.CachedDB.lgettext(
+ locale,
+ domain,
+ msgctxt,
+ msgid,
+ bindings
+ ) do
+ {:ok, translation} ->
+ {:ok, translation}
+
+ {:error, :not_found} ->
+ backend = fallback_backend()
+ backend.lgettext(locale, domain, msgctxt, msgid, bindings)
+ end
+ end
+
+ def handle_missing_plural_translation(
+ locale,
+ domain,
+ msgctxt,
+ msgid,
+ msgid_plural,
+ n,
+ bindings
+ ) do
+ case Kanta.Backend.Adapter.CachedDB.lngettext(
+ locale,
+ domain,
+ msgctxt,
+ msgid,
+ msgid_plural,
+ n,
+ bindings
+ ) do
+ {:ok, translation} ->
+ {:ok, translation}
+
+ {:error, :not_found} ->
+ backend = fallback_backend()
+
+ backend.lngettext(
+ locale,
+ domain,
+ msgctxt,
+ msgid,
+ msgid_plural,
+ n,
+ bindings
+ )
+ end
+ end
+
+ defp fallback_backend() do
+ Module.concat(__MODULE__, GettextFallbackBackend)
+ end
+ end
+ end
+end
diff --git a/lib/kanta/backend/adapter.ex b/lib/kanta/backend/adapter.ex
new file mode 100644
index 0000000..d0b6496
--- /dev/null
+++ b/lib/kanta/backend/adapter.ex
@@ -0,0 +1,60 @@
+defmodule Kanta.Backend.Adapter do
+ @moduledoc """
+ Defines the behavior for Kanta adapters used in translation lookups.
+
+ Adapters implementing this behavior are responsible for handling
+ translation lookups for both singular and plural forms.
+ """
+
+ @doc """
+ Looks up a singular translation in the specified locale and domain.
+
+ ## Parameters
+
+ - `locale` - The locale code (e.g., "en", "fr")
+ - `domain` - The translation domain name
+ - `msgctxt` - Optional message context, or nil if no context
+ - `msgid` - The message identifier to translate
+ - `bindings` - Map or keyword list of bindings for interpolation
+
+ ## Returns
+
+ - `{:ok, translated_string}` - When the translation is found
+ - `{:error, :not_found}` - When the translation is not found
+ """
+ @callback lgettext(
+ locale :: String.t(),
+ domain :: String.t(),
+ msgctxt :: String.t() | nil,
+ msgid :: String.t(),
+ bindings :: Keyword.t() | map()
+ ) :: {:ok, String.t()} | {:error, :not_found}
+
+ @doc """
+ Looks up a plural translation in the specified locale and domain.
+
+ ## Parameters
+
+ - `locale` - The locale code (e.g., "en", "fr")
+ - `domain` - The translation domain name
+ - `msgctxt` - Optional message context, or nil if no context
+ - `msgid` - The singular message identifier
+ - `msgid_plural` - The plural message identifier
+ - `n` - The count to determine which plural form to use
+ - `bindings` - Map or keyword list of bindings for interpolation
+
+ ## Returns
+
+ - `{:ok, translated_string}` - When the translation is found
+ - `{:error, :not_found}` - When the translation is not found
+ """
+ @callback lngettext(
+ locale :: String.t(),
+ domain :: String.t(),
+ msgctxt :: String.t() | nil,
+ msgid :: String.t(),
+ msgid_plural :: String.t(),
+ n :: non_neg_integer(),
+ bindings :: Keyword.t() | map()
+ ) :: {:ok, String.t()} | {:error, :not_found}
+end
diff --git a/lib/kanta/backend/adapter/cached_db.ex b/lib/kanta/backend/adapter/cached_db.ex
new file mode 100644
index 0000000..b2d9ff8
--- /dev/null
+++ b/lib/kanta/backend/adapter/cached_db.ex
@@ -0,0 +1,145 @@
+defmodule Kanta.Backend.Adapter.CachedDB do
+ @moduledoc """
+ Kanta adapter used in *gettext functions from Kanta.Gettext.Macros.
+
+ Handles translation lookups in cache and DB for both singular and plural forms.
+ """
+
+ require Logger
+ @behaviour Kanta.Backend.Adapter
+
+ alias Kanta.Translations.{
+ Context,
+ Domain,
+ Locale,
+ Message,
+ PluralTranslation,
+ SingularTranslation
+ }
+
+ alias Kanta.Translations
+
+ @doc """
+ Translates a message with the given locale, domain, context, and message ID.
+
+ ## Parameters
+ * `locale` - ISO-639 code for the locale
+ * `domain` - Name of the translation domain
+ * `msgctxt` - Optional context for the message
+ * `msgid` - Message ID to translate
+ * `bindings` - Map or keyword list of variables to interpolate
+
+ ## Returns
+ * `{:ok, translation}` - When translation is found
+ * `{:error, :not_found}` - When translation is not found
+ """
+ @impl true
+ def lgettext(locale, domain, msgctxt, msgid, bindings) do
+ with {:ok, %Locale{id: locale_id}} <-
+ Translations.get_locale(filter: [iso639_code: locale]),
+ {:ok, %Domain{id: domain_id}} <-
+ Translations.get_domain(filter: [name: domain]),
+ {:ok, context_id} <- maybe_get_context_id(msgctxt),
+ {:ok, %Message{id: message_id}} <-
+ Translations.get_message(
+ filter: [
+ msgid: msgid,
+ context_id: context_id,
+ domain_id: domain_id,
+ application_source_id: nil
+ ]
+ ),
+ {:ok, %SingularTranslation{translated_text: text}} when not is_nil(text) <-
+ Translations.get_singular_translation(
+ filter: [
+ locale_id: locale_id,
+ message_id: message_id
+ ]
+ ),
+ {:ok, interpolated} <- apply_bindings(text, bindings) do
+ {:ok, interpolated}
+ else
+ _ -> {:error, :not_found}
+ end
+ end
+
+ @doc """
+ Translates a plural message with the given locale, domain, context, message IDs, count and bindings.
+
+ ## Parameters
+ * `locale` - ISO-639 code for the locale
+ * `domain` - Name of the translation domain
+ * `msgctxt` - Optional context for the message
+ * `msgid` - Singular message ID
+ * `msgid_plural` - Plural message ID
+ * `n` - Count to determine which plural form to use
+ * `bindings` - Map or keyword list of variables to interpolate
+
+ ## Returns
+ * `{:ok, translation}` - When translation is found
+ * `{:error, :not_found}` - When translation is not found
+ """
+ @impl true
+ def lngettext(locale, domain, msgctxt, _msgid, msgid_plural, n, bindings) do
+ with {:ok, %Locale{id: locale_id, plurals_header: plurals_header}} <-
+ Translations.get_locale(filter: [iso639_code: locale]),
+ {:ok, %Domain{id: domain_id}} <-
+ Translations.get_domain(filter: [name: domain]),
+ {:ok, context_id} <- maybe_get_context_id(msgctxt),
+ {:ok, %Message{id: message_id}} <-
+ Translations.get_message(
+ filter: [
+ msgid: msgid_plural,
+ context_id: context_id,
+ domain_id: domain_id,
+ application_source_id: nil
+ ]
+ ),
+ {:ok, plurals_options} <- Expo.PluralForms.parse(plurals_header),
+ nplural_index <- Expo.PluralForms.index(plurals_options, n),
+ {:ok, %PluralTranslation{translated_text: text}} <-
+ Translations.get_plural_translation(
+ filter: [
+ locale_id: locale_id,
+ message_id: message_id,
+ nplural_index: nplural_index
+ ]
+ ),
+ {:ok, interpolated} <- apply_bindings(text, Map.put(bindings, :count, n)) do
+ {:ok, interpolated}
+ else
+ _ -> {:error, :not_found}
+ end
+ end
+
+ defp maybe_get_context_id(nil), do: {:ok, nil}
+
+ defp maybe_get_context_id(msgctxt) do
+ case Translations.get_context(filter: [name: msgctxt]) do
+ {:ok, %Context{} = context} -> {:ok, context.id}
+ _ -> {:ok, nil}
+ end
+ end
+
+ @spec apply_bindings(String.t(), Keyword.t() | map()) ::
+ {:ok, String.t()} | {:error, :not_found}
+ defp apply_bindings(text, bindings) when is_list(bindings) do
+ apply_bindings(text, Map.new(bindings))
+ end
+
+ defp apply_bindings(text, bindings) do
+ case Gettext.Interpolation.Default.runtime_interpolate(text, bindings) do
+ {:ok, interpolated} ->
+ {:ok, interpolated}
+
+ {:missing_bindings, partially_interpolated_message, missing_bindings} ->
+ Logger.warning("[Kanta]: Missing bindings for translation", %{
+ text: text,
+ partially_interpolated_message: partially_interpolated_message,
+ missing_bindings: missing_bindings
+ })
+
+ {:error, :not_found}
+ end
+ end
+end
diff --git a/lib/kanta/backend/fallback_backend.ex b/lib/kanta/backend/fallback_backend.ex
new file mode 100644
index 0000000..2d688b6
--- /dev/null
+++ b/lib/kanta/backend/fallback_backend.ex
@@ -0,0 +1,41 @@
+defmodule Kanta.Backend.GettextFallback do
+ @moduledoc """
+ Provides a fallback mechanism to Gettext's PO files translation system.
+
+ This module creates a nested Gettext backend that is used when database
+ translations are not found. It allows Kanta to gracefully fall back to
+ standard PO file translations when a specific translation is not available
+ in the database.
+ """
+
+ defmacro __using__(opts) do
+ quote bind_quoted: [opts: opts] do
+ defmodule GettextFallbackBackend do
+ @moduledoc false
+
+ require Logger
+
+ @flag_file Path.join([
+ Mix.Project.build_path(),
+ "kanta_recompile",
+ ".fallback_recompiled"
+ ])
+
+ # When `mix gettext extract` create empty stub so that the Kanta.Backend can compile.
+ if Gettext.Extractor.extracting?() do
+ def lgettext(_locale, _domain, _msgctxt, _msgid, _bindings), do: nil
+ def lngettext(_locale, _domain, _msgctxt, _msgid, _msgid_plural, _n, _bindings), do: nil
+
+ Kanta.Utils.GettextRecompiler.setup_recompile_flag(@flag_file)
+ else
+ # ...otherwise generate the Gettext.Backend interface
+ use Gettext.Backend, opts
+ end
+
+ def __mix_recompile__?() do
+ Kanta.Utils.GettextRecompiler.needs_recompile?(@flag_file)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/kanta/gettext/repo.ex b/lib/kanta/gettext/repo.ex
deleted file mode 100644
index 9aad9f6..0000000
--- a/lib/kanta/gettext/repo.ex
+++ /dev/null
@@ -1,156 +0,0 @@
-defmodule Kanta.Gettext.Repo do
- alias Kanta.Utils.Compilation
-
- alias Kanta.Translations.{
- Context,
- Domain,
- Locale,
- Message,
- PluralTranslation,
- SingularTranslation
- }
-
- alias Kanta.Translations
-
- def init(_) do
- __MODULE__
- end
-
- def get_translation(locale, domain, msgctxt, msgid, opts) do
- if Compilation.compiling?() do
- msgid
- else
- do_get_translation(locale, domain, msgctxt, msgid, opts)
- end
- end
-
- defp do_get_translation(locale, domain, msgctxt, msgid, opts) do
- default_locale = Application.get_env(:kanta, :default_locale) || "en"
-
- with {:ok, %Locale{id: locale_id}} <-
- Translations.get_locale(filter: [iso639_code: locale]),
- {:ok, %Domain{id: domain_id}} <-
- Translations.get_domain(filter: [name: domain]),
- {:ok, context_id} <- maybe_get_context_id(msgctxt),
- {:ok, %Message{id: message_id}} <-
- Translations.get_message(
- filter: [
- msgid: msgid,
- context_id: context_id,
- domain_id: domain_id,
- application_source_id: nil
- ]
- ),
- {:ok, %SingularTranslation{translated_text: text}} <-
- Translations.get_singular_translation(
- filter: [
- locale_id: locale_id,
- message_id: message_id
- ]
- ) do
- if is_nil(text) do
- if locale != default_locale do
- do_get_translation(default_locale, domain, msgctxt, msgid, opts)
- else
- :not_found
- end
- else
- {:ok, text}
- end
- else
- _ ->
- :not_found
- end
- end
-
- def get_plural_translation(
- locale,
- domain,
- msgctxt,
- msgid,
- msgid_plural,
- plural_form,
- opts
- ) do
- if Compilation.compiling?() do
- if plural_form == 1, do: msgid, else: msgid_plural
- else
- do_get_plural_translation(
- locale,
- domain,
- msgctxt,
- msgid,
- msgid_plural,
- plural_form,
- opts
- )
- end
- end
-
- defp do_get_plural_translation(
- locale,
- domain,
- msgctxt,
- msgid,
- msgid_plural,
- plural_form,
- opts
- ) do
- default_locale = Application.get_env(:kanta, :default_locale) || "en"
-
- with {:ok, %Locale{id: locale_id, plurals_header: plurals_header}} <-
- Translations.get_locale(filter: [iso639_code: locale]),
- {:ok, %Domain{id: domain_id}} <-
- Translations.get_domain(filter: [name: domain]),
- {:ok, context_id} <- maybe_get_context_id(msgctxt),
- {:ok, %Message{id: message_id}} <-
- Translations.get_message(
- filter: [
- msgid: msgid_plural,
- context_id: context_id,
- domain_id: domain_id,
- application_source_id: nil
- ]
- ),
- {:ok, plurals_options} <- Expo.PluralForms.parse(plurals_header),
- nplural_index <- Expo.PluralForms.index(plurals_options, plural_form),
- {:ok, %PluralTranslation{translated_text: text}} <-
- Translations.get_plural_translation(
- filter: [
- locale_id: locale_id,
- message_id: message_id,
- nplural_index: nplural_index
- ]
- ) do
- if is_nil(text) do
- if locale != default_locale do
- do_get_plural_translation(
- default_locale,
- domain,
- msgctxt,
- msgid,
- msgid_plural,
- plural_form,
- opts
- )
- else
- :not_found
- end
- else
- {:ok, text}
- end
- else
- _ ->
- :not_found
- end
- end
-
- defp maybe_get_context_id(nil), do: {:ok, nil}
-
- defp maybe_get_context_id(msgctxt) do
- case Translations.get_context(filter: [name: msgctxt]) do
- {:ok, %Context{} = context} -> {:ok, context.id}
- _ -> {:ok, nil}
- end
- end
-end
diff --git a/lib/kanta/translations.ex b/lib/kanta/translations.ex
index fdbe8a5..7cbcf58 100644
--- a/lib/kanta/translations.ex
+++ b/lib/kanta/translations.ex
@@ -46,6 +46,7 @@ defmodule Kanta.Translations do
defdelegate list_locales(params \\ []), to: Locales
defdelegate get_locale(params \\ []), to: Locales
defdelegate update_locale(locale, attrs, opts \\ []), to: Locales
+ defdelegate create_locale(attrs, opts \\ []), to: Locales
# TRANSLATIONS
defdelegate list_plural_translations(params \\ []), to: PluralTranslations
diff --git a/lib/kanta/translations/locale/locales.ex b/lib/kanta/translations/locale/locales.ex
index 2776659..30571ef 100644
--- a/lib/kanta/translations/locale/locales.ex
+++ b/lib/kanta/translations/locale/locales.ex
@@ -16,6 +16,10 @@ defmodule Kanta.Translations.Locales do
GetLocale.find(params)
end
+ def create_locale(attrs, opts \\ []) do
+ %Locale{} |> Locale.changeset(attrs) |> Repo.get_repo().insert(opts)
+ end
+
def update_locale(locale, attrs \\ %{}, opts \\ []) do
Locale.changeset(locale, attrs)
|> Repo.get_repo().update(opts)
diff --git a/lib/kanta/utils/compilation.ex b/lib/kanta/utils/compilation.ex
deleted file mode 100644
index f16aee3..0000000
--- a/lib/kanta/utils/compilation.ex
+++ /dev/null
@@ -1,14 +0,0 @@
-defmodule Kanta.Utils.Compilation do
- @moduledoc false
-
- @doc """
- Returns `true` if it is run during compilation.
-
- This function is used to handle translation messages during compilation time in macros and module attributes.
- """
- @spec compiling?() :: boolean()
- def compiling? do
- Code.ensure_loaded?(Code) &&
- Code.can_await_module_compilation?()
- end
-end
diff --git a/lib/kanta/utils/gettext_recompiler.ex b/lib/kanta/utils/gettext_recompiler.ex
new file mode 100644
index 0000000..e4f8fce
--- /dev/null
+++ b/lib/kanta/utils/gettext_recompiler.ex
@@ -0,0 +1,26 @@
+defmodule Kanta.Utils.GettextRecompiler do
+ @moduledoc """
+ Handles recompilation detection for Gettext backends during extraction.
+
+ This module manages flag files that track when Gettext extraction occurs,
+ allowing the system to trigger recompilation when needed.
+ """
+
+ require Logger
+
+ def setup_recompile_flag(flag_file) do
+ if Gettext.Extractor.extracting?() do
+ File.mkdir_p!(Path.dirname(flag_file))
+ File.touch!(flag_file)
+ end
+ end
+
+ def needs_recompile?(flag_file) do
+ if !Gettext.Extractor.extracting?() && File.exists?(flag_file) do
+ File.rm(flag_file)
+ true
+ else
+ false
+ end
+ end
+end
diff --git a/lib/kanta/utils/module_folder.ex b/lib/kanta/utils/module_folder.ex
new file mode 100644
index 0000000..def7847
--- /dev/null
+++ b/lib/kanta/utils/module_folder.ex
@@ -0,0 +1,48 @@
+defmodule Kanta.Utils.ModuleFolder do
+ @moduledoc """
+ Utilities for converting module names to filesystem-safe folder names.
+ """
+
+ @doc """
+ Converts a module name to a safe folder name.
+
+ ## Options
+ - `:lowercase` - Set to `true` to convert to lowercase (default: `false`)
+ - `:replace_with` - Character to replace invalid chars with (default: `"_"`)
+
+ ## Examples
+ iex> ModuleFolder.safe_folder_name(MyApp.UserSchema)
+ "my_app_user_schema"
+
+ iex> ModuleFolder.safe_folder_name(MyApp.UserSchema, lowercase: false)
+ "MyApp_UserSchema"
+
+ iex> ModuleFolder.safe_folder_name("Elixir.MyApp.Module", replace_with: "-")
+ "MyApp-Module"
+ """
+ def safe_folder_name(module) when is_atom(module) or is_binary(module) do
+ replacement = "_"
+
+ module
+ |> module_to_string()
+ |> remove_elixir_prefix()
+ |> replace_invalid_chars(replacement)
+ |> String.downcase()
+ end
+
+ defp module_to_string(module) when is_atom(module), do: Atom.to_string(module)
+ defp module_to_string(module) when is_binary(module), do: module
+
+ defp remove_elixir_prefix("Elixir." <> rest), do: rest
+ defp remove_elixir_prefix(other), do: other
+
+ defp replace_invalid_chars(name, replacement) do
+ # Replace characters that are invalid in folder names
+ name
+ |> String.replace(~r/[^\w\-\.]/, replacement)
+ # Collapse multiple replacements
+ |> String.replace(~r/#{replacement}+/, replacement)
+ # Comply with Windows naming rules
+ |> String.trim_trailing(".")
+ end
+end
diff --git a/mix.exs b/mix.exs
index 198d13b..142fc7e 100644
--- a/mix.exs
+++ b/mix.exs
@@ -8,6 +8,7 @@ defmodule Kanta.MixProject do
package: package(),
version: "0.4.2",
elixir: "~> 1.14",
+ elixirc_paths: elixirc_paths(Mix.env()),
elixirc_options: [
warnings_as_errors: true
],
@@ -31,6 +32,9 @@ defmodule Kanta.MixProject do
]
end
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_env), do: ["lib"]
+
# Run "mix help deps" to learn about dependencies.
defp deps do
[
@@ -55,9 +59,10 @@ defmodule Kanta.MixProject do
{:esbuild, "~> 0.7", only: :dev},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:mix_audit, "~> 2.0", only: [:dev, :test], runtime: false},
- {:gettext, github: "ravensiris/gettext", branch: "runtime-gettext", only: [:dev, :test]},
+ {:gettext, "~> 0.26"},
{:ex_doc, "~> 0.27", only: :dev, runtime: false},
- {:dialyxir, "~> 1.4", only: :dev, runtime: false}
+ {:dialyxir, "~> 1.4", only: :dev, runtime: false},
+ {:postgrex, "~> 0.16", only: :test}
]
end
diff --git a/mix.lock b/mix.lock
index 9de8cf6..6eea590 100644
--- a/mix.lock
+++ b/mix.lock
@@ -11,9 +11,9 @@
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
"ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"},
- "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"},
+ "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"},
"file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
- "gettext": {:git, "https://github.com/ravensiris/gettext.git", "030ad843e38eaa935062997c2313709e04ecfafc", [branch: "runtime-gettext"]},
+ "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"},
@@ -32,6 +32,7 @@
"phoenix_view": {:hex, :phoenix_view, "2.0.3", "4d32c4817fce933693741deeb99ef1392619f942633dde834a5163124813aad3", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "cd34049af41be2c627df99cd4eaa71fc52a328c0c3d8e7d4aa28f880c30e7f64"},
"plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"},
"plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"},
+ "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"},
"scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"},
"scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},
"shards": {:hex, :shards, "1.1.0", "ed3032e63ae99f0eaa6d012b8b9f9cead48b9a810b3f91aeac266cfc4118eff6", [:make, :rebar3], [], "hexpm", "1d188e565a54a458a7a601c2fd1e74f5cfeba755c5a534239266d28b7ff124c7"},
diff --git a/priv/repo/migrations/20250327123641_update_kanta_migrations.exs b/priv/repo/migrations/20250327123641_update_kanta_migrations.exs
new file mode 100644
index 0000000..4020ef9
--- /dev/null
+++ b/priv/repo/migrations/20250327123641_update_kanta_migrations.exs
@@ -0,0 +1,11 @@
+defmodule Kanta.Test.Repo.Migrations.UpdateKantaMigrations do
+ use Ecto.Migration
+
+ def up do
+ Kanta.Migration.up(version: 4)
+ end
+
+ def down do
+ Kanta.Migration.down(version: 4)
+ end
+end
diff --git a/test/fixtures/multi_messages/es/LC_MESSAGES/default.po b/test/fixtures/multi_messages/es/LC_MESSAGES/default.po
new file mode 100644
index 0000000..7447937
--- /dev/null
+++ b/test/fixtures/multi_messages/es/LC_MESSAGES/default.po
@@ -0,0 +1,2 @@
+msgid "Hello world"
+msgstr "Hola mundo"
\ No newline at end of file
diff --git a/test/fixtures/multi_messages/it/LC_MESSAGES/default.po b/test/fixtures/multi_messages/it/LC_MESSAGES/default.po
new file mode 100644
index 0000000..23d292a
--- /dev/null
+++ b/test/fixtures/multi_messages/it/LC_MESSAGES/default.po
@@ -0,0 +1,6 @@
+msgid "Hello world"
+msgstr "Ciao mondo"
+
+msgctxt "test"
+msgid "Hello world"
+msgstr "Ciao mondo"
\ No newline at end of file
diff --git a/test/fixtures/multi_messages/it/LC_MESSAGES/errors.po b/test/fixtures/multi_messages/it/LC_MESSAGES/errors.po
new file mode 100644
index 0000000..8ab153e
--- /dev/null
+++ b/test/fixtures/multi_messages/it/LC_MESSAGES/errors.po
@@ -0,0 +1,2 @@
+msgid "Invalid email address"
+msgstr "Indirizzo email non valido"
\ No newline at end of file
diff --git a/test/fixtures/single_messages/it/LC_MESSAGES/default.po b/test/fixtures/single_messages/it/LC_MESSAGES/default.po
new file mode 100644
index 0000000..c7f09c3
--- /dev/null
+++ b/test/fixtures/single_messages/it/LC_MESSAGES/default.po
@@ -0,0 +1,49 @@
+msgid ""
+msgstr ""
+"Language: it\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+msgid "Hello world"
+msgstr "Ciao mondo"
+
+msgctxt "test"
+msgid "Hello world"
+msgstr "Ciao mondo"
+
+msgctxt "test"
+msgid "Hello %{name}"
+msgstr "Ciao %{name}"
+
+msgid "One new email"
+msgid_plural "%{count} new emails"
+msgstr[0] "Una nuova email"
+msgstr[1] "%{count} nuove email"
+
+msgctxt "test"
+msgid "One new email"
+msgid_plural "%{count} new emails"
+msgstr[0] "Una nuova test email"
+msgstr[1] "%{count} nuove test email"
+
+msgid "Concatenated" " and long "
+ "string"
+msgstr "Stringa" " lunga e "
+ "concatenata"
+
+msgid "A" " friend"
+msgid_plural "%{count}" " friends"
+msgstr[0] "Un" " amico"
+msgstr[1] "%{count}" " amici"
+
+msgid "Empty msgstr!"
+msgstr "" ""
+
+msgid "Not even one msgstr"
+msgid_plural "Not even %{count} msgstrs"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "One apple"
+msgid_plural "%{count} apples"
+msgstr[0] "Una mela"
+msgstr[1] "%{count} mele"
diff --git a/test/fixtures/single_messages/it/LC_MESSAGES/errors.po b/test/fixtures/single_messages/it/LC_MESSAGES/errors.po
new file mode 100644
index 0000000..008bdea
--- /dev/null
+++ b/test/fixtures/single_messages/it/LC_MESSAGES/errors.po
@@ -0,0 +1,7 @@
+msgid "Invalid email address"
+msgstr "Indirizzo email non valido"
+
+msgid "There was an error"
+msgid_plural "There were %{count} errors"
+msgstr[0] "C'è stato un errore"
+msgstr[1] "Ci sono stati %{count} errori"
diff --git a/test/fixtures/single_messages/it/LC_MESSAGES/interpolations.po b/test/fixtures/single_messages/it/LC_MESSAGES/interpolations.po
new file mode 100644
index 0000000..60df957
--- /dev/null
+++ b/test/fixtures/single_messages/it/LC_MESSAGES/interpolations.po
@@ -0,0 +1,21 @@
+msgid "Hello %{name}"
+msgstr "Ciao %{name}"
+
+msgid "My name is %{name} and I'm %{age}"
+msgstr "Mi chiamo %{name} e ho %{age} anni"
+
+msgid "You have one message, %{name}"
+msgid_plural "You have %{count} messages, %{name}"
+msgstr[0] "Hai un messaggio, %{name}"
+msgstr[1] "Hai %{count} messaggi, %{name}"
+
+msgid "Month"
+msgid_plural "%{count} months"
+msgstr[0] "Mese"
+msgstr[1] "%{count} mesi"
+
+msgctxt "test"
+msgid "You have one message, %{name}"
+msgid_plural "You have %{count} messages, %{name}"
+msgstr[0] "Hai un messaggio, %{name}"
+msgstr[1] "Hai %{count} messaggi, %{name}"
diff --git a/test/fixtures/single_messages/ja/LC_MESSAGES/errors.po b/test/fixtures/single_messages/ja/LC_MESSAGES/errors.po
new file mode 100644
index 0000000..fb16dff
--- /dev/null
+++ b/test/fixtures/single_messages/ja/LC_MESSAGES/errors.po
@@ -0,0 +1,6 @@
+msgid "Invalid email address"
+msgstr "無効なメールアドレス"
+
+msgid "There was an error"
+msgid_plural "There were %{count} errors"
+msgstr[0] "%{count} エラーがありました"
diff --git a/test/kanta/backend/adapter/cached_db_test.exs b/test/kanta/backend/adapter/cached_db_test.exs
new file mode 100644
index 0000000..244412e
--- /dev/null
+++ b/test/kanta/backend/adapter/cached_db_test.exs
@@ -0,0 +1,235 @@
+defmodule Kanta.Backend.Adapter.CachedDBTest do
+ # Changing back to async: false for now
+ use Kanta.Test.DataCase, async: false
+
+ alias Kanta.Translations
+ alias Kanta.Backend.Adapter.CachedDB
+
+ setup do
+ # Clear the cache before each test
+ Kanta.Cache.delete_all()
+
+ # Create test data in database
+ {:ok, locale} =
+ Translations.create_locale(%{
+ native_name: "Français",
+ name: "French",
+ iso639_code: "fr",
+ plurals_header: "nplurals=2; plural=(n > 1);"
+ })
+
+ {:ok, domain} = Translations.create_domain(%{name: "test_domain"})
+ {:ok, context} = Translations.create_context(%{name: "test_context"})
+
+ # Create a message for singular translation test
+ {:ok, singular_message} =
+ Translations.create_message(%{
+ message_type: :singular,
+ msgid: "Hello world",
+ context_id: context.id,
+ domain_id: domain.id
+ })
+
+ # Create a message for plural translation test
+ {:ok, plural_message} =
+ Translations.create_message(%{
+ message_type: :plural,
+ msgid: "%{count} items",
+ context_id: context.id,
+ domain_id: domain.id
+ })
+
+ # Create the actual translations
+ {:ok, _singular_translation} =
+ Translations.create_singular_translation(%{
+ locale_id: locale.id,
+ message_id: singular_message.id,
+ translated_text: "Bonjour le monde"
+ })
+
+ # Create plural translations for both forms
+ {:ok, _plural_translation_one} =
+ Translations.create_plural_translation(%{
+ locale_id: locale.id,
+ message_id: plural_message.id,
+ nplural_index: 0,
+ translated_text: "%{count} élément"
+ })
+
+ {:ok, _plural_translation_many} =
+ Translations.create_plural_translation(%{
+ locale_id: locale.id,
+ message_id: plural_message.id,
+ nplural_index: 1,
+ translated_text: "%{count} éléments"
+ })
+
+ {:ok, %{locale: locale, domain: domain, context: context}}
+ end
+
+ describe "lgettext/5" do
+ test "returns translation from database for existing message" do
+ result = CachedDB.lgettext("fr", "test_domain", "test_context", "Hello world", %{})
+ assert result == {:ok, "Bonjour le monde"}
+ end
+
+ test "returns error for non-existing translation" do
+ result = CachedDB.lgettext("fr", "test_domain", "test_context", "Non-existent message", %{})
+
+ assert result == {:error, :not_found}
+ end
+
+ test "returns error for non-existing locale" do
+ result = CachedDB.lgettext("de", "test_domain", "test_context", "Hello world", %{})
+ assert result == {:error, :not_found}
+ end
+
+ test "interpolates variables correctly", %{locale: locale, domain: domain} do
+ # Create message and translation for interpolation test
+ {:ok, message} =
+ Translations.create_message(%{
+ message_type: :singular,
+ msgid: "Hello %{name}",
+ context_id: nil,
+ domain_id: domain.id
+ })
+
+ {:ok, _translation} =
+ Translations.create_singular_translation(%{
+ locale_id: locale.id,
+ message_id: message.id,
+ translated_text: "Bonjour %{name}"
+ })
+
+ # Clear cache to ensure fresh state
+ Kanta.Cache.delete_all()
+
+ result = CachedDB.lgettext("fr", "test_domain", nil, "Hello %{name}", %{name: "Alice"})
+ assert result == {:ok, "Bonjour Alice"}
+ end
+ end
+
+ describe "lngettext/7" do
+ test "returns singular form for count = 1", %{
+ locale: locale,
+ domain: domain,
+ context: context
+ } do
+ # Create a specific message for singular test
+ {:ok, message} =
+ Translations.create_message(%{
+ message_type: :singular,
+ msgid: "One item",
+ context_id: context.id,
+ domain_id: domain.id
+ })
+
+ # Change the translation to expect interpolation
+ {:ok, _translation} =
+ Translations.create_singular_translation(%{
+ locale_id: locale.id,
+ message_id: message.id,
+ # Changed from "Un élément"
+ translated_text: "%{count} élément"
+ })
+
+ # Clear cache to ensure fresh state
+ Kanta.Cache.delete_all()
+
+ result =
+ CachedDB.lngettext(
+ "fr",
+ "test_domain",
+ "test_context",
+ "One item",
+ "%{count} items",
+ 1,
+ %{}
+ )
+
+ # Changed from "Un élément"
+ assert result == {:ok, "1 élément"}
+ end
+
+ test "returns plural form for count > 1" do
+ # Use the plural message set up in the main setup block
+ result =
+ CachedDB.lngettext(
+ "fr",
+ "test_domain",
+ "test_context",
+ "One item",
+ "%{count} items",
+ 5,
+ %{}
+ )
+
+ assert result == {:ok, "5 éléments"}
+ end
+
+ test "returns error for non-existing translation" do
+ result =
+ CachedDB.lngettext(
+ "fr",
+ "test_domain",
+ "test_context",
+ "One thing",
+ "%{count} things",
+ 5,
+ %{}
+ )
+
+ assert result == {:error, :not_found}
+ end
+
+ test "correctly adds count to bindings", %{locale: locale, domain: domain} do
+ # Create a specific message for this test
+ {:ok, message} =
+ Translations.create_message(%{
+ message_type: :plural,
+ msgid: "%{count} custom items with %{extra}",
+ context_id: nil,
+ domain_id: domain.id
+ })
+
+ {:ok, _translation_singular} =
+ Translations.create_plural_translation(%{
+ locale_id: locale.id,
+ message_id: message.id,
+ nplural_index: 0,
+ translated_text: "%{count} élément personnalisé avec %{extra}"
+ })
+
+ {:ok, _translation_plural} =
+ Translations.create_plural_translation(%{
+ locale_id: locale.id,
+ message_id: message.id,
+ nplural_index: 1,
+ translated_text: "%{count} éléments personnalisés avec %{extra}"
+ })
+
+ # Clear cache to ensure fresh state
+ Kanta.Cache.delete_all()
+
+ result =
+ CachedDB.lngettext(
+ "fr",
+ "test_domain",
+ nil,
+ "One custom item with %{extra}",
+ "%{count} custom items with %{extra}",
+ 3,
+ %{extra: "info"}
+ )
+
+ assert result == {:ok, "3 éléments personnalisés avec info"}
+ end
+ end
+
+ # Run after each test
+ setup_all do
+ on_exit(fn ->
+ Kanta.Cache.delete_all()
+ end)
+ end
+end
diff --git a/test/kanta/backend_test.exs b/test/kanta/backend_test.exs
new file mode 100644
index 0000000..d99bb95
--- /dev/null
+++ b/test/kanta/backend_test.exs
@@ -0,0 +1,261 @@
+defmodule Kanta.BackendTest do
+ require Logger
+ use Kanta.Test.DataCase, async: false
+
+ alias Kanta.Translations
+
+ defmodule Backend do
+ use Kanta.Backend, otp_app: :kanta, priv: "test/fixtures/single_messages"
+ end
+
+ alias Kanta.BackendTest.Backend
+
+ setup do
+ # Clear the cache before each test
+ Kanta.Cache.delete_all()
+
+ # Create test data in database
+ {:ok, locale} =
+ Translations.create_locale(%{
+ native_name: "Italiano",
+ name: "Italian",
+ iso639_code: "it",
+ plurals_header: "nplurals=2; plural=(n != 1);"
+ })
+
+ {:ok, domain} = Translations.create_domain(%{name: "default"})
+ {:ok, context} = Translations.create_context(%{name: "test"})
+
+ # Create messages and translations for database-backed tests
+
+ # 1. Message for DB test with no PO equivalent
+ {:ok, db_only_message} =
+ Translations.create_message(%{
+ message_type: :singular,
+ msgid: "DB only message",
+ context_id: context.id,
+ domain_id: domain.id
+ })
+
+ {:ok, _db_only_translation} =
+ Translations.create_singular_translation(%{
+ locale_id: locale.id,
+ message_id: db_only_message.id,
+ translated_text: "Messaggio solo nel DB"
+ })
+
+ # 2. Message that exists in both DB and PO to test priority
+ {:ok, override_message} =
+ Translations.create_message(%{
+ message_type: :singular,
+ msgid: "Hello world",
+ context_id: context.id,
+ domain_id: domain.id
+ })
+
+ {:ok, _override_translation} =
+ Translations.create_singular_translation(%{
+ locale_id: locale.id,
+ message_id: override_message.id,
+ translated_text: "DB: Ciao mondo"
+ })
+
+ # 3. Plural message in DB
+ {:ok, plural_message} =
+ Translations.create_message(%{
+ message_type: :plural,
+ msgid: "%{count} plural messages",
+ context_id: context.id,
+ domain_id: domain.id
+ })
+
+ # Create plural translations for both forms
+ {:ok, _plural_translation_one} =
+ Translations.create_plural_translation(%{
+ locale_id: locale.id,
+ message_id: plural_message.id,
+ nplural_index: 0,
+ translated_text: "DB: %{count} messaggio plurale"
+ })
+
+ {:ok, _plural_translation_many} =
+ Translations.create_plural_translation(%{
+ locale_id: locale.id,
+ message_id: plural_message.id,
+ nplural_index: 1,
+ translated_text: "DB: %{count} messaggi plurali"
+ })
+
+ # 4. Override plural message that exists in PO
+ {:ok, override_plural_message} =
+ Translations.create_message(%{
+ message_type: :plural,
+ msgid: "%{count} new emails",
+ context_id: nil,
+ domain_id: domain.id
+ })
+
+ {:ok, _override_plural_singular} =
+ Translations.create_plural_translation(%{
+ locale_id: locale.id,
+ message_id: override_plural_message.id,
+ nplural_index: 0,
+ translated_text: "DB: Una nuova email"
+ })
+
+ {:ok, _override_plural_plural} =
+ Translations.create_plural_translation(%{
+ locale_id: locale.id,
+ message_id: override_plural_message.id,
+ nplural_index: 1,
+ translated_text: "DB: %{count} nuove email"
+ })
+
+ :ok
+ end
+
+ describe "Database-backed translations" do
+ test "translates messages that only exist in DB" do
+ Gettext.put_locale("it")
+
+ assert Gettext.dpgettext(Backend, "default", "test", "DB only message", %{}) ==
+ "Messaggio solo nel DB"
+ end
+
+ test "DB translations take priority over PO file translations" do
+ # This should use the DB version which overrides the PO file version
+
+ Gettext.with_locale("it", fn ->
+ assert Gettext.dpgettext(Backend, "default", "test", "Hello world", %{}) ==
+ "DB: Ciao mondo"
+ end)
+ end
+
+ test "translates plural forms from DB" do
+ Gettext.put_locale("it")
+
+ assert Gettext.dpngettext(
+ Backend,
+ "default",
+ "test",
+ "DB plural message",
+ "%{count} plural messages",
+ 1,
+ %{}
+ ) ==
+ "DB: 1 messaggio plurale"
+
+ assert Gettext.dpngettext(
+ Backend,
+ "default",
+ "test",
+ "DB plural message",
+ "%{count} plural messages",
+ 5,
+ %{}
+ ) ==
+ "DB: 5 messaggi plurali"
+ end
+
+ test "DB plural translations override PO plural translations" do
+ # These should use the DB versions which override the PO file versions
+ Gettext.put_locale("it")
+
+ assert Gettext.dpngettext(
+ Backend,
+ "default",
+ nil,
+ "One new email",
+ "%{count} new emails",
+ 1,
+ %{}
+ ) ==
+ "DB: Una nuova email"
+
+ assert Gettext.dpngettext(
+ Backend,
+ "default",
+ nil,
+ "One new email",
+ "%{count} new emails",
+ 5,
+ %{}
+ ) ==
+ "DB: 5 nuove email"
+ end
+ end
+
+ describe "Fallback translations" do
+ test "fallback to Gettext for simple translation" do
+ Gettext.put_locale("it")
+ # Test for a message that only exists in PO file
+ assert Gettext.dpgettext(Backend, "default", "test", "Hello %{name}", %{name: "Kuba"}) ==
+ "Ciao Kuba"
+ end
+
+ test "fallback for Gettext for plurals" do
+ # Create a new message key not in the DB
+ Gettext.put_locale("it")
+
+ assert Gettext.dpngettext(Backend, "default", nil, "One apple", "%{count} apples", 1, %{}) ==
+ "Una mela"
+
+ assert Gettext.dpngettext(Backend, "default", nil, "One apple", "%{count} apples", 2, %{}) ==
+ "2 mele"
+ end
+ end
+
+ test "handles missing translations gracefully" do
+ Gettext.put_locale("it")
+ # Test what happens with a locale that doesn't exist anywhere
+ assert Gettext.gettext(Backend, "Non-existent message") == "Non-existent message"
+
+ # Test with a non-existent locale
+ assert Gettext.with_locale("xy", fn ->
+ Gettext.gettext(Backend, "Hello world")
+ end) == "Hello world"
+ end
+
+ test "interpolates variables in both DB and PO translations" do
+ # Add a new DB translation with variables
+ {:ok, locale} = Translations.get_locale(filter: [iso639_code: "it"])
+ {:ok, domain} = Translations.get_domain(filter: [name: "default"])
+
+ {:ok, message} =
+ Translations.create_message(%{
+ message_type: :singular,
+ msgid: "Welcome %{user} to %{app}",
+ context_id: nil,
+ domain_id: domain.id
+ })
+
+ {:ok, _translation} =
+ Translations.create_singular_translation(%{
+ locale_id: locale.id,
+ message_id: message.id,
+ translated_text: "Benvenuto %{user} a %{app}"
+ })
+
+ # Clear cache
+ Kanta.Cache.delete_all()
+
+ Gettext.put_locale("it")
+
+ assert Gettext.gettext(Backend, "Welcome %{user} to %{app}", %{
+ user: "Mario",
+ app: "Kanta"
+ }) ==
+ "Benvenuto Mario a Kanta"
+ end
+
+ test "works with dynamic module API too" do
+ # Test the functions available through the Gettext API
+
+ Gettext.with_locale("it", fn ->
+ assert Gettext.gettext(Backend, "Hello world") == "DB: Ciao mondo"
+
+ assert Gettext.ngettext(Backend, "One new email", "%{count} new emails", 3) ==
+ "DB: 3 nuove email"
+ end)
+ end
+end
diff --git a/test/support/backend.ex b/test/support/backend.ex
new file mode 100644
index 0000000..a15b294
--- /dev/null
+++ b/test/support/backend.ex
@@ -0,0 +1,25 @@
+defmodule Kanta.Test.Backend do
+ @moduledoc false
+
+ use Gettext.Backend,
+ otp_app: :test_application,
+ priv: "test/fixtures/single_messages"
+
+ def handle_missing_translation(locale, domain, msgctxt, msgid, bindings) do
+ send(self(), {locale, domain, msgctxt, msgid, bindings})
+ super(locale, domain, msgctxt, msgid, bindings)
+ end
+
+ def handle_missing_plural_translation(
+ locale,
+ domain,
+ msgctxt,
+ msgid,
+ msgid_plural,
+ n,
+ bindings
+ ) do
+ send(self(), {locale, domain, msgctxt, msgid, msgid_plural, n, bindings})
+ super(locale, domain, msgctxt, msgid, msgid_plural, n, bindings)
+ end
+end
diff --git a/test/support/data_case.ex b/test/support/data_case.ex
new file mode 100644
index 0000000..cae5519
--- /dev/null
+++ b/test/support/data_case.ex
@@ -0,0 +1,58 @@
+defmodule Kanta.Test.DataCase do
+ @moduledoc """
+ This module defines the setup for tests requiring
+ access to the application's data layer.
+
+ You may define functions here to be used as helpers in
+ your tests.
+
+ Finally, if the test case interacts with the database,
+ we enable the SQL sandbox, so changes done to the database
+ are reverted at the end of every test. If you are using
+ PostgreSQL, you can even run database tests asynchronously
+ by setting `use MyLang.DataCase, async: true`, although
+ this option is not recommended for other databases.
+ """
+
+ use ExUnit.CaseTemplate
+
+ using do
+ quote do
+ alias Kanta.Test.Repo
+
+ import Ecto
+ import Ecto.Changeset
+ import Ecto.Query
+ import Kanta.Test.DataCase
+ end
+ end
+
+ setup tags do
+ Kanta.Test.DataCase.setup_sandbox(tags)
+ :ok
+ end
+
+ @doc """
+ Sets up the sandbox based on the test tags.
+ """
+ def setup_sandbox(tags) do
+ pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Kanta.Test.Repo, shared: not tags[:async])
+ on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
+ end
+
+ @doc """
+ A helper that transforms changeset errors into a map of messages.
+
+ assert {:error, changeset} = Accounts.create_user(%{password: "short"})
+ assert "password is too short" in errors_on(changeset).password
+ assert %{password: ["password is too short"]} = errors_on(changeset)
+
+ """
+ def errors_on(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
+ Regex.replace(~r"%{(\w+)}", message, fn _, key ->
+ opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
+ end)
+ end)
+ end
+end
diff --git a/test/support/endpoint.ex b/test/support/endpoint.ex
new file mode 100644
index 0000000..ae0618e
--- /dev/null
+++ b/test/support/endpoint.ex
@@ -0,0 +1,5 @@
+defmodule Kanta.Test.Endpoint do
+ @moduledoc false
+
+ use Phoenix.Endpoint, otp_app: :kanta
+end
diff --git a/test/support/migration.ex b/test/support/migration.ex
new file mode 100644
index 0000000..101cea1
--- /dev/null
+++ b/test/support/migration.ex
@@ -0,0 +1,15 @@
+defmodule Kanta.Test.Migration do
+ @moduledoc false
+
+ use Ecto.Migration
+
+ @current_version Kanta.Migrations.Postgresql.current_version()
+
+ def up do
+ Kanta.Migration.up(version: @current_version)
+ end
+
+ def down do
+ Kanta.Migration.down(version: @current_version)
+ end
+end
diff --git a/test/support/repo.ex b/test/support/repo.ex
new file mode 100644
index 0000000..c4287b5
--- /dev/null
+++ b/test/support/repo.ex
@@ -0,0 +1,7 @@
+defmodule Kanta.Test.Repo do
+ @moduledoc false
+
+ use Ecto.Repo,
+ otp_app: :kanta,
+ adapter: Ecto.Adapters.Postgres
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
index 869559e..11fe712 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -1 +1,17 @@
+Application.ensure_all_started(:kanta)
+
+Kanta.Test.Repo.start_link()
+
+Kanta.start_link(
+ endpoint: Kanta.Test.Endpoint,
+ repo: Kanta.Test.Repo,
+ otp_name: :kanta,
+ plugins: []
+)
+
ExUnit.start()
+
+# clear translations cache
+Kanta.Cache.delete_all()
+
+Ecto.Adapters.SQL.Sandbox.mode(Kanta.Test.Repo, :manual)