%% @author Arthur Clemens
%% @copyright Copyright 2016-2026 Arthur Clemens
%% @doc Language code handling functions.
%%
%% Mandatory background read on language tags: [1].
%%
%% Some quotes from [1]:
%%
%% The golden rule when creating language tags is to keep the tag as short as
%% possible. Avoid region, script or other subtags except where they add useful
%% distinguishing information. For instance, use 'ja' for Japanese and not
%% 'ja-JP', unless there is a particular reason that you need to say that this is
%% Japanese as spoken in Japan, rather than elsewhere.
%%
%% The entries in the registry follow certain conventions with regard to upper
%% and lower letter-casing. For example, language tags are lower case, alphabetic
%% region subtags are upper case, and script tags begin with an initial capital.
%% This is only a convention!
%%
%% Note that we use lower case subtags in subtag identifiers and URLs.
%%
%% Language identifiers can have the following forms:
%% - language;
%% - language-extlang;
%% - language-region;
%% - language-script;
%% It is discouraged to use language-script-region, but it is possible if
%% required.
%% For a list of language, region and script codes, see [2].
%% [1] http://www.w3.org/International/articles/language-tags/
%% [2] http://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
%% @end
%% Copyright 2016-2026 Arthur Clemens
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
-module(z_language).
-export([
initialize_config/1,
available_translations/1,
default_language/1,
enabled_languages/1,
editable_languages/1,
acceptable_languages_map/1,
language_config/1,
set_language_config/2,
is_valid/1,
to_language_atom/1,
fallback_language/1,
fallback_language/2,
english_name/1,
localized_name/2,
local_name/1,
is_rtl/1,
properties/1,
all_languages/0,
main_languages/0,
language_list/1,
language_list_sorted/1,
sort_localized/2,
is_language_enabled/2,
is_language_editable/2,
enabled_language_codes/1,
editable_language_codes/1
]).
-include("zotonic.hrl").
-define(DEFAULT_LANGUAGE, en).
-type language_code() :: atom().
-type language() :: language_code() | binary() | string().
-type language_status() :: true | editable | false.
-export_type([ language/0, language_code/0, language_status/0 ]).
%% @doc Initialize the i18n language configurations
-spec initialize_config( z:context() ) -> ok | {error, term()}.
initialize_config(Context) ->
case z_db:has_connection(Context) of
true ->
% Set default language
m_config:set_default_value(i18n, language, default_language(Context), Context),
% Set list of enabled languages
case m_config:get_prop(i18n, languages, list, Context) of
undefined ->
init_config_languages(Context);
[] ->
Default = [ {default_language(Context), true} ],
m_config:set_prop(i18n, languages, list, Default, Context);
[{FirstCode, _} | _ ] = Config ->
% Ensure that the default language is the first enabled language
Default = default_language(Context),
if
FirstCode =:= Default ->
ok;
true ->
{Enabled, Disabled} = lists:partition(
fun({_,Status}) -> Status =:= true end,
Config),
{Editable, Off} = lists:partition(
fun({_,Status}) -> Status =:= editable end,
Disabled),
Config1 = Enabled ++ Editable ++ Off,
Default1 = [ {Default, true} | proplists:delete(Default, Config1) ],
m_config:set_prop(i18n, languages, list, Default1, Context)
end,
ok
end;
false ->
ok
end.
init_config_languages(Context) ->
case m_config:get(i18n, language_list, Context) of
undefined ->
m_config:set_default_prop(i18n, languages, list, default_languages(Context), Context);
I18NLanguageList ->
maybe_update_config_list(I18NLanguageList, Context),
ok
end.
%% @doc Return the list of available languages, enable the default language.
default_languages(Context) ->
Codes = available_translations(Context),
List = [ {C, editable} || C <- Codes ],
DefaultLang = default_language(Context),
[ {DefaultLang, true} | proplists:delete(DefaultLang, List) ].
%% @doc Fetch the available translations by checking for all .po files in zotonic_core
-spec available_translations(z:context()) -> list( atom() ).
available_translations(Context) ->
ModPoFiles = z_module_indexer:translations(Context),
PoFiles = proplists:get_value(zotonic_core, ModPoFiles, []),
lists:filter(
fun z_language:is_valid/1, lists:usort([ C || {C, _File} <- PoFiles ])
).
% Fetch the map of acceptable languages and their fallback languages.
% Store this in the depcache for quick(er) lookups.
-spec acceptable_languages_map( z:context() ) -> #{ binary() => binary() }.
acceptable_languages_map(Context) ->
z_depcache:memo(
fun() -> acceptable_languages_map_1(Context) end,
acceptable_languages_map,
3600,
[config],
Context).
acceptable_languages_map_1(Context) ->
lists:foldl(
fun(Code, Acc) ->
Props = #{
code_bin := CodeBin,
fallback := FallbackList
} = z_language:properties(Code),
AliasList = maps:get(alias, Props, []),
Acc1 = Acc#{ CodeBin => CodeBin },
Acc2 = lists:foldl(
fun(Alias, FAcc) ->
maybe_set(Alias, CodeBin, FAcc)
end,
Acc1,
AliasList),
lists:foldl(
fun(Fallback, FAcc) ->
maybe_set(atom_to_binary(Fallback), CodeBin, FAcc)
end,
Acc2,
FallbackList)
end,
#{},
enabled_languages(Context)).
maybe_set(K, V, Map) ->
case maps:find(K, Map) of
error -> Map#{ K => V };
_ -> Map
end.
%% @doc Get the list of configured languages that are enabled. The list is
%% in the order of configured priority, falls back to have
%% at least one enabled language.
-spec enabled_languages(Context) -> LanguageCodes when
Context :: z:context(),
LanguageCodes :: [ language_code() ].
enabled_languages(Context) ->
MemoKey = {'z_language$enabled_languages', z_context:site(Context)},
case z_memo:get(MemoKey) of
V when is_list(V) ->
V;
_ ->
ConfigLanguages = lists:filtermap(
fun
({Code, true}) -> {true, Code};
({_, _}) -> false
end,
language_config(Context)),
z_memo:set(MemoKey, ConfigLanguages)
end.
%% @doc Get the list of configured languages that are editable. The list is
%% in the order of configured priority, falls back to have
%% at least one editable language
-spec editable_languages(Context) -> LanguageCodes when
Context :: z:context(),
LanguageCodes :: [ language_code() ].
editable_languages(Context) ->
MemoKey = {'z_language$editable_languages', z_context:site(Context)},
case z_memo:get(MemoKey) of
V when is_list(V) ->
V;
_ ->
ConfigLanguages = lists:filtermap(
fun
({Code, true}) -> {true, Code};
({Code, editable}) -> {true, Code};
({_, _}) -> false
end,
language_config(Context)),
z_memo:set(MemoKey, ConfigLanguages)
end.
%% @doc Get the list of configured languages.
-spec language_config(Context) -> LanguageConfigList when
Context :: z:context(),
LanguageConfigList :: [ {language_code(), language_status()} ].
language_config(Context) ->
case m_config:get(i18n, languages, Context) of
undefined -> [];
LanguageConfig -> proplists:get_value(list, LanguageConfig, [])
end.
%% @doc Save a new language config list. If the empty list is saved then the
%% default language (en) is enabled. There must be a language enabled. The list
%% is in the order of preference. The default system language is the first
%% enabled (true) language in the list.
-spec set_language_config( LanguageStatusList, Context ) -> ok when
Context :: z:context(),
LanguageStatusList :: [ {language_code(), language_status()} ].
set_language_config([], Context) ->
set_language_config([ {?DEFAULT_LANGUAGE, true} ], Context);
set_language_config(LanguageStatusList, Context) ->
case language_config(Context) of
LanguageStatusList -> ok;
_ -> m_config:set_prop(i18n, languages, list, LanguageStatusList, Context)
end,
% Store the first enabled language as the default language
Default = first_enabled(LanguageStatusList),
DefaultB = z_convert:to_binary(Default),
case m_config:get_value(i18n, language, Context) of
DefaultB -> ok;
_ -> m_config:set_value(i18n, language, Default, Context)
end,
z_memo:delete({'z_language$enabled_languages', z_context:site(Context)}),
z_memo:delete({'z_language$editable_languages', z_context:site(Context)}),
ok.
first_enabled([]) ->
?DEFAULT_LANGUAGE;
first_enabled([{Code, true}|_]) ->
Code;
first_enabled([_|Cs]) ->
first_enabled(Cs).
%% @doc Returns the configured default language for this server; if not set, 'en'
%% (English).
-spec default_language(OptContext) -> language_code() when
OptContext :: z:context() | undefined.
default_language(undefined) ->
?DEFAULT_LANGUAGE;
default_language(Context) ->
case m_config:get_value(i18n, language, Context) of
undefined -> ?DEFAULT_LANGUAGE;
<<>> -> ?DEFAULT_LANGUAGE;
"" -> ?DEFAULT_LANGUAGE;
Lang -> z_convert:to_atom(Lang)
end.
%% @doc Check if the language code code is a valid language.
-spec is_valid( language() ) -> boolean().
is_valid(Code) ->
z_language_data:is_language(Code).
%% @doc Translate a language-code to an atom; only return known codes.
%% Also map aliased language codes to their preferred format. Eg. 'zh-tw' to 'zh-hant'
-spec to_language_atom( language() ) -> {ok, language_code()} | {error, not_a_language}.
to_language_atom(Code) when is_binary(Code); is_atom(Code) ->
z_language_data:to_language_atom(Code);
to_language_atom(Code) ->
to_language_atom(z_convert:to_binary(Code)).
%% @doc Return the list of fallback languages (atoms) for the language.
-spec fallback_language( language() ) -> [ language_code() ].
fallback_language(Code) ->
z_language_data:fallback(Code).
%% @doc Return the fallback language (the base language); if no fallback language is
%% found, returns the default language.
-spec fallback_language( language() | undefined, z:context() ) -> language_code().
fallback_language(undefined, Context) ->
default_language(Context);
fallback_language(Code, Context) when is_binary(Code); is_atom(Code) ->
case is_valid(Code) of
false ->
default_language(Context);
true ->
case z_language_data:fallback(Code) of
[ Fallback | _ ] -> Fallback;
[] -> default_language(Context)
end
end;
fallback_language(Code, Context) ->
fallback_language(z_convert:to_binary(Code), Context).
%% @doc Returns the English language name.
-spec english_name( language() ) -> binary() | undefined.
english_name(Code) ->
get_property(Code, name_en).
%% @doc Returns the language name in the current language.
-spec localized_name( language(), z:context() ) -> binary() | undefined.
localized_name(Code, Context) ->
case z_context:language(Context) of
Code -> get_property(Code, name);
_ -> z_trans:trans(english_name(Code), Context)
end.
%% @doc Returns the local language name in the language itself.
-spec local_name( language() ) -> binary() | undefined.
local_name(Code) ->
get_property(Code, name).
%% @doc Check if the given language is a rtl language.
-spec is_rtl( language() ) -> boolean().
is_rtl(Code) ->
get_property(Code, direction) =:= <<"RTL">>.
%% @doc Check if a language code is allowed to be used as a user
%% selectable language for the interface. Returns false for
%% unknown languages.
-spec is_language_enabled(Language, Context) -> boolean() when
Language :: language(),
Context :: z:context().
is_language_enabled(Code, Context) when is_atom(Code) ->
lists:member(Code, enabled_language_codes(Context));
is_language_enabled(Code, Context) ->
case to_language_atom(Code) of
{ok, Lang} ->
lists:member(Lang, enabled_language_codes(Context));
{error, not_a_language} ->
false
end.
%% @doc Check if a language code is allowed to be edited.
%% This is a superset of the enabled languages. Returns false for
%% unknown languages.
-spec is_language_editable( language(), z:context() ) -> boolean().
is_language_editable(Code, Context) when is_atom(Code) ->
lists:member(Code, editable_language_codes(Context));
is_language_editable(Code, Context) ->
case to_language_atom(Code) of
{ok, Lang} ->
lists:member(Lang, editable_language_codes(Context));
{error, not_a_language} ->
false
end.
%% @doc Returns a list of properties from a language item retrieved from *all* languages.
%% Proplists key: language code - this is the ISO 639-1 language code or otherwise
%% the ISO 639-3, combined with region or script extension (lowercase).
%% Properties:
%% - name: native language name.
%% - name_en: English language name.
%% - language: base language; functions as fallback language if translation
%% is not available for the sub-language
%% - region (only for region variations): Alpha-2 code of country/region
%% (ISO 3166-2).
%% - script (only for script variations): 4-letter script code (ISO 15924); if omitted: Latn.
%% - direction: (if omitted: LTR) or RTL.
-spec properties( language() ) -> map() | undefined.
properties(Code) when is_binary(Code); is_atom(Code) ->
maps:get(Code, z_language_data:languages_map_flat(), undefined);
properties(Code) when is_list(Code) ->
properties(z_convert:to_binary(Code)).
%% @doc List of language data.
%% Returns a maps of language maps; sub-languages are added to the map of main languages.
%% For each language a map with properties is returned - see properties/1.
%% Each language is present with its iso code as an atom and binary key. This for
%% easier lookups.
-spec all_languages() -> LanguageMap when
LanguageMap :: #{ Code => map() },
Code :: binary() | atom().
all_languages() ->
z_language_data:languages_map_flat().
%% @doc Map of language data of main languages.
-spec main_languages() -> map().
main_languages() ->
z_language_data:languages_map_main().
%% @doc Return the currently configured list of languages, falls back to have
%% at least one enabled language.
-spec language_list(z:context()) -> list( {language_code(), language_status()} ).
language_list(Context) ->
case m_config:get(i18n, languages, Context) of
undefined ->
[ {default_language(Context), true} ];
Cfg ->
case proplists:get_value(list, Cfg, []) of
[] -> [ {default_language(Context), true} ];
L when is_list(L) -> L
end
end.
%% @doc Return the currently configured list of languages, sorted by the localized name.
-spec language_list_sorted(z:context()) -> list( {language_code(), language_status()} ).
language_list_sorted(Context) ->
WithName = lists:map(
fun({Code, _Status} = Lang) ->
{localized_name(Code, Context), Lang}
end,
language_list(Context)),
Sorted = lists:sort(WithName),
[ Lang || {_, Lang} <- Sorted ].
%% @doc Sort a list of language codes by their localized name. If the value is a map then the
%% property 'name_localized' is added. Always returns a proplist.
-spec sort_localized
([ language_code() ], Context) -> [ language_code() ] when
Context :: z:context();
([ {language_code(), language_status()} ], Context) -> [ {language_code(), language_status()} ] when
Context :: z:context();
([ {language_code(), map()} ], Context) -> [ {language_code(), map()} ] when
Context :: z:context();
([ map() ], Context) -> [ {language_code(), map()} ] when
Context :: z:context().
sort_localized(Langs, Context) when is_list(Langs) ->
WithName = lists:map(
fun
(Code) when is_atom(Code) ->
{localized_name(Code, Context), Code};
({Code, #{ name_en := _ } = Map}) when is_atom(Code) ->
LocalizedName = localized_name(Code, Context),
{LocalizedName, {Code, Map#{ name_localized => LocalizedName }}};
({Code, Map} = Lang) when is_atom(Code), is_map(Map) ->
LocalizedName = localized_name(Code, Context),
{LocalizedName, Lang};
({Code, _} = Lang) when is_atom(Code) ->
{localized_name(Code, Context), Lang}
end,
Langs),
Sorted = lists:sort(WithName),
[ Lang || {_, Lang} <- Sorted ];
sort_localized(Langs, Context) when is_map(Langs) ->
List = maps:fold(
fun
(Lang, V, Acc) ->
case z_language:to_language_atom(Lang) of
{ok, Code} when is_atom(Code) ->
[ {Code, V} | Acc ];
{error, not_a_language} ->
%% Ignore unknown languages
Acc
end
end,
[],
Langs),
sort_localized(List, Context).
%% @doc Return list of languges enabled in the user interface. This might be an empty list.
-spec enabled_language_codes(z:context()) -> list( language_code() ).
enabled_language_codes(Context) ->
case m_config:get(i18n, languages, Context) of
undefined ->
[ default_language(Context) ];
Cfg when is_list(Cfg) ->
lists:filtermap(
fun
({Code, true}) -> {true, Code};
(_) -> false
end,
proplists:get_value(list, Cfg, []))
end.
%% @doc Return list of languages enabled in the user interface. This might be an empty list.
-spec editable_language_codes(z:context()) -> list( language_code() ).
editable_language_codes(Context) ->
case m_config:get(i18n, languages, Context) of
undefined ->
[ default_language(Context) ];
Cfg when is_list(Cfg) ->
lists:filtermap(
fun
({Code, true}) -> {true, Code};
({Code, editable}) -> {true, Code};
(_) -> false
end,
proplists:get_value(list, Cfg, []))
end.
%% @private
%% Gets a property from an item retrieved from *all* languages.
-spec get_property( language(), Key:: atom() ) -> binary() | undefined.
get_property(Code, Key) ->
Map = maps:get(Code, z_language_data:languages_map_flat(), #{}),
maps:get(Key, Map, undefined).
%% @doc Convert the 0.x config i18n.language_list to the 1.x i18n.languages
-spec maybe_update_config_list(I18NLanguageList::list(), Context::z:context()) -> ok.
maybe_update_config_list(I18NLanguageList, Context) ->
case proplists:get_value(list, I18NLanguageList) of
undefined ->
m_config:delete(i18n, language_list, Context),
ok;
List when is_list(List) ->
?LOG_INFO(#{
text => <<"mod_translation: Converting 'i18n.language_list.list' config list from 0.x to 1.0.">>,
in => zotonic_core
}),
NewList = lists:foldr(
fun
({Code, ItemProps}, Acc) when is_list(ItemProps), is_atom(Code) ->
case z_language:is_valid(Code) of
true ->
IsEnabled = z_convert:to_bool( proplists:get_value(is_enabled, ItemProps, false) ),
IsEditable = z_convert:to_bool( proplists:get_value(is_editable, ItemProps, false) ),
case {IsEnabled, IsEditable} of
{true, _} -> [ {Code, true} | Acc ];
{_, true} -> [ {Code, edit} | Acc ];
{_, _} -> [ {Code, false} | Acc ]
end;
false ->
?LOG_WARNING(#{
text => <<"Conversion error, language does not exist in z_language, skipping.">>,
in => zotonic_core,
language => Code
}),
Acc
end;
(Unknown, Acc) ->
?LOG_WARNING(#{
text => <<"Conversion error, contains unknown record.">>,
in => zotonic_core,
record => Unknown
}),
Acc
end,
[],
List),
% Ensure the default language is the first enabled language
Default = default_language(Context),
NewList1 = [ {Default, true} | proplists:delete(Default, NewList) ],
m_config:set_default_prop(i18n, languages, list, NewList1, Context),
m_config:delete(i18n, language_list, Context);
_ ->
?LOG_WARNING(#{
text => <<"Conversion error, 'i18n.language_list.list' is not a list. Resetting languages.">>,
in => zotonic_core
}),
m_config:delete(i18n, language_list, Context)
end.