src/i18n/z_language.erl

%% @doc 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+extlang combinations are provided to accommodate legacy language tag
%%          forms, however, there is a single language subtag available for every
%%          language+extlang combination. That language subtag should be used rather than
%%          the language+extlang combination, where possible. For example, use 'yue'
%%          rather than 'zh-yue' for Cantonese, and 'afb' rather than 'ar-afb' for Gulf
%%          Arabic, if you can.

%%      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
-module(z_language).

-export([
    initialize_config/1,
    available_translations/1,
    default_language/1,
    enabled_languages/1,
    editable_languages/1,
    acceptable_languages/1,
    language_config/1,
    set_language_config/2,
    is_valid/1,
    to_language_atom/1,
    fallback_language/1,
    fallback_language/2,
    english_name/1,
    local_name/1,
    is_rtl/1,
    properties/1,
    all_languages/0,
    main_languages/0,
    language_list/1,
    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().

-export_type([ language/0, language_code/0 ]).



%% @doc Initialize the i18n language configurations
-spec initialize_config( z:context() ) -> ok.
initialize_config(Context) ->
    case z_db:has_connection(Context) of
        true ->
            % Set default language
            case m_config:get_value(i18n, language, Context) of
                undefined -> m_config:set_value(i18n, language, default_language(Context), Context);
                _ -> ok
            end,
            % Set list of enabled languages
            case m_config:get(i18n, languages, Context) of
                undefined ->
                    init_config_languages(Context);
                _Existing ->
                    ok
            end;
        false ->
            ok
    end.

init_config_languages(Context) ->
    case m_config:get(i18n, language_list, Context) of
        undefined ->
            m_config:set_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 ],
    EnabledLang = case m_config:get_value(i18n, language, Context) of
        undefined -> en;
        <<>> -> en;
        Code -> z_convert:to_atom(Code)
    end,
    List1 = proplists:delete(EnabledLang, List),
    lists:sort( [ {EnabledLang, true} | List1 ]).


%% @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 list of acceptable languages and their fallback languages.
% Store this in the depcache (and memo) for quick(er) lookups.
-spec acceptable_languages( z:context() ) -> list( {binary(), [ binary() ]} ).
acceptable_languages(Context) ->
    z_depcache:memo(
        fun() ->
            Enabled = enabled_languages(Context),
            lists:foldl(
                fun(Code, Acc) ->
                    LangProps = z_language:properties(Code),
                    Lang = atom_to_binary(Code, utf8),
                    Fs = z_language:fallback_language(Code),
                    FsAsBin = [ z_convert:to_binary(F) || F <- Fs ],
                    Acc1 = [ {Lang, FsAsBin} | Acc ],
                    lists:foldl(
                        fun(Alias, AliasAcc) ->
                            AliasBin = z_convert:to_binary(Alias),
                            [ {AliasBin, FsAsBin} | AliasAcc ]
                        end,
                        Acc1,
                        maps:get(alias, LangProps, []))
                end,
                [],
                Enabled)
        end,
        acceptable_languages,
        3600,
        [config],
        Context).

%% @doc Get the list of configured languages that are enabled.
-spec enabled_languages(z:context()) -> list( atom() ).
enabled_languages(Context) ->
    case z_memo:get('z_language$enabled_languages') of
        V when is_list(V) ->
            V;
        _ ->
            ConfigLanguages = lists:filtermap(
                fun
                    ({Code, true}) -> {true, Code};
                    ({_, _}) -> false
                end,
                language_config(Context)),
            z_memo:set('z_language$enabled_languages', ConfigLanguages)
    end.

%% @doc Get the list of configured languages that are editable.
-spec editable_languages(z:context()) -> list( atom() ).
editable_languages(Context) ->
    case z_memo:get('z_language$editable_languages') 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('z_language$editable_languages', ConfigLanguages)
    end.

%% @doc Get the list of configured languages.
-spec language_config(z:context()) -> list( {atom(), boolean()} ).
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
-spec set_language_config( list(), z:context() ) -> ok.
set_language_config(NewConfig, Context) ->
    case language_config(Context) of
        NewConfig -> ok;
        _ ->
            SortedConfig = lists:sort(NewConfig),
            m_config:set_prop(i18n, languages, list, SortedConfig, Context)
    end,
    z_memo:delete('z_language$enabled_languages'),
    z_memo:delete('z_language$editable_languages'),
    ok.

%% @doc Returns the configured default language for this server; if not set, 'en'
%%      (English).
-spec default_language( z:context() | undefined ) -> language_code().
default_language(undefined) ->
    ?DEFAULT_LANGUAGE;
default_language(Context) ->
    z_convert:to_atom(m_config:get_value(i18n, language, ?DEFAULT_LANGUAGE, Context)).

%% @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()} | {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 local language name.
-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 an user
%%      selectable language for the interface.
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.
-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 flattened list of property lists; sub-languages are added to the list of
%%      main languages.
%%      For each language a property list is returned - see properties/1.
-spec all_languages() -> map().
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
-spec language_list(z:context()) -> list( {language_code(), list()} ).
language_list(Context) ->
    case m_config:get(i18n, languages, Context) of
        undefined ->
            [ {default_language(Context), []} ];
        Cfg ->
            case proplists:get_value(list, Cfg, []) of
                [] -> [ {default_language(Context), []} ];
                L when is_list(L) -> L
            end
    end.

%% @doc Return list of languges enabled in the user interface.
-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.
-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),
            m_config:set_prop(i18n, languages, list, NewList, 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.