diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..f3de6b81 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,81 @@ +# Contributing to Keila + +This document describes how to contribute code or translations to the Keila +project. + +## Development Setup + +Before you get started with developing Keila, you need to set up your development +environment. + +### Setup with VS Code and [DevContainer](https://code.visualstudio.com/docs/remote/containers) +* Clone the repository: + `git clone https://github.com/pentacent/keila.git` +* Install the [Remote Container](https://github.com/microsoft/vscode-dev-containers) + extension in Visual Code +* Click on the ![Screenshot of DevContainer icon](.github/assets/devcontainer-button.png) + icon in the bottom left corner and select `Reopen in Container` or look for + `Reopen in Container` in the command panel (`Ctrl+Shift+P`) +* Wait for the containers to build and install all the dependencies needed to + run Keila, including [PostgreSQL](https://www.postgresql.org/) + and [Elixir](https://elixir-lang.org/install.html) +* Open a terminal from VS Code and proceed with the instructions from the + [Run Keila](#run-keila) section + +### Setup with other editors +Start by cloning the repository: `git clone https://github.com/pentacent/keila.git` + +#### Install Dependencies +[Install Elixir](https://elixir-lang.org/install.html) on your machine and launch +an an instance of [PostgreSQL](https://www.postgresql.org/). + +#### Run Keila + +* Install dependencies with `mix deps.get` +* Install dependencies and set up the database with `mix setup` +* Start Keila server with `mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +## Contributing Code +Code contributions to Keila are welcome! + +If you don’t know where to start, take a look at these [Good First Issues](https://github.com/pentacent/keila/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). + +### Contribution Guidelines +Before you commit code to Keila, make sure to run `mix format` to ensure +consistent formatting. + + +## Translating Keila +Keila uses the [Gettext format](https://en.wikipedia.org/wiki/Gettext) for +translations of the interface. + +Translation files are located in `priv/gettext`. + +### Getting Started + +Before you create a new translation or modify an existing translation, you need +to extract and update all strings from the code: +`mix gettext.extract --merge` + +### Modify existing translations +Next you can use the editor of your choice to either edit the existing `.po` +files in `priv/gettext`. A convenient Open Source program for editing +translation files is [Poedit](https://poedit.net). + +### Creating a new translation +1) Use the `.pot` templates in `priv/gettext` to create translation files for your + language . New translations should be stored in + `priv/gettext/:language/LC_MESSAGES`. + +2) Find the line `config :keila, KeilaWeb.Gettext` in `config/config.exs` and + add the language code of the new language to the `:locales` list. + +3) Find the line `def available_locales() do` in `lib/keila_web/gettext.ex` and + add a tuple with the native language name and the language code to the list + returned by the function. + +## Finalizing your changes +Run `mix gettext.merge priv/gettext` to compile your changes. Make sure to +commit all changes to `.pot`, `.po` and the compiled `.mo`. \ No newline at end of file diff --git a/README.md b/README.md index 1984e15a..2502c9fe 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ to SMTP. ![Screenshot of the Keila campaign editor showing the WYSIWYG editor and the default template](.github/assets/keila-20211203.jpg) -## Giving Keila a Try +## Give Keila a Try! You can give a hosted version of Keila a try at [keila.io/try](https://www.keila.io/try). @@ -28,31 +28,11 @@ in this repo. Follow the [Installation Docs](https://keila.io/docs/installation) for more details. -## Developing Keila +## Contributing -If you want to give Keila a try, here’s how to get it running from this -repository: +You can contribute to the Keila project with translations or code! Learn more +about how to contribute code or translations to Keila here: [CONTRIBUTING.md](CONTRIBUTING.md) -* [Install Elixir](https://elixir-lang.org/install.html) and make sure you have [PostgreSQL](https://www.postgresql.org/) running on your machine -* Clone the repository: - `git clone https://github.com/pentacent/keila.git` -* Install dependencies with `mix deps.get` -* Install dependencies and set up the database with `mix setup` -* Start Keila server with `mix phx.server` - -Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. - -## (Optional) Developing Keila inside a [DevContainer](https://code.visualstudio.com/docs/remote/containers) - -Here’s how to get it running using DevContainer from Visual Code: - -* Install [Remote Container](https://github.com/microsoft/vscode-dev-containers) extension in Visual Code -* Click on the bottom left corner with the icon below and choose `Reopen in Container` - -![Screenshot of DevContainer icon](.github/assets/devcontainer-button.png) - -* Wait for the containers to build and install all the dependencies needed to run Keila, including [PostgreSQL](https://www.postgresql.org/) and [Elixir](https://elixir-lang.org/install.html) -* Open a terminal inside the container and proceed to run the commands from [Developing Keila](#developing-keila) section ## The Name Keila is the name of the elephant mascot of this project. diff --git a/config/config.exs b/config/config.exs index 39f3f485..e4f743ea 100644 --- a/config/config.exs +++ b/config/config.exs @@ -95,6 +95,11 @@ config :mime, :types, %{ "text/tab-separated-values" => ["tsv"] } +# Configure locales +config :keila, KeilaWeb.Gettext, + default_locale: "en", + locales: ["de", "en"] + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/lib/keila/auth/auth.ex b/lib/keila/auth/auth.ex index a9bc9d42..daff6196 100644 --- a/lib/keila/auth/auth.ex +++ b/lib/keila/auth/auth.ex @@ -428,7 +428,7 @@ defmodule Keila.Auth do {:ok, Token.t()} | {:ok, User.t()} | {:error, Changeset.t(User.t())} def update_user_email(id, params, url_fn \\ &default_url_function/1) do user = Repo.get(User, id) - changeset = User.update_changeset(user, params) + changeset = User.update_email_changeset(user, params) if changeset.valid? do email = Changeset.get_change(changeset, :email) @@ -460,7 +460,7 @@ defmodule Keila.Auth do token = %Token{} -> user = Repo.get(User, token.user_id) params = %{email: token.data["email"]} - Repo.update(User.update_changeset(user, params)) + Repo.update(User.update_email_changeset(user, params)) _ -> :error @@ -496,6 +496,15 @@ defmodule Keila.Auth do end end + @spec set_user_locale(User.id(), String.t()) :: + {:ok, User.t()} | {:error, Changeset.t(User.t())} + def set_user_locale(id, locale) do + id + |> get_user() + |> User.update_locale_changeset(%{locale: locale}) + |> Repo.update() + end + @doc """ Creates a token for given `scope` and `user_id`. diff --git a/lib/keila/auth/schemas/user.ex b/lib/keila/auth/schemas/user.ex index dc482fd8..25e17ee3 100644 --- a/lib/keila/auth/schemas/user.ex +++ b/lib/keila/auth/schemas/user.ex @@ -6,6 +6,8 @@ defmodule Keila.Auth.User do field(:password, :string, virtual: true) field(:password_hash, :string) + field(:locale, :string) + field(:activated_at, :utc_datetime) has_many(:user_groups, Keila.Auth.UserGroup) @@ -19,7 +21,7 @@ defmodule Keila.Auth.User do @spec creation_changeset(t() | Ecto.Changeset.data()) :: Ecto.Changeset.t(t) def creation_changeset(struct \\ %__MODULE__{}, params) do struct - |> cast(params, [:email, :password]) + |> cast(params, [:email, :password, :locale]) |> validate_email() |> validate_password() end @@ -27,14 +29,6 @@ defmodule Keila.Auth.User do @doc """ Changeset for User updates """ - @spec update_changeset(t() | Ecto.Changeset.data()) :: Ecto.Changeset.t(t) - def update_changeset(struct \\ %__MODULE__{}, params) do - struct - |> cast(params, [:email, :password]) - |> validate_email() - |> maybe_validate_password() - end - @spec update_email_changeset(t() | Ecto.Changeset.data()) :: Ecto.Changeset.t(t) def update_email_changeset(struct \\ %__MODULE__{}, params) do struct @@ -42,6 +36,12 @@ defmodule Keila.Auth.User do |> validate_email() end + @spec update_locale_changeset(t() | Ecto.Changeset.data()) :: Ecto.Changeset.t(t) + def update_locale_changeset(struct \\ %__MODULE__{}, params) do + struct + |> cast(params, [:locale]) + end + @spec update_password_changeset(t() | Ecto.Changeset.data()) :: Ecto.Changeset.t(t) def update_password_changeset(struct \\ %__MODULE__{}, params) do struct @@ -57,14 +57,6 @@ defmodule Keila.Auth.User do |> validate_length(:email, max: 255) |> unique_constraint(:email) end - - defp maybe_validate_password(changeset) do - case get_change(changeset, :password) do - nil -> changeset - _pw -> validate_password(changeset) - end - end - defp validate_password(changeset) do changeset |> validate_required([:password]) diff --git a/lib/keila_web/controllers/account_controller.ex b/lib/keila_web/controllers/account_controller.ex index 3b8305f0..85f7d761 100644 --- a/lib/keila_web/controllers/account_controller.ex +++ b/lib/keila_web/controllers/account_controller.ex @@ -10,8 +10,8 @@ defmodule KeilaWeb.AccountController do end @spec post_edit(Plug.Conn.t(), map()) :: Plug.Conn.t() - def post_edit(conn, params) do - params = Map.get(params, "user", %{}) + def post_edit(conn, %{"user" => %{"password" => password}}) do + params = %{password: password} case Auth.update_user_password(conn.assigns.current_user.id, params) do {:ok, user} -> @@ -24,6 +24,17 @@ defmodule KeilaWeb.AccountController do end end + def post_edit(conn, %{"user" => %{"locale" => locale}}) do + case Auth.set_user_locale(conn.assigns.current_user.id, locale) do + {:ok, _user} -> + conn + |> redirect(to: Routes.account_path(conn, :edit)) + + {:error, changeset} -> + render_edit(conn, changeset) + end + end + defp render_edit(conn, changeset) do account = Accounts.get_user_account(conn.assigns.current_user.id) credits = if account, do: Accounts.get_credits(account.id) diff --git a/lib/keila_web/gettext.ex b/lib/keila_web/gettext.ex index 2039711d..583daf73 100644 --- a/lib/keila_web/gettext.ex +++ b/lib/keila_web/gettext.ex @@ -34,4 +34,11 @@ defmodule KeilaWeb.Gettext do |> then(fn html -> {:safe, html} end) end end + + def available_locales() do + [ + {"English", "en"}, + {"Deutsch", "de"} + ] + end end diff --git a/lib/keila_web/helpers/auth_session/put_locale_plug.ex b/lib/keila_web/helpers/auth_session/put_locale_plug.ex new file mode 100644 index 00000000..99f9b404 --- /dev/null +++ b/lib/keila_web/helpers/auth_session/put_locale_plug.ex @@ -0,0 +1,68 @@ +defmodule KeilaWeb.PutLocalePlug do + @moduledoc """ + Plug for setting the Gettext locale. + + When the `:current_user` assign is available and has a locale set, the locale + is taken from the user’s settings. Otherwise the locales from the + `accept-language` header are used. + """ + + alias Keila.Auth.User + + @spec init(list()) :: list() + def init(_), do: [] + + @spec call(Plug.Conn.t(), list()) :: Plug.Conn.t() + def call(conn, _opts) do + case conn.assigns[:current_user] do + %User{locale: locale} when is_binary(locale) -> put_locale(locale) + _other -> put_locale_from_headers(conn) + end + + conn + end + + defp put_locale(locale) do + locales = Application.get_env(:keila, KeilaWeb.Gettext) |> Keyword.fetch!(:locales) + + if locale in locales do + Gettext.put_locale(locale) + :ok + else + :error + end + end + + defp put_locale_from_headers(conn) do + accepted_locales = get_accepted_locales(conn) + + # We’re not actually trying to find this value, but this is the easiest + # way to run the `put_locale` side effect until we’ve found a locale that + # is supported + Enum.find(accepted_locales, fn locale -> + put_locale(locale) == :ok + end) + end + + defp get_accepted_locales(conn) do + conn + |> Plug.Conn.get_req_header("accept-language") + |> Enum.join(",") + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.map(fn language -> + case String.split(language, ";q=") do + [language] -> + {language, 1.0} + + [language, quality] -> + case Float.parse(quality) do + {quality, _} -> {language, quality} + :error -> {language, 1.0} + end + end + end) + |> Enum.sort_by(&elem(&1, 1), :desc) + |> Enum.map(&elem(&1, 0)) + end +end diff --git a/lib/keila_web/router.ex b/lib/keila_web/router.ex index c3834014..8600069d 100644 --- a/lib/keila_web/router.ex +++ b/lib/keila_web/router.ex @@ -10,6 +10,7 @@ defmodule KeilaWeb.Router do plug :put_secure_browser_headers plug KeilaWeb.Meta.Plug plug KeilaWeb.AuthSession.Plug + plug KeilaWeb.PutLocalePlug end # Non-authenticated Routes diff --git a/lib/keila_web/templates/account/edit.html.heex b/lib/keila_web/templates/account/edit.html.heex index fa0bafc7..a91a81a3 100644 --- a/lib/keila_web/templates/account/edit.html.heex +++ b/lib/keila_web/templates/account/edit.html.heex @@ -167,6 +167,31 @@ + <.form + let={f} + for={@changeset} + action={ Routes.account_path(@conn, :post_edit)} + class="rounded shadow p-8 mt-8 max-w-5xl mx-auto flex flex-col gap-4 bg-gray-900 text-gray-50" + @change="setUnsavedReminder(true)" + > +

+ <%= gettext("Change Language") %> +

+

+ <%= gettext("Here you can change the language for the Keila interface.") %> +

+
+ <%= label(f, :locale, gettext("Language")) %> + <%= with_validation(f, :locale) do %> + <%= select(f, :locale, KeilaWeb.Gettext.available_locales(), [class: "text-black"]) %> + <% end %> +
+
+ +
+ diff --git a/lib/keila_web/templates/campaign/_settings_dialog.html.heex b/lib/keila_web/templates/campaign/_settings_dialog.html.heex index adee1377..7b9e26ab 100644 --- a/lib/keila_web/templates/campaign/_settings_dialog.html.heex +++ b/lib/keila_web/templates/campaign/_settings_dialog.html.heex @@ -67,12 +67,12 @@ <%= inputs_for(f, :settings, fn fs -> %>
<%= checkbox(fs, :enable_wysiwyg) %> - <%= label(fs, :enable_wysiwyg, "Enable rich text editor") %> + <%= label(fs, :enable_wysiwyg, gettext("Enable rich text editor")) %>
<% end) %>
- <%= label(f, :template_id, "Template") %> + <%= label(f, :template_id, gettext("Template")) %> <%= with_validation(f, :template_id) do %> <%= select(f, :template_id, [{gettext("Default"), nil} | Enum.map(@templates, &{&1.name, &1.id})], class: "text-black") %> @@ -81,7 +81,7 @@
- <%= label(f, :segment_id, "Segment") %> + <%= label(f, :segment_id, gettext("Segment")) %> <%= with_validation(f, :segment_id) do %> <%= select(f, :segment_id, [{gettext("All Contacts"), nil}] ++ Enum.map(@segments, &{&1.name, &1.id}), class: "text-black") %> @@ -89,7 +89,7 @@
- <%= label(f, :segment_id, "Campaign data") %> + <%= label(f, :segment_id, gettext("Campaign data")) %> <%= gettext("You can add any JSON object as custom data to your campaign.") %> <%= with_validation(f, :data) do %> <%= case input_value(f, :data) do %> diff --git a/lib/keila_web/templates/campaign/_wysiwyg_editor.html.heex b/lib/keila_web/templates/campaign/_wysiwyg_editor.html.heex index bc57e13a..39d7d2b7 100644 --- a/lib/keila_web/templates/campaign/_wysiwyg_editor.html.heex +++ b/lib/keila_web/templates/campaign/_wysiwyg_editor.html.heex @@ -16,7 +16,7 @@ i