Packages

phoenix_kit

1.7.88
1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit utils multilang.ex
Raw

lib/phoenix_kit/utils/multilang.ex

defmodule PhoenixKit.Utils.Multilang do
@moduledoc """
Multi-language data transformation helpers for entity data JSONB.
Multi-language support is driven by the Languages module globally.
When the Languages module is enabled and has more than one language,
all entities automatically support multilang data. There is no
per-entity toggle — languages are configured system-wide.
The `data` JSONB column stores a nested structure:
%{
"_primary_language" => "en-US",
"en-US" => %{"_title" => "Acme", "name" => "Acme", "tagline" => "Quality products"},
"es-ES" => %{"_title" => "Acme España", "name" => "Acme España"}
}
The primary language always has complete data. Secondary languages
store only overrides — fields that differ from primary. Display
merges primary values as defaults with language-specific overrides.
The `_title` key stores the record title alongside custom fields,
unifying title translation with the same override-only storage pattern.
The `title` DB column remains a denormalized copy for queries/sorting.
"""
alias PhoenixKit.Modules.Languages
@primary_language_key "_primary_language"
# ── Global language helpers ─────────────────────────────────────
@doc """
Checks if multilang is enabled globally.
Returns true when the Languages module is enabled and has more than one language.
"""
@spec enabled?() :: boolean()
def enabled? do
if languages_available?() do
length(enabled_language_codes()) > 1
else
false
end
end
@doc """
Gets the primary (default) language code.
Returns the Languages module default, falling back to "en-US".
"""
@spec primary_language() :: String.t()
def primary_language do
default_language_code()
end
@doc """
Gets the list of enabled language codes.
Returns at minimum the primary language.
"""
@spec enabled_languages() :: [String.t()]
def enabled_languages do
if languages_available?() do
codes = enabled_language_codes()
primary = primary_language()
if primary in codes, do: codes, else: [primary | codes]
else
[primary_language()]
end
end
# ── Data read helpers ─────────────────────────────────────────
@doc """
Extracts the data map for a specific language from a record's data.
For multilang data: returns merged data (primary as base + overrides).
For flat data: returns data as-is (backward compat).
"""
@spec get_language_data(map() | nil, String.t()) :: map()
def get_language_data(data, lang_code) do
if multilang_data?(data) do
primary = primary_language_from_data(data)
primary_data = Map.get(data, primary, %{})
if lang_code == primary do
primary_data
else
lang_data = Map.get(data, lang_code, %{})
Map.merge(primary_data, lang_data)
end
else
data || %{}
end
end
@doc """
Gets the primary language data from a record (for display in lists etc).
"""
@spec get_primary_data(map() | nil) :: map()
def get_primary_data(data) do
if multilang_data?(data) do
primary = primary_language_from_data(data)
Map.get(data, primary, %{})
else
data || %{}
end
end
@doc """
Gets raw (non-merged) language-specific data for a language.
Used by the form UI to detect which fields are overridden vs inherited.
"""
@spec get_raw_language_data(map() | nil, String.t()) :: map()
def get_raw_language_data(data, lang_code) do
if multilang_data?(data) do
Map.get(data, lang_code, %{})
else
data || %{}
end
end
@doc """
Checks if a data map uses the multilang structure.
Presence of `_primary_language` key indicates multilang.
"""
@spec multilang_data?(map() | nil) :: boolean()
def multilang_data?(nil), do: false
def multilang_data?(data) when is_map(data) do
Map.has_key?(data, @primary_language_key)
end
def multilang_data?(_), do: false
# ── Data write helpers ────────────────────────────────────────
@doc """
Merges language-specific form data into the full multilang JSONB.
For primary language: stores ALL fields.
For secondary language: stores only fields that differ from primary.
"""
@spec put_language_data(map() | nil, String.t(), map()) :: map()
def put_language_data(existing_data, lang_code, new_field_data) do
existing_data = existing_data || %{}
# Use embedded primary for existing multilang data, global for new/flat data
primary =
if multilang_data?(existing_data) do
primary_language_from_data(existing_data)
else
primary_language()
end
# Ensure multilang structure
base_data =
if multilang_data?(existing_data) do
existing_data
else
# Convert flat data to multilang (migration path)
%{@primary_language_key => primary, primary => existing_data}
end
if lang_code == primary do
# Primary language: store all fields
Map.put(base_data, lang_code, new_field_data)
else
# Secondary language: only store overrides
primary_data = Map.get(base_data, primary, %{})
overrides = compute_overrides(new_field_data, primary_data)
if map_size(overrides) == 0 do
Map.delete(base_data, lang_code)
else
Map.put(base_data, lang_code, overrides)
end
end
end
@doc """
Converts existing flat data to multilang structure.
"""
@spec migrate_to_multilang(map() | nil, String.t()) :: map()
def migrate_to_multilang(flat_data, primary_lang) do
flat_data = flat_data || %{}
%{
@primary_language_key => primary_lang,
primary_lang => flat_data
}
end
@doc """
Converts multilang data back to flat structure.
Returns primary language data.
"""
@spec flatten_to_primary(map() | nil) :: map()
def flatten_to_primary(nil), do: %{}
def flatten_to_primary(data) when is_map(data) do
primary = data[@primary_language_key]
if primary, do: Map.get(data, primary, %{}), else: data
end
def flatten_to_primary(_), do: %{}
# ── Primary language re-keying ──────────────────────────────
@doc """
Re-keys multilang data to a new primary language.
Updates `_primary_language` to the new primary and ensures the new
primary has complete data (fills missing fields from the old primary).
All secondary languages are recomputed: their overrides are recalculated
against the new promoted primary, and languages with zero overrides are removed.
Returns data unchanged if already using the given primary or not multilang.
"""
@spec rekey_primary(map() | nil, String.t()) :: map()
def rekey_primary(nil, _new_primary), do: nil
def rekey_primary(data, new_primary) when is_map(data) do
cond do
not multilang_data?(data) ->
data
primary_language_from_data(data) == new_primary ->
data
true ->
old_primary = primary_language_from_data(data)
old_primary_data = Map.get(data, old_primary, %{})
new_primary_data = Map.get(data, new_primary, %{})
# Promote: fill missing fields in new primary from old primary
promoted = Map.merge(old_primary_data, new_primary_data)
data =
data
|> Map.put(@primary_language_key, new_primary)
|> Map.put(new_primary, promoted)
# Recompute all secondaries (including old primary) against the new base
recompute_all_secondaries(data, new_primary, promoted, old_primary_data)
end
end
def rekey_primary(data, _new_primary), do: data
@doc """
Checks if data needs re-keying (embedded primary != global primary).
Returns re-keyed data if needed, original data otherwise.
"""
@spec maybe_rekey_data(map() | nil) :: map() | nil
def maybe_rekey_data(data) do
if multilang_data?(data) do
global = primary_language()
embedded = primary_language_from_data(data)
if embedded != global do
rekey_primary(data, global)
else
data
end
else
data
end
end
# ── Language tab helpers ──────────────────────────────────────
@doc """
Builds language tab data for the UI from the Languages module.
Returns a list of maps with code, name, flag, and is_primary fields.
"""
@spec build_language_tabs() :: [map()]
def build_language_tabs do
if enabled?() do
primary = primary_language()
langs = enabled_languages()
# Ensure primary is always first
ordered = [primary | Enum.reject(langs, &(&1 == primary))]
Enum.map(ordered, fn code ->
info = get_language_info(code)
%{
code: code,
name: info.name,
flag: info.flag,
is_primary: code == primary,
short_code: compute_short_code(code, ordered)
}
end)
else
[]
end
end
# ── Private helpers ───────────────────────────────────────────
defp compute_short_code(code, all_codes) do
base = code |> String.split("-") |> List.first() |> String.upcase()
collision =
Enum.any?(all_codes, fn other ->
other != code and
other |> String.split("-") |> List.first() |> String.upcase() == base
end)
if collision, do: String.upcase(code), else: base
end
# After rekeying, recompute overrides for every secondary language against the
# new promoted primary. Removes language keys that have zero overrides.
defp recompute_all_secondaries(data, new_primary, promoted, old_primary_data) do
Enum.reduce(data, data, fn
{@primary_language_key, _}, acc ->
acc
{^new_primary, _}, acc ->
acc
{lang, lang_data}, acc when is_map(lang_data) ->
# Reconstruct full data using OLD primary as base (overrides were against old primary)
full_lang_data = Map.merge(old_primary_data, lang_data)
# Then diff against the NEW primary to compute new overrides
overrides = compute_overrides(full_lang_data, promoted)
put_or_remove_language(acc, lang, overrides)
{_key, _value}, acc ->
acc
end)
end
defp put_or_remove_language(data, lang, overrides) do
if map_size(overrides) == 0 do
Map.delete(data, lang)
else
Map.put(data, lang, overrides)
end
end
defp compute_overrides(lang_data, primary_data) do
lang_data
|> Enum.filter(fn {key, value} ->
value != nil and value != "" and Map.get(primary_data, key) != value
end)
|> Map.new()
end
defp primary_language_from_data(data) do
data[@primary_language_key] || primary_language()
end
defp languages_available? do
Code.ensure_loaded?(Languages) and
function_exported?(Languages, :enabled?, 0) and
Languages.enabled?()
rescue
_ -> false
end
defp enabled_language_codes do
if Code.ensure_loaded?(Languages) and
function_exported?(Languages, :get_enabled_language_codes, 0) do
Languages.get_enabled_language_codes()
else
[default_language_code()]
end
rescue
_ -> [default_language_code()]
end
defp default_language_code do
if Code.ensure_loaded?(Languages) and
function_exported?(Languages, :get_default_language, 0) do
case Languages.get_default_language() do
%{code: code} when is_binary(code) -> code
_ -> "en-US"
end
else
"en-US"
end
rescue
_ -> "en-US"
end
defp get_language_info(code) do
lang =
if Code.ensure_loaded?(Languages) and
function_exported?(Languages, :get_language, 1) do
Languages.get_language(code)
end
available =
if is_nil(lang) and Code.ensure_loaded?(Languages) and
function_exported?(Languages, :get_available_language_by_code, 1) do
Languages.get_available_language_by_code(code)
end
cond do
lang != nil ->
%{name: Map.get(lang, :name, code), flag: Map.get(lang, :flag, nil)}
available != nil ->
%{name: Map.get(available, :name, code), flag: Map.get(available, :flag, nil)}
true ->
%{name: code, flag: nil}
end
end
end