src/i18n/z_trans.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2009-2026 Marc Worrell
%% @doc Translate english sentences into other languages, following
%% the GNU gettext principle.
%% @end

%% Copyright 2009-2026 Marc Worrell
%%
%% 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_trans).
-author("Marc Worrell <marc@worrell.nl>").

-export([
    translations/2,
    parse_translations/1,
    trans/2,
    trans/3,
    lookup/2,
    lookup/3,
    lookup_fallback/2,
    lookup_fallback/3,
    lookup_fallback_languages/2,
    lookup_fallback_language/2,
    lookup_fallback_language/3
]).

-include_lib("../../include/zotonic.hrl").

%% @doc Fetch all translations for the given English string. If no translations are
%% found then a trans record with the given string is returned.
-spec translations(z:trans() | binary() | string(), z:context()) -> z:trans().
translations(#trans{ tr = Tr0 } = Trans0, Context) ->
    case lists:keyfind(en, 1, Tr0) of
        {en, From} ->
            #trans{ tr = Tr1 } = translations(From, Context),
            #trans{ tr = merge_trs(Tr0, lists:reverse(Tr1)) };
        false ->
            Trans0
    end;
translations(From, Context) when is_binary(From) ->
    try
        case ets:lookup(z_trans_server:table(Context), From) of
            [] ->
    			#trans{ tr = [ {en, From} ] };
            [{_, Trans}] ->
    			#trans{ tr = Trans }
        end
    catch
        error:badarg ->
            #trans{ tr = [ {en, From} ] }
    end;
translations(From, Context) when is_list(From) ->
    translations(unicode:characters_to_binary(From), Context);
translations(From, Context) ->
    translations(z_convert:to_binary(From), Context).

merge_trs([], Acc) ->
    lists:reverse(Acc);
merge_trs([{Lang,_} = LT|Rest], Acc) ->
    case proplists:is_defined(Lang, Acc) of
        true -> merge_trs(Rest, Acc);
        false -> merge_trs(Rest, [LT|Acc])
    end.

%% @doc Prepare a translations table based on all .po files in the active modules.
%%      Returns a map of English sentences with all their translations
-spec parse_translations(z:context()) -> map().
parse_translations(Context) ->
    Mods = z_module_indexer:translations(Context),
    ParsePOFiles = parse_mod_pofiles(Mods, []),
    build_index(ParsePOFiles, #{}).

%% @doc Parse all .po files. Results in a map with per found label
%% a list of translations: [iso_code, trans]. Usually, the labels
%% are the English sentences.
parse_mod_pofiles([], Acc) ->
    lists:reverse(Acc);
parse_mod_pofiles([{_Module, POFiles}|Rest], Acc) ->
    Acc1 = parse_pofiles(POFiles, Acc),
    parse_mod_pofiles(Rest, Acc1).

parse_pofiles([], Acc) ->
    Acc;
parse_pofiles([{Lang,File}|POFiles], Acc) ->
    parse_pofiles(POFiles, [{Lang, z_gettext:parse_po(File)}|Acc]).

build_index([], Acc) ->
    Acc;
build_index([{Lang, Labels}|Rest], Acc) ->
    build_index(Rest, add_labels(Lang, Labels, Acc)).

add_labels(_Lang, [], Acc) ->
    Acc;
add_labels(Lang, [{header,_}|Rest], Acc) ->
    add_labels(Lang, Rest, Acc);
add_labels(Lang, [{Label,Trans}|Rest], Acc) when is_binary(Trans), is_binary(Label) ->
    case maps:find(Label, Acc) of
        {ok, Ts} ->
            case proplists:is_defined(Lang, Ts) of
                false -> add_labels(Lang, Rest, Acc#{ Label => [{Lang,Trans}|Ts]});
                true -> add_labels(Lang, Rest, Acc)
            end;
        error ->
            add_labels(Lang, Rest, Acc#{ Label => [{Lang,Trans}] })
    end.

%% @doc Strict translation lookup of a language version using the languages
%% in the context. If no translation is found or the input is undefined, then
%% undefined is returned.
-spec lookup(Text, Context) -> Translation when
    Text :: z:trans() | binary() | string() | undefined,
    Context :: z:context(),
    Translation :: binary() | undefined.
lookup(Trans, Context) ->
    lookup(Trans, z_context:languages(Context), Context).

%% @doc Strict translation lookup of a language version using the given language
%% or languages. If no translation is found or the input is undefined, then
%% undefined is returned.
-spec lookup(Text, Language, Context) -> Translation | undefined when
    Text :: z:trans() | binary() | string() | undefined,
    Language :: z_language:language_code() | [ z_language:language_code() ],
    Context :: z:context(),
    Translation :: binary().
lookup(undefined, _Lang, _Context) ->
    undefined;
lookup(Text, Lang, Context) when is_list(Text) ->
    lookup(unicode:characters_to_binary(Text), Lang, Context);
lookup(#trans{ tr = Tr }, Lang, _Context) when is_atom(Lang) ->
    case lists:keyfind(Lang, 1, Tr) of
        {_, T} -> T;
        false -> undefined
    end;
lookup(Text, Lang, Context) when is_atom(Lang), is_binary(Text) ->
     case z_context:language(Context) of
        Lang -> Text;
        _ -> undefined
    end;
lookup(Text, Lang, Context) when is_atom(Lang) ->
    lookup(Text, [Lang], Context);
lookup(#trans{ tr = Tr }, Langs, _Context) ->
    find_first(Langs, Tr);
lookup(Text, Langs, Context) when is_binary(Text) ->
    ContextLangs = z_context:languages(Context),
    case lists:any(fun(Iso) -> lists:member(Iso, ContextLangs) end, Langs) of
        true -> Text;
        false -> undefined
    end;
lookup(_Text, _Langs, _Context) ->
    undefined.

%% @doc Non strict translation lookup of a language version.
%% In order check: requested languages, default configured language, english, any
%% If no translation is found, then the empty binary string is returned.
-spec lookup_fallback(Text, OptContext) -> Translation when
    Text :: z:trans() | binary() | string() | list() | undefined,
    OptContext :: z:context() | undefined,
    Translation :: binary().
lookup_fallback(undefined, _OptContext) ->
    <<>>;
lookup_fallback(Trans, undefined) ->
    lookup_fallback(Trans, [en], undefined);
lookup_fallback(Trans, Context) ->
    lookup_fallback(Trans, z_context:languages(Context), Context).

%% @doc Non strict translation lookup of a language version.
%% In order check: requested languages, default configured language, english, any
%% If no translation is found, then the empty binary string is returned.
-spec lookup_fallback(Text, Language, OptContext) -> Translation when
    Text :: z:trans() | binary() | string() | list() | undefined,
    Language :: z_language:language_code() | [ z_language:language_code() ] | binary(),
    OptContext :: z:context() | undefined,
    Translation :: binary().
lookup_fallback(TextList, Lang, OptContext) when is_list(TextList) ->
    case is_iodata(TextList) of
        true ->
            Text1 = unicode:characters_to_binary(TextList, utf8),
            lookup_fallback(Text1, Lang, OptContext);
        false ->
            T1 = lists:map(
                fun(T) -> lookup_fallback(T, Lang, OptContext) end,
                TextList),
            case unicode:characters_to_binary(T1, utf8) of
                S when is_binary(S) -> S;
                _ -> <<>>
            end
    end;
lookup_fallback(Text, Lang, OptContext) when is_atom(Lang) ->
    lookup_fallback(Text, [Lang], OptContext);
lookup_fallback(Text, Lang, OptContext) when is_binary(Lang) ->
    case z_language:to_language_atom(Lang) of
        {ok, Code} ->
            lookup_fallback(Text, [Code], OptContext);
        {error, _} when is_binary(Text) ->
            Text;
        {error, _} ->
            <<>>
    end;
lookup_fallback(#trans{ tr = Tr }, Langs, OptContext) when is_list(Langs) ->
    case find_first(Langs, Tr) of
        undefined ->
            CfgLang = z_language:default_language(OptContext),
            case lists:keyfind(CfgLang, 1, Tr) of
                {_, Text} -> Text;
                false -> take_english_or_first(Tr)
            end;
        Text ->
            Text
    end;
lookup_fallback(Text, _Lang, _Context) when is_binary(Text) ->
    Text;
lookup_fallback(_Text, _Lang, _Context) ->
    <<>>.

is_iodata([H|T]) when is_integer(H), H >= 1 -> is_iodata(T);
is_iodata([H|T]) when is_binary(H) -> is_iodata(T);
is_iodata([H|T]) when is_list(H) -> is_iodata(H) andalso is_iodata(T);
is_iodata([]) -> true;
is_iodata(_) -> false.

-spec find_first(Langs, Texts) -> binary() | undefined when
    Langs :: [ z_language:language_code() ],
    Texts :: [ {z_language:language_code(), binary()} ].
find_first(_Langs, []) ->
    undefined;
find_first([], _Tr) ->
    undefined;
find_first([Lang|Langs], Tr) ->
    case lists:keyfind(Lang, 1, Tr) of
        false -> find_first(Langs, Tr);
        {_, Text} -> Text
    end.

-spec take_english_or_first(Tr) -> binary() when
    Tr :: [ {z_language:language_code(), binary()} ].
take_english_or_first(Tr) ->
    case lists:keyfind(en, 1, Tr) of
        false ->
            case Tr of
                [{_,Text}|_] -> Text;
                _ -> <<>>
            end;
        {_, Text} ->
            Text
    end.


%% @doc Return the language that would be selected, given the context languages.
-spec lookup_fallback_languages(Available, Context) -> Language when
    Available :: [ atom() ],
    Context :: z:context(),
    Language :: atom().
lookup_fallback_languages([], Context) ->
    z_context:language(Context);
lookup_fallback_languages(Available, Context) ->
    Enabled = z_context:languages(Context),
    case lookup_fallback_languages_1(Enabled, Available) of
        undefined ->
            case lists:member(en, Available) of
                true -> en;
                false -> hd(Available)
            end;
        Lang ->
            Lang
    end.

lookup_fallback_languages_1([], _Available) ->
    undefined;
lookup_fallback_languages_1([Lang|Enabled], Available) ->
    case lists:member(Lang, Available) of
        true -> Lang;
        false -> lookup_fallback_languages_1(Enabled, Available)
    end.


-spec lookup_fallback_language([atom()], z:context()) -> atom().
lookup_fallback_language(Langs, Context) ->
    lookup_fallback_language(Langs, z_context:language(Context), Context).

-spec lookup_fallback_language([atom()], atom(), z:context()) -> atom().
lookup_fallback_language([], Lang, _Context) ->
    Lang;
lookup_fallback_language(Langs, Lang, Context) ->
    case lists:member(Lang, Langs) of
        false ->
            case z_language:default_language(Context) of
                undefined ->
                    case lists:member(en, Langs) of
                        true ->
                            en;
                        false ->
                            case Langs of
                                [] -> Lang;
                                [L|_] -> L
                            end
                    end;
                CfgLang ->
                    CfgLangAtom = z_convert:to_atom(CfgLang),
                    case lists:member(CfgLangAtom, Langs) of
                        false ->
                            case lists:member(en, Langs) of
                                true ->
                                    en;
                                false ->
                                    case Langs of
                                        [] -> Lang;
                                        [L|_] -> L
                                    end
                            end;
                        true ->
                            CfgLangAtom
                    end
            end;
        true ->
            Lang
    end.


%% @doc translate a string or trans record into another language. Prefer
%% the language translations from the .po files over the translations in
%% the trans record itself.
-spec trans(Text, ContextOrLanguage) -> Translation when
    Text :: z:trans() | binary() | string(),
    ContextOrLanguage :: z:context() | [ atom() ] | atom(),
    Translation :: binary().
trans(Text, Lang) when is_list(Text) ->
    trans(unicode:characters_to_binary(Text), Lang);
trans(Text, #context{} = Context) when is_binary(Text) ->
    trans(Text, z_context:languages(Context), Context);
trans(#trans{} = Trans, #context{} = Context) ->
    trans(Trans, z_context:languages(Context));
trans(#trans{ tr = Tr }, Langs) ->
    case find_first(Langs, Tr) of
        undefined -> take_english_or_first(Tr);
        T -> T
    end;
trans(Text, _Lang) when is_binary(Text) ->
    Text;
trans(_Text, _Lang) ->
    <<>>.


-spec trans(Text, Language, Context) -> Translation when
    Text :: z:trans() | binary() | string(),
    Language :: [ atom() ] | atom(),
    Context :: z:context(),
    Translation :: binary().
trans(Text, Language, Context) when is_atom(Language) ->
    trans(Text, [ Language ], Context);
trans(Text, Languages, Context) when is_list(Text) ->
    trans(unicode:characters_to_binary(Text), Languages, Context);
trans(#trans{ tr = [] }, _Languages, _Context) ->
    <<>>;
trans(#trans{ tr = Tr0 }, Languages, Context) ->
    case lists:keyfind(en, 1, Tr0) of
        {en, EnText} ->
            #trans{ tr = Tr } = translations(EnText, Context),
            case find_first(Languages, Tr) of
                undefined ->
                    case find_first(Languages, Tr0) of
                        undefined -> EnText;
                        T -> T
                    end;
                T ->
                    T
            end;
        false ->
            case find_first(Languages, Tr0) of
                undefined -> <<>>;
                T -> T
            end
    end;
trans(Text, Languages, Context) when is_binary(Text) ->
    #trans{ tr = Tr } = translations(Text, Context),
    case find_first(Languages, Tr) of
        undefined -> Text;
        T -> T
    end;
trans(_Text, _Languages, _Context) ->
    <<>>.