defmodule PhoenixKit.Modules.Languages do @moduledoc """ Languages management for PhoenixKit - complete language configuration in a single module. This module provides management for language module in PhoenixKit applications. It handles language configuration, settings, and language data through JSON settings. ## Language Structure Each language has the following structure: - `code`: Language code (e.g., "en-US", "es-ES", "fr-FR") - `name`: Full language name (e.g., "English (United States)", "Spanish (Spain)", "French (France)") - `is_default`: Boolean indicating if this is the default language - `is_enabled`: Boolean indicating if this language is active ## Core Functions ### Languages Management - `enabled?/0` - Check if languages are enabled - `enable_system/0` - Enable languages with default English - `disable_system/0` - Disable languages - `get_config/0` - Get complete configuration ### Language Management - `get_languages/0` - Get all configured languages - `get_enabled_languages/0` - Get only enabled languages - `get_default_language/0` - Get the default language - `get_language/1` - Get a specific language by code - `get_language_codes/0` - Get list of all language codes - `get_enabled_language_codes/0` - Get list of enabled language codes - `valid_language?/1` - Check if a language code exists - `language_enabled?/1` - Check if a language is enabled - `add_language/1` - Add a new language to the system - `update_language/2` - Update an existing language - `remove_language/1` - Remove a language from the system - `set_default_language/1` - Set a new default language - `enable_language/1` - Enable a specific language - `disable_language/1` - Disable a specific language ## Usage Examples # Check if languages are enabled if PhoenixKit.Modules.Languages.enabled?() do # Languages are active end # Enable languages (creates default English language) {:ok, config} = PhoenixKit.Modules.Languages.enable_system() # Add a new language {:ok, config} = PhoenixKit.Modules.Languages.add_language("es") # Get all languages languages = PhoenixKit.Modules.Languages.get_languages() # => [%Language{code: "en-US", name: "English (United States)", is_default: true, is_enabled: true}, ...] # Get only enabled languages (most common use case) enabled_languages = PhoenixKit.Modules.Languages.get_enabled_languages() # => [%Language{code: "en-US", name: "English (United States)", ...}, ...] # Get a specific language by code spanish = PhoenixKit.Modules.Languages.get_language("es-ES") # => %{code: "es-ES", name: "Spanish (Spain)", is_enabled: true} # Get just the language codes codes = PhoenixKit.Modules.Languages.get_enabled_language_codes() # => ["en-US", "es-ES", "fr-FR"] # Check if a language is valid and enabled if PhoenixKit.Modules.Languages.language_enabled?("es-ES") do # Use Spanish language end ## JSON Storage Format Languages are stored in the `languages_config` setting as JSON. The array order determines the display order: { "languages": [ { "code": "en-US", "name": "English (United States)", "is_default": true, "is_enabled": true }, { "code": "es-ES", "name": "Spanish (Spain)", "is_default": false, "is_enabled": true } ] } """ use PhoenixKit.Module require Logger alias PhoenixKit.Config alias PhoenixKit.Dashboard.Tab alias PhoenixKit.Modules.Languages.Language alias PhoenixKit.Settings @config_key "languages_config" @default_language_no_prefix_key "default_language_no_prefix" @default_locale Config.default_locale() @enabled_key "languages_enabled" @module_name "languages" # Available languages are sourced from BeamLabCountries.Languages # Default configuration when system is first enabled @default_config %{ "languages" => [ %{ "code" => "en-US", "name" => "English (United States)", "is_default" => true, "is_enabled" => true } ] } # Default languages for permanent display in admin @default_languages [ %Language{code: "en-US", name: "English (United States)", is_default: true, is_enabled: true}, %Language{code: "es-ES", name: "Spanish (Spain)", is_default: false, is_enabled: true}, %Language{code: "fr-FR", name: "French (France)", is_default: false, is_enabled: true}, %Language{code: "de-DE", name: "German (Germany)", is_default: false, is_enabled: true}, %Language{code: "ja", name: "Japanese", is_default: false, is_enabled: true}, %Language{code: "pt-BR", name: "Portuguese (Brazil)", is_default: false, is_enabled: true}, %Language{code: "it", name: "Italian", is_default: false, is_enabled: true}, %Language{code: "ko", name: "Korean", is_default: false, is_enabled: true}, %Language{code: "ru", name: "Russian", is_default: false, is_enabled: true}, %Language{code: "nl", name: "Dutch", is_default: false, is_enabled: true}, %Language{code: "zh", name: "Chinese", is_default: false, is_enabled: true}, %Language{code: "ar", name: "Arabic", is_default: false, is_enabled: true}, %Language{code: "et", name: "Estonian", is_default: false, is_enabled: true} ] ## --- System Management Functions --- @doc """ Normalizes language settings by merging any languages from the legacy `admin_languages` setting into the unified `languages_config`. This is a one-time migration function that: 1. Reads the old `admin_languages` JSON array of codes 2. Ensures the Languages module is enabled if admin languages existed 3. Adds any admin-only languages to the unified config 4. Clears the old `admin_languages` setting to prevent re-processing Idempotent — if `admin_languages` doesn't exist or is empty, this is a no-op. ## Examples iex> PhoenixKit.Modules.Languages.normalize_language_settings() :ok """ def normalize_language_settings do case Settings.get_setting("admin_languages") do nil -> :ok "[]" -> :ok admin_json when is_binary(admin_json) -> case Jason.decode(admin_json) do {:ok, codes} when is_list(codes) and codes != [] -> merge_admin_languages(codes) {:ok, _} -> :ok {:error, decode_error} -> Logger.warning( "[PhoenixKit Languages] Invalid JSON in legacy admin_languages setting: #{inspect(decode_error)}" ) :ok end _ -> :ok end rescue error -> Logger.warning( "[PhoenixKit Languages] Could not normalize legacy admin_languages setting: #{inspect(error)}" ) :ok end defp merge_admin_languages(admin_codes) do # Ensure the system is enabled so we can add languages unless enabled?() do case enable_system() do {:ok, _} -> Logger.info("[PhoenixKit Languages] Enabled Languages module for legacy migration") {:error, reason} -> Logger.warning( "[PhoenixKit Languages] Failed to enable Languages module during migration: #{inspect(reason)}" ) # Cannot proceed without the module enabled throw(:enable_failed) end end current_codes = get_language_codes() # Add any admin-only languages that aren't already in the config results = for code <- admin_codes, code not in current_codes do case add_language(code) do {:ok, _} -> Logger.info( "[PhoenixKit Languages] Migrated admin language #{code} to unified config" ) {:ok, code} {:error, reason} -> Logger.warning( "[PhoenixKit Languages] Failed to migrate admin language #{code}: #{inspect(reason)}" ) {:error, code} end end added_count = Enum.count(results, &match?({:ok, _}, &1)) failed_count = Enum.count(results, &match?({:error, _}, &1)) if added_count > 0 or failed_count > 0 do Logger.info( "[PhoenixKit Languages] Migration complete: #{added_count} added, #{failed_count} failed" ) end # Clear the legacy setting so this doesn't run again case Settings.update_setting("admin_languages", "[]") do {:ok, _} -> :ok {:error, reason} -> Logger.warning( "[PhoenixKit Languages] Failed to clear legacy admin_languages setting: #{inspect(reason)}" ) end :ok catch :enable_failed -> :ok end @impl PhoenixKit.Module @doc """ Backfills the `default_language_no_prefix` setting from the legacy `publishing_default_language_no_prefix` key the first time this runs on an install that predates the site-wide setting. Idempotent — once the new key is set, subsequent calls are no-ops. Called by `PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0` from the host app's `Application.start/2`. """ @spec migrate_legacy() :: :ok | {:ok, map()} def migrate_legacy do new_key = @default_language_no_prefix_key legacy_key = "publishing_default_language_no_prefix" case Settings.get_setting(new_key) do nil -> case Settings.get_setting(legacy_key) do nil -> {:ok, %{default_language_no_prefix: :not_migrated}} legacy_value when is_binary(legacy_value) -> # Settings serialize booleans as the strings "true"/"false"; # rewrite the same shape under the new key so # `get_boolean_setting/2` parses it identically. {:ok, _} = Settings.update_setting(new_key, legacy_value) {:ok, %{default_language_no_prefix: :migrated_from_legacy, value: legacy_value}} end _existing -> {:ok, %{default_language_no_prefix: :already_set}} end end @impl PhoenixKit.Module @doc """ Checks if the language module is enabled. Returns true if the module is enabled, false otherwise. ## Examples iex> PhoenixKit.Modules.Languages.enabled?() false """ def enabled? do Settings.get_boolean_setting(@enabled_key, false) end @impl PhoenixKit.Module @doc """ Enables the language module and creates default configuration. Creates the initial module configuration with English as the default language. If a previous configuration exists, it will be restored instead of reset. Updates both the enabled flag and the JSON configuration. Returns `{:ok, config}` on success, `{:error, reason}` on failure. ## Examples iex> PhoenixKit.Modules.Languages.enable_system() {:ok, %{"languages" => [%{"code" => "en-US", ...}]}} """ def enable_system do # Enable the system case Settings.update_boolean_setting_with_module(@enabled_key, true, @module_name) do {:ok, _setting} -> # Check if a previous configuration exists to preserve languages existing_config = Settings.get_json_setting(@config_key, nil) config_to_save = if is_nil(existing_config), do: @default_config, else: existing_config # Save the configuration (either existing or new default) case Settings.update_json_setting_with_module(@config_key, config_to_save, @module_name) do {:ok, _setting} -> {:ok, config_to_save} {:error, changeset} -> {:error, changeset} end {:error, changeset} -> {:error, changeset} end end @impl PhoenixKit.Module @doc """ Disables the language module. Turns off the language module but preserves the language configuration. Returns `{:ok, setting}` on success, `{:error, changeset}` on failure. ## Examples iex> PhoenixKit.Modules.Languages.disable_system() {:ok, %Setting{}} """ def disable_system do Settings.update_boolean_setting_with_module(@enabled_key, false, @module_name) end @impl PhoenixKit.Module @doc """ Gets the complete language module configuration. Returns a map with module status and language configuration. ## Examples iex> PhoenixKit.Modules.Languages.get_config() %{ enabled: true, languages: [%{"code" => "en-US", "name" => "English (United States)", ...}], language_count: 1, enabled_count: 1, default_language: %{"code" => "en-US", "name" => "English (United States)", ...} } """ def get_config do enabled = enabled?() languages = get_languages() enabled_languages = Enum.filter(languages, & &1.is_enabled) default_language = Enum.find(languages, & &1.is_default) %{ enabled: enabled, languages: languages, language_count: length(languages), enabled_count: length(enabled_languages), default_language: default_language } end # ============================================================================ # Module Behaviour Callbacks # ============================================================================ @impl PhoenixKit.Module def module_key, do: "languages" @impl PhoenixKit.Module def module_name, do: "Languages" @impl PhoenixKit.Module def permission_metadata do %{ key: "languages", label: "Languages", icon: "hero-language", description: "Multi-language support and locale management" } end @impl PhoenixKit.Module def settings_tabs do [ Tab.new!( id: :admin_settings_languages, label: "Languages", icon: "hero-language", path: "languages", priority: 928, level: :admin, parent: :admin_settings, permission: "languages", gettext_backend: PhoenixKitWeb.Gettext ) ] end ## --- Language Management Functions --- @doc """ Gets all configured languages from the JSON setting. Returns a list of language maps, or empty list if not configured. ## Examples iex> PhoenixKit.Modules.Languages.get_languages() [%{"code" => "en-US", "name" => "English (United States)", "is_default" => true, ...}] # When system is disabled: iex> PhoenixKit.Modules.Languages.get_languages() [] """ def get_languages do if enabled?() do case Settings.get_json_setting_cached(@config_key, nil) do %{"languages" => languages} when is_list(languages) -> Enum.map(languages, &Language.from_json_map/1) _ -> [] end else [] end end @doc """ Gets only enabled languages, sorted by position. Returns a list of enabled language maps. ## Examples iex> PhoenixKit.Modules.Languages.get_enabled_languages() [%{"code" => "en-US", "name" => "English (United States)", ...}] """ def get_enabled_languages do get_languages() |> Enum.filter(& &1.is_enabled) |> Enum.sort_by(& &1.position) end @doc """ Gets enabled languages grouped by continent. Filters `get_languages_grouped_by_continent/0` to only include languages that are currently enabled. Languages may appear under multiple continents (same as the admin settings page). Returns `[{continent, [language_maps]}]`. ## Examples iex> PhoenixKit.Modules.Languages.get_enabled_languages_by_continent() [{"Asia", [%{code: "ja", name: "Japanese", ...}]}, {"Europe", [%{code: "de-DE", ...}]}, ...] """ def get_enabled_languages_by_continent do enabled_codes = get_display_languages() |> Enum.filter(& &1.is_enabled) |> MapSet.new(& &1.code) get_languages_grouped_by_continent() |> Enum.map(fn {continent, countries} -> langs = collect_enabled_langs(countries, enabled_codes) {continent, langs} end) |> Enum.reject(fn {_, langs} -> langs == [] end) |> Enum.sort_by(fn {continent, _} -> continent end) end defp collect_enabled_langs(countries, enabled_codes) do countries |> Enum.flat_map(fn {_country, _flag, country_langs} -> Enum.filter(country_langs, &(lang_code(&1) in enabled_codes)) end) |> Enum.uniq_by(&lang_code/1) end defp lang_code(lang) when is_struct(lang), do: lang.code defp lang_code(lang) when is_map(lang), do: lang[:code] @doc """ Gets the default language. Returns the language map marked as default, or nil if none found. ## Examples iex> PhoenixKit.Modules.Languages.get_default_language() %{"code" => "en-US", "name" => "English (United States)", "is_default" => true, ...} # When system is disabled: iex> PhoenixKit.Modules.Languages.get_default_language() nil """ def get_default_language do if enabled?() do get_languages() |> Enum.find(& &1.is_default) else nil end end @doc """ Returns true when public + admin URLs should omit the locale segment for the primary language. Site-wide setting (key `default_language_no_prefix`). When `true`, every URL generator that honors it — `Routes.path/1`, `Routes.admin_path/2`, the sitemap, publishing's HTML builders, the publishing canonical-redirect controller, and the publishing `RouterDispatch` rewriter — emits the primary language as the prefixless shape (`/admin/users`, `/blog/post`). When `false`, all generators emit the prefixed shape (`/en/admin/users`, `/en/blog/post`). Default: `false` (matches the historical publishing default and keeps existing installs' indexed URLs stable on upgrade). The setting can be migrated from the legacy `publishing_default_language_no_prefix` key — see `migrate_legacy/0`. """ @spec default_language_no_prefix?() :: boolean() def default_language_no_prefix? do Settings.get_boolean_setting(@default_language_no_prefix_key, false) end @doc """ Persists the `default_language_no_prefix` setting. Returns `{:ok, setting}` on success or `{:error, changeset}` on failure. """ @spec set_default_language_no_prefix(boolean()) :: {:ok, PhoenixKit.Settings.Setting.t()} | {:error, Ecto.Changeset.t()} def set_default_language_no_prefix(value) when is_boolean(value) do Settings.update_boolean_setting(@default_language_no_prefix_key, value) end @doc """ Returns the storage key used for the no-prefix setting. Exposed for the legacy-key migration in `PhoenixKit.Migration`; callers should prefer `default_language_no_prefix?/0`. """ @spec default_language_no_prefix_key() :: String.t() def default_language_no_prefix_key, do: @default_language_no_prefix_key @doc """ Boot- and mix-task-safe wrapper around `default_language_no_prefix?/0`. URL-emission call sites (`PhoenixKit.Utils.Routes`, `PhoenixKitWeb.Users.Auth`) need the setting before the application is fully up — during `mix phoenix_kit.install`, during host-app routing macros that execute at compile time, or when the Settings table simply doesn't exist yet. This wrapper: * Returns `false` during mix-task context (detected via the same sentinel `Routes.path/1` uses) so admin URL emission stays sensible without DB. * Rescues any other exception (DB unreachable, schema mismatch, cache miss) to `false` — the safer default since prefixed URLs resolve under both setting states. Use `default_language_no_prefix?/0` from runtime contexts where the DB is guaranteed reachable. Use this from boot/middleware contexts. """ @spec prefixless_primary_safe?() :: boolean() def prefixless_primary_safe? do if mix_task_context?() do false else default_language_no_prefix?() end rescue _ -> false end # Mirrors `PhoenixKit.Utils.Routes.mix_task_context?/0` — the # settings-cache-status sentinel is the same regardless of caller. defp mix_task_context? do case Process.get(:phoenix_kit_config_status) do nil -> false _ -> true end end @doc """ Gets a specific language by its code. Returns the language map if found, or nil if not found. ## Examples iex> PhoenixKit.Modules.Languages.get_language("es-ES") %{"code" => "es-ES", "name" => "Spanish (Spain)", "is_enabled" => true} iex> PhoenixKit.Modules.Languages.get_language("invalid") nil """ def get_language(code) when is_binary(code) do if enabled?() do get_languages() |> Enum.find(&(&1.code == code)) else nil end end @doc """ Gets a list of all language codes. Returns a list of language code strings. ## Examples iex> PhoenixKit.Modules.Languages.get_language_codes() ["en-US", "es-ES", "fr-FR"] """ def get_language_codes do get_languages() |> Enum.map(& &1.code) end @doc """ Gets a list of enabled language codes, sorted by position. Returns a list of enabled language code strings. ## Examples iex> PhoenixKit.Modules.Languages.get_enabled_language_codes() ["en-US", "es-ES"] """ def get_enabled_language_codes do get_enabled_languages() |> Enum.map(& &1.code) end @doc """ Gets enabled language codes for locale-based routing. Returns a list of enabled language codes that can be used in URL routing. Falls back to ["en"] when the language module is disabled. ## Examples iex> PhoenixKit.Modules.Languages.enabled_locale_codes() ["en-US", "es-ES", "fr-FR"] # When system is disabled: iex> PhoenixKit.Modules.Languages.enabled_locale_codes() ["en-US"] """ def enabled_locale_codes do # Return enabled language codes from the frontend language module only # Admin navbar languages are managed separately in settings if enabled?() do codes = get_enabled_language_codes() # Ensure we always have at least the default locale as a fallback if Enum.empty?(codes), do: [@default_locale], else: codes else [@default_locale] end end @doc """ Gets the appropriate language list for frontend display. Returns the configured languages if the module is enabled. Otherwise, returns the default languages for display. This allows the frontend to always show a language list, reverting to defaults when the module is disabled. ## Examples # When enabled (any number of languages) iex> PhoenixKit.Modules.Languages.get_display_languages() [%{"code" => "en-US", "name" => "English (United States)", ...}] # When disabled iex> PhoenixKit.Modules.Languages.get_display_languages() [%{"code" => "en-US", ...}, %{"code" => "es-ES", ...}, ...] # Top 12 default """ def get_display_languages do if enabled?() do # Show configured languages when enabled (even if just 1) get_languages() else # Show default languages when disabled @default_languages end end @doc """ Checks if currently in configured mode (showing user-configured languages vs defaults). Returns true if the module is enabled and has 2+ configured languages. Returns false if showing the default top 10 languages. ## Examples iex> PhoenixKit.Modules.Languages.in_configured_mode?() true # When enabled with 2+ languages iex> PhoenixKit.Modules.Languages.in_configured_mode?() false # When disabled or only 1 language """ def in_configured_mode? do enabled?() and length(get_languages()) >= 2 end @doc """ Checks if a language code is valid (exists in configuration). Returns true if the language exists, false otherwise. ## Examples iex> PhoenixKit.Modules.Languages.valid_language?("es-ES") true iex> PhoenixKit.Modules.Languages.valid_language?("invalid") false """ def valid_language?(code) when is_binary(code) do not is_nil(get_language(code)) end @doc """ Checks if a language is enabled. Returns true if the language exists and is enabled, false otherwise. ## Examples iex> PhoenixKit.Modules.Languages.language_enabled?("es-ES") true iex> PhoenixKit.Modules.Languages.language_enabled?("disabled_lang") false """ def language_enabled?(code) when is_binary(code) do enabled?() && case get_language(code) do %Language{is_enabled: true} -> true _ -> false end end @doc """ Gets the default popular languages for admin panel display. Returns a list of the most commonly used language codes that should be available in the admin panel language selector. ## Examples iex> PhoenixKit.Modules.Languages.get_default_language_codes() ["en-US", "es-ES", "fr-FR", "de-DE", "pt-BR", "it", "nl", "ru", "ja", "ko", "zh", "ar", "et"] """ def get_default_language_codes do @default_languages |> Enum.map(& &1.code) end @doc """ Gets all available predefined languages. Returns the complete list of languages that can be added to the system. This list is used to populate dropdown selections. ## Examples iex> PhoenixKit.Modules.Languages.get_available_languages() [%{code: "en-US", name: "English (United States)", native: "English (US)", flag: "🇺🇸"}, ...] """ # Cache key for memoized language data (static, never changes at runtime) @available_languages_cache_key :phoenix_kit_available_languages_cache @available_languages_map_cache_key :phoenix_kit_available_languages_map_cache def get_available_languages do # Use cached version if available (static data, never changes at runtime) case :persistent_term.get(@available_languages_cache_key, :not_cached) do :not_cached -> languages = build_available_languages() :persistent_term.put(@available_languages_cache_key, languages) languages cached -> cached end end @doc """ Gets a language by code with O(1) lookup from cached map. Returns nil if not found. """ def get_available_language_by_code(code) do map = get_available_languages_map() Map.get(map, code) end # Returns a map of code => language for O(1) lookups defp get_available_languages_map do case :persistent_term.get(@available_languages_map_cache_key, :not_cached) do :not_cached -> languages = get_available_languages() map = Map.new(languages, fn lang -> {lang.code, lang} end) :persistent_term.put(@available_languages_map_cache_key, map) map cached -> cached end end # Builds the language list (called once, then cached) defp build_available_languages do BeamLabCountries.Languages.all_locales() |> Enum.map(fn locale -> %Language{ code: locale.code, name: locale.name, native: locale.native_name, flag: locale.flag, countries: BeamLabCountries.Languages.country_names_for_language(locale.base_code) } end) end @doc """ Gets available languages for selection (excludes already added languages). Returns languages that can be added to the system, filtered to exclude languages that have already been configured. ## Examples iex> PhoenixKit.Modules.Languages.get_available_languages_for_selection() [%{code: "es-ES", name: "Spanish (Spain)", native: "Español (España)", flag: "🇪🇸"}, ...] """ def get_available_languages_for_selection do all_languages = get_available_languages() if enabled?() do current_codes = get_languages() |> Enum.map(& &1.code) all_languages |> Enum.reject(fn lang -> lang.code in current_codes end) else all_languages end end @doc """ Gets a predefined language by code. Returns the language definition from the available languages list. ## Examples iex> PhoenixKit.Modules.Languages.get_predefined_language("es-ES") %{code: "es-ES", name: "Spanish (Spain)", native: "Español (España)", flag: "🇪🇸"} iex> PhoenixKit.Modules.Languages.get_predefined_language("invalid") nil """ def get_predefined_language(code) when is_binary(code) do case BeamLabCountries.Languages.get_locale(code) do nil -> nil locale -> %Language{ code: locale.code, name: locale.name, native: locale.native_name, flag: locale.flag, countries: BeamLabCountries.Languages.country_names_for_language(locale.base_code) } end end @doc """ Gets all available languages grouped by continent, then by country. Returns a list of {continent, countries} tuples where countries is a list of {country, flag, languages} tuples. Sorted alphabetically by continent and country. Only shows languages that are actually used in each country based on the `language_locales` field from BeamLabCountries. ## Examples iex> PhoenixKit.Modules.Languages.get_languages_grouped_by_continent() [ {"Africa", [{"South Africa", "🇿🇦", [%{code: "en-ZA", ...}]}, ...]}, {"Asia", [{"China", "🇨🇳", [%{code: "zh-CN", ...}]}, ...]}, {"Europe", [{"Germany", "🇩🇪", [%{code: "de-DE", ...}]}, ...]}, ... ] """ def get_languages_grouped_by_continent do get_available_languages() |> Enum.flat_map(fn lang -> base_map = normalize_language_map(lang) # Create one entry per country for this language Enum.map(lang.countries, fn country -> Map.put(base_map, :country, country) end) end) |> Enum.group_by(& &1.country) |> Enum.map(fn {country, languages} -> # Get country data from BeamLabCountries country_data = BeamLabCountries.get_by(:name, country) continent = if country_data, do: country_data.continent, else: "Other" country_flag = if country_data, do: country_data.flag, else: "🌐" # Filter languages based on language_locales if available filtered_languages = filter_languages_by_locale(languages, country_data) {continent, country, country_flag, Enum.sort_by(filtered_languages, & &1.name)} end) |> Enum.reject(fn {_, _, _, langs} -> langs == [] end) |> Enum.group_by(fn {continent, _, _, _} -> continent end) |> Enum.sort_by(fn {continent, _} -> continent end) |> Enum.map(fn {continent, countries} -> sorted_countries = countries |> Enum.map(fn {_, country, flag, langs} -> {country, flag, langs} end) |> Enum.sort_by(fn {country, _, _} -> country end) {continent, sorted_countries} end) end defp normalize_language_map(%Language{} = lang), do: Map.from_struct(lang) defp normalize_language_map(lang) when is_map(lang), do: lang # Filter languages based on country's languages_official and language_locales # - languages_official: determines WHICH languages to show (e.g., ["fr"] for France) # - language_locales: determines which SPECIFIC LOCALE to use (e.g., %{en: "en-GB"}) defp filter_languages_by_locale(languages, nil), do: languages defp filter_languages_by_locale(languages, country_data) do languages_spoken = country_data.languages_official || [] language_locales = country_data.language_locales || %{} if languages_spoken == [] do languages else languages_spoken |> Enum.map(&find_language_for_code(languages, &1, language_locales)) |> Enum.reject(&is_nil/1) end end # Find the appropriate language for a given code, using specific locale if available defp find_language_for_code(languages, lang_code, language_locales) do locale_code = Map.get(language_locales, String.to_atom(lang_code), lang_code) Enum.find(languages, fn lang -> lang.code == locale_code end) end @doc """ Adds a predefined language to the module by language code. Takes a language code and adds the corresponding predefined language to the module configuration. Only languages from the predefined list can be added. ## Examples iex> PhoenixKit.Modules.Languages.add_language("es-ES") {:ok, updated_config} iex> PhoenixKit.Modules.Languages.add_language("en-US") # if already exists {:error, "Language already exists"} iex> PhoenixKit.Modules.Languages.add_language("invalid") {:error, "Language not found in available languages"} """ def add_language(code) when is_binary(code) do # Check if language exists in predefined list case get_predefined_language(code) do nil -> {:error, "Language not found in available languages"} predefined_lang -> current_config = Settings.get_json_setting(@config_key, @default_config) current_languages = Map.get(current_config, "languages", []) # Check if language code already exists if Enum.any?(current_languages, &(&1["code"] == code)) do {:error, "Language already exists"} else # Create new language from predefined data new_language = %{ "code" => predefined_lang.code, "name" => predefined_lang.name, "is_default" => false, "is_enabled" => true } # Add new language to the end of the list final_languages = current_languages ++ [new_language] updated_config = Map.put(current_config, "languages", final_languages) # Save updated configuration case Settings.update_json_setting_with_module(@config_key, updated_config, @module_name) do {:ok, _setting} -> {:ok, updated_config} {:error, changeset} -> {:error, changeset} end end end end @doc """ Updates an existing language in the system. Takes a language code and map of attributes to update. ## Examples iex> PhoenixKit.Modules.Languages.update_language("es-ES", %{name: "Español"}) {:ok, updated_config} iex> PhoenixKit.Modules.Languages.update_language("nonexistent", %{name: "Test"}) {:error, "Language not found"} """ def update_language(code, attrs) when is_binary(code) and is_map(attrs) do current_config = Settings.get_json_setting(@config_key, @default_config) current_languages = Map.get(current_config, "languages", []) case Enum.find_index(current_languages, &(&1["code"] == code)) do nil -> {:error, "Language not found"} index -> do_update_language_at_index(current_config, current_languages, index, attrs) end end @doc """ Removes a language from the system. Cannot remove the default language or the last remaining language. ## Examples iex> PhoenixKit.Modules.Languages.remove_language("es-ES") {:ok, updated_config} iex> PhoenixKit.Modules.Languages.remove_language("en-US") # if it's default {:error, "Cannot remove default language"} """ def remove_language(code) when is_binary(code) do current_config = Settings.get_json_setting(@config_key, @default_config) current_languages = Map.get(current_config, "languages", []) language_to_remove = Enum.find(current_languages, &(&1["code"] == code)) cond do is_nil(language_to_remove) -> {:error, "Language not found"} language_to_remove["is_default"] -> {:error, "Cannot remove default language"} length(current_languages) <= 1 -> {:error, "Cannot remove the last language"} true -> updated_languages = Enum.reject(current_languages, &(&1["code"] == code)) updated_config = Map.put(current_config, "languages", updated_languages) # Save updated configuration case Settings.update_json_setting_with_module(@config_key, updated_config, @module_name) do {:ok, _setting} -> {:ok, updated_config} {:error, changeset} -> {:error, changeset} end end end @doc """ Sets a new default language. Removes default status from all other languages and sets the specified language as default. ## Examples iex> PhoenixKit.Modules.Languages.set_default_language("es-ES") {:ok, updated_config} iex> PhoenixKit.Modules.Languages.set_default_language("nonexistent") {:error, "Language not found"} """ def set_default_language(code) when is_binary(code) do update_language(code, %{"is_default" => true}) end @doc """ Enables a specific language. ## Examples iex> PhoenixKit.Modules.Languages.enable_language("es-ES") {:ok, updated_config} """ def enable_language(code) when is_binary(code) do update_language(code, %{"is_enabled" => true}) end @doc """ Disables a specific language. Cannot disable the default language. ## Examples iex> PhoenixKit.Modules.Languages.disable_language("es-ES") {:ok, updated_config} iex> PhoenixKit.Modules.Languages.disable_language("en-US") # if it's default {:error, "Cannot disable default language"} """ def disable_language(code) when is_binary(code) do current_languages = get_languages() language = Enum.find(current_languages, &(&1.code == code)) if language && language.is_default do {:error, "Cannot disable default language"} else update_language(code, %{"is_enabled" => false}) end end @doc """ Moves a language up one position in the array. Moves the language one index earlier in the languages array. Cannot move the first language up. ## Examples iex> PhoenixKit.Modules.Languages.move_language_up("es-ES") {:ok, updated_config} iex> PhoenixKit.Modules.Languages.move_language_up("en-US") # if first in array {:error, "Language is already at the top"} """ def move_language_up(code) when is_binary(code) do current_config = Settings.get_json_setting(@config_key, @default_config) current_languages = Map.get(current_config, "languages", []) # Find the index of the language to move current_index = Enum.find_index(current_languages, &(&1["code"] == code)) if current_index do if current_index == 0 do {:error, "Language is already at the top"} else # Swap with the previous element in the array updated_languages = current_languages |> List.update_at(current_index, fn _ -> Enum.at(current_languages, current_index - 1) end) |> List.update_at(current_index - 1, fn _ -> Enum.at(current_languages, current_index) end) updated_config = Map.put(current_config, "languages", updated_languages) # Save updated configuration case Settings.update_json_setting_with_module(@config_key, updated_config, @module_name) do {:ok, _setting} -> {:ok, updated_config} {:error, changeset} -> {:error, changeset} end end else {:error, "Language not found"} end end @doc """ Moves a language down one position in the array. Moves the language one index later in the languages array. Cannot move the last language down. ## Examples iex> PhoenixKit.Modules.Languages.move_language_down("en-US") {:ok, updated_config} iex> PhoenixKit.Modules.Languages.move_language_down("es-ES") # if last in array {:error, "Language is already at the bottom"} """ def move_language_down(code) when is_binary(code) do current_config = Settings.get_json_setting(@config_key, @default_config) current_languages = Map.get(current_config, "languages", []) # Find the index of the language to move current_index = Enum.find_index(current_languages, &(&1["code"] == code)) if current_index do max_index = length(current_languages) - 1 if current_index == max_index do {:error, "Language is already at the bottom"} else # Swap with the next element in the array updated_languages = current_languages |> List.update_at(current_index, fn _ -> Enum.at(current_languages, current_index + 1) end) |> List.update_at(current_index + 1, fn _ -> Enum.at(current_languages, current_index) end) updated_config = Map.put(current_config, "languages", updated_languages) # Save updated configuration case Settings.update_json_setting_with_module(@config_key, updated_config, @module_name) do {:ok, _setting} -> {:ok, updated_config} {:error, changeset} -> {:error, changeset} end end else {:error, "Language not found"} end end @doc """ Reorders languages to match the given list of codes. Languages not in `ordered_codes` keep their current position at the end. """ def reorder_languages(ordered_codes) when is_list(ordered_codes) do current_config = Settings.get_json_setting(@config_key, @default_config) current_languages = Map.get(current_config, "languages", []) lang_by_code = Map.new(current_languages, &{&1["code"], &1}) ordered_set = MapSet.new(ordered_codes) ordered = ordered_codes |> Enum.uniq() |> Enum.map(&Map.get(lang_by_code, &1)) |> Enum.reject(&is_nil/1) remaining = Enum.reject(current_languages, &MapSet.member?(ordered_set, &1["code"])) updated_languages = ordered ++ remaining updated_config = Map.put(current_config, "languages", updated_languages) case Settings.update_json_setting_with_module(@config_key, updated_config, @module_name) do {:ok, _setting} -> {:ok, updated_config} {:error, changeset} -> {:error, changeset} end end ## --- Private Helper Functions --- # Update a language at a specific index with proper default handling defp do_update_language_at_index(current_config, current_languages, index, attrs) do # Update the language current_language = Enum.at(current_languages, index) updated_language = Map.merge(current_language, stringify_keys(attrs)) # If setting as default, remove default from other languages updated_languages = if updated_language["is_default"] do update_languages_with_new_default(current_languages, index, updated_language) else List.replace_at(current_languages, index, updated_language) end updated_config = Map.put(current_config, "languages", updated_languages) # Save updated configuration case Settings.update_json_setting_with_module(@config_key, updated_config, @module_name) do {:ok, _setting} -> {:ok, updated_config} {:error, changeset} -> {:error, changeset} end end # Update languages list when setting a new default language defp update_languages_with_new_default(current_languages, index, updated_language) do current_languages |> List.replace_at(index, updated_language) |> Enum.with_index() |> Enum.map(fn {lang, idx} -> if idx != index, do: Map.put(lang, "is_default", false), else: lang end) end # Convert atom keys to string keys for JSON storage defp stringify_keys(map) when is_map(map) do Map.new(map, fn {key, value} when is_atom(key) -> {Atom.to_string(key), value} {key, value} -> {key, value} end) end end