src/support/z_props.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2020 Marc Worrell
%%
%% @doc Query string processing, property lists and property maps for
%% Zotonic resources.

%% Copyright 2020 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_props).

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

-export([
    from_props/1,

    from_list/1,
    from_qs/1,
    from_qs/2,

    extract_languages/1,
    prune_languages/2,

    normalize_dates/3
    ]).

%% Query String key, value and property types.
%% Used as input for the from_qs/1,2 functions.
-type qs_key() :: binary().
-type qs_value() :: binary() | #upload{} | term().
-type qs_prop() :: { qs_key(), qs_value() }.

-export_type([
    qs_key/0,
    qs_value/0,
    qs_prop/0
    ]).

%% We use -4700 as the most prehistoric date, as postgresql can't handle
%% dates before this date.
-define(EPOCH_START_YEAR, -4700).
-define(EPOCH_END_YEAR, 9999).


%% @doc Transform a proplist from older resources and/or code to a (nested) map.
%%      This knows how to handle nestes lists like the 'blocks' list of lists.
-spec from_props( proplists:proplist() | undefined ) -> map().
from_props(undefined) ->
    #{};
from_props(Ps) when is_list(Ps) ->
    from_props_1(Ps).

from_props_1(Ps) when is_list(Ps) ->
    L1 = lists:map(fun from_prop/1, Ps),
    maps:from_list(L1);
from_props_1(P) ->
    P.

from_prop({K, [ [{_,_}|_] | _ ] = Vs}) ->
    % This is typical for the 'blocks' property, which contains
    % a list of blocks. Where each block is a property list.
    {to_binary(K), lists:map(fun from_props_1/1, Vs)};
from_prop({K, V}) ->
    from_prop_value(to_binary(K), V);
from_prop(K) when is_atom(K) ->
    {to_binary(K), true};
from_prop(K) ->
    K.

to_binary(K) when is_atom(K) -> atom_to_binary(K, utf8);
to_binary(K) when is_binary(K) -> K;
to_binary(K) -> K.

from_prop_value(K, undefined) ->
    {K, undefined};
from_prop_value(K, V) when is_boolean(V) ->
    {K, V};
from_prop_value(<<"is_", _/binary>> = K, V) ->
    {K, z_convert:to_bool(V)};
from_prop_value(K, V) when is_atom(V) ->
    {K, atom_to_binary(V, utf8)};
from_prop_value(K, "") ->
    {K, <<>>};
from_prop_value(K, [ C | _ ] = V) when is_integer(C), C >= 0, C =< 255 ->
    % Might be string with UTF8 encoded characters.
    % Can be found in legacy Zotonic 0.x code.
    try
        V1 = iolist_to_binary(V),
        {K, V1}
    catch
        error:badarg ->
            {K, V}
    end;
from_prop_value(K, #trans{ tr = Tr }) ->
    Tr1 = lists:filtermap(
        fun
            ({Iso, Text}) when is_atom(Iso), is_binary(Text) ->
                {true, {Iso, Text}};
            ({Iso, Text}) ->
                case z_language:to_language_atom(Iso) of
                    {ok, Code} ->
                        {true, {Code, z_convert:to_binary(Text)}};
                    _ ->
                        false
                end
        end,
        Tr),
    {K, #trans{ tr = Tr1 }};
from_prop_value(K, V) ->
    {K, V}.


%% @doc Convert a list for rsc insert and/or update to a map.
%%      The list could be a list of (binary) query args, or
%%      a property list with atom keys.
-spec from_list( list() ) -> {ok, #{ binary() => term() }}.
from_list([ {K, _} | _ ] = L) when is_atom(K) ->
    {ok, from_props(L)};
from_list([ {K, _} | _ ] = L) when is_binary(K) ->
    from_qs(L).


%% @doc Combine properties from a form. The form consists of a flat list
%% with query string properties.
%% The result is a map which can be used by the rsc and other routines.
%%
%% The keys can have a special format, specifying how they are processed:
%% <ul>
%%  <li> 'dt:ymd:0:property' is datetime property, 0 for start-date, 1 for end-date,
%%    the pattern 'ymd' describes what is in the value, could also be 'y', 'm',
%%    'd', 'dmy', 'his', 'hi', 'h', 'i', or 's'</li>
%%  <li> 'prop$en' is the 'en' translation of the property named 'prop'</li>
%%  <li> 'a.b.c.d' is a nested map</li>
%%  <li> 'blocks[].name' is a list of maps, use 'blocks[].' to append a new empty
%%    entry. Use 'name[]' to append to a list of values.</li>
%% </ul>
%%
%% If a date/time part is missing then the current UTC date is used to fill the
%% missing parts.
-spec from_qs( list( qs_prop() ) ) -> {ok, #{ binary() => term() }}.
from_qs(Qs) ->
    from_qs(Qs, calendar:universal_time()).

%% @doc Like from_qs/1, except that a specific date is used to fill in any
%       missing date/time parts.
-spec from_qs( list( qs_prop() ), calendar:datetime() ) ->
            {ok, #{ binary() => term() }}.
from_qs(Qs, Now) ->
    Nested = nested(Qs),
    WithDates = combine_dates(Nested, Now),
    WithTrans = combine_trans(WithDates),
    {ok, WithTrans}.

% -spec local_now(z:context()) -> calendar:datetime().
% local_now(Context) ->
%     z_datetime:to_local(erlang:universaltime(), Context).

% is_date_prop({<<"date_remarks", _/binary>>, _}) -> false;
% is_date_prop({<<"date_", _/binary>>, _}) -> true;
% is_date_prop({<<"publication_start">>, _}) -> true;
% is_date_prop({<<"publication_end">>, _}) -> true;
% is_date_prop({<<"org_pubdate">>, _}) -> true;
% is_date_prop(_) -> false.


%% ---------------------------------------------------------------------------------------
%% Nested maps
%% ---------------------------------------------------------------------------------------

nested(Qs) ->
    Map = lists:foldl(
        fun({K, V}, Acc) ->
            [ K1 | _ ] = binary:split(K, <<"~">>),
            Parts = binary:split(K1, <<".">>, [global]),
            nested_assign(Parts, V, Acc)
        end,
        #{},
        Qs),
    reverse_lists(Map).

reverse_lists(Map) when is_map(Map) ->
    maps:map(
        fun
            (_K, M) when is_map(M) ->
                reverse_lists(M);
            (_K, L) when is_list(L) ->
                lists:foldl(
                    fun(V, Acc) ->
                        [ reverse_lists(V) | Acc ]
                    end,
                    [],
                    L);
            (_K, V) ->
                V
        end,
        Map);
reverse_lists(V) ->
    V.

nested_assign([ <<>> | _ ], _V, Map) ->
    % Drop empty keys
    Map;
nested_assign([ <<"block-">> ], V, Map) ->
    % Handle forms with old 'block-' editing templates
    nested_assign([ <<"blocks[].">> ], V, Map);
nested_assign([ <<"block-", Rest/binary>> ], V, Map) ->
    % Handle forms with old 'block-' editing templates
    case binary:split(Rest, <<"-">>) of
        [ _ , K ] -> nested_assign([ <<"blocks[].", K/binary>> ], V, Map);
        [ K ] -> nested_assign([ <<"blocks[].", K/binary>> ], V, Map)
    end;
nested_assign([ K, <<>> ], _V, Map) ->
    % Start a new map, if key ends in "[]"
    % This was a key 'a.b[].' which notifies the
    % start of a new map.
    case has_suffix(K, <<"[]">>) of
        true ->
            Len = size(K) - size(<<"[]">>),
            <<K1:Len/binary, "[]">> = K,
            case maps:get(K1, Map, []) of
                L when is_list(L) ->
                    Map#{ K1 => [ #{} | L ] };
                _ ->
                    Map#{ K1 => [ #{} ] }
            end;
        false ->
            Map
    end;
nested_assign([ K ], V, Map) ->
    case has_suffix(K, <<"[]">>) of
        true ->
            % This was a key 'a.b[]' which appends a
            % value to a list.
            Len = size(K) - size(<<"[]">>),
            <<K1:Len/binary, "[]">> = K,
            case maps:get(K1, Map, []) of
                L when is_list(L) ->
                    Map#{ K1 => [ V | L ] };
                _ ->
                    Map#{ K1 => [ V ] }
            end;
        false ->
            Map#{ K => V }
    end;
nested_assign([ K | Ks ], V, Map) ->
    case has_suffix(K, <<"[]">>) of
        true ->
            % This was a key 'a.b[].d' which sets a
            % value in a list of maps.
            Len = size(K) - size(<<"[]">>),
            <<K1:Len/binary, "[]">> = K,
            case maps:find(K1, Map) of
                {ok, [ M | L ]} ->
                    M1 = nested_assign(Ks, V, M),
                    Map#{ K1 => [ M1 | L ]};
                _ ->
                    M1 = nested_assign(Ks, V, #{}),
                    Map#{ K1 => [ M1 ]}
            end;
        false ->
            Sub = maps:get(K, Map, #{}),
            Sub1 = nested_assign(Ks, V, Sub),
            Map#{ K => Sub1 }
    end.

%% ---------------------------------------------------------------------------------------
%% Language handling
%% ---------------------------------------------------------------------------------------

%% @doc Combine properties like 'title$en' into #trans{} records.
combine_trans(Map) when is_map(Map) ->
    MapCombined = combine_trans_1( has_trans_prop(Map), Map ),
    maps:map(
        fun
            (_K, V) when is_list(V); is_map(V) ->
                combine_trans(V);
            (_K, V) ->
                V
        end,
        MapCombined);
combine_trans(List) when is_list(List) ->
    lists:map( fun combine_trans/1, List );
combine_trans(V) ->
    V.

has_trans_prop(Map) ->
    maps:fold(
        fun
            (K, V, false) -> is_trans_prop(K, V);
            (_, _, true) -> true
        end,
        false,
        Map).

combine_trans_1(true, Map) ->
    {TransParts, OtherProps} = maps:fold(
        fun(K, V, {Ts, Os}) ->
            case is_trans_prop(K, V) of
                true ->
                    Ts1 = [ {K,V} | Ts ],
                    {Ts1, Os};
                false ->
                    Os1 = Os#{ K => V },
                    {Ts, Os1}
            end
        end,
        {[], #{}},
        Map),
    TransPartsMap = group_trans_parts(TransParts),
    maps:merge(OtherProps, TransPartsMap);
combine_trans_1(false, Map) ->
    Map.

is_trans_prop(K, V) when is_binary(V) ->
    binary:match(K, <<"$">>) =/= nomatch;
is_trans_prop(_, _) ->
    false.

group_trans_parts(TransParts) ->
    lists:foldl(
        fun
            ({K, V}, Acc) ->
                case binary:split(K, <<"$">>, [global]) of
                    [ Name, Lang ] ->
                        case z_language:to_language_atom(Lang) of
                            {ok, Code} ->
                                add_trans(Name, Code, V, Acc);
                            {error, Reason} ->
                                ?LOG_NOTICE(#{
                                    text => <<"Dropping trans part, language code is unknown">>,
                                    in => zotonic_core,
                                    result => error,
                                    reason => Reason,
                                    language => Lang,
                                    part => K
                                }),
                                Acc
                        end;
                    _ ->
                        ?LOG_NOTICE(#{
                            text => <<"Dropping unknown trans part, should be like 'title$en'">>,
                            in => zotonic_core,
                            result => error,
                            reason => format,
                            part => K
                        }),
                        Acc
                end
        end,
        #{},
        TransParts).

add_trans(Name, Code, undefined, Acc) ->
    add_trans(Name, Code, <<>>, Acc);
add_trans(Name, Code, V, Acc) ->
    #trans{ tr = Tr } = maps:get(Name, Acc, #trans{}),
    Tr1 = [ {Code, z_string:trim(V)} | proplists:delete(Code, Tr) ],
    Acc#{ Name => #trans{ tr = Tr1 } }.

%% ---------------------------------------------------------------------------------------
%% Date handling
%% ---------------------------------------------------------------------------------------

%% @doc Combine multiple qs values to single dates.
combine_dates(Map, Now) when is_map(Map) ->
    MapCombined = combine_dates_1( has_dt_prop(Map), Map, Now ),
    maps:map(
        fun
            (_K, V) when is_list(V); is_map(V) ->
                combine_dates(V, Now);
            (_K, V) ->
                V
        end,
        MapCombined);
combine_dates(List, Now) when is_list(List) ->
    lists:map( fun(V) -> combine_dates(V, Now) end, List );
combine_dates(V, _Now) ->
    V.

has_dt_prop(Map) ->
    maps:fold(
        fun
            (_, _, true) -> true;
            (<<"dt:", _/binary>>, _V, false) -> true;
            (_, _, HasDT) -> HasDT
        end,
        false,
        Map).


%% @doc Combine multiple qs values to single dates.
combine_dates_1(true, Map, Now) ->
    {DateParts, OtherProps} = maps:fold(
        fun
            (<<"dt:", _/binary>> = K, V, {Ts, Os}) ->
                Ts1 = [ {K,V} | Ts ],
                {Ts1, Os};
            (K, V, {Ts, Os}) ->
                Os1 = Os#{ K => V },
                {Ts, Os1}
        end,
        {[], #{}},
        Map),
    ByName = group_date_parts(DateParts),
    DateKVs = combine_date_parts(ByName, Now),
    DateKVs1 = cleanup_dates(DateKVs),
    maps:merge(OtherProps, maps:from_list(DateKVs1));
combine_dates_1(false, Qs, _Now) ->
    Qs.

cleanup_dates(DateKVs) ->
    lists:map(
        fun(KV) ->
            cleanup_date_prop(KV)
        end,
        DateKVs).

cleanup_date_prop({Name, Date}) ->
    {Name, cleanup_date(Date)}.

cleanup_date(<<>>) -> undefined;
cleanup_date(null) -> undefined;
cleanup_date(undefined) -> undefined;
cleanup_date(false) -> undefined;
cleanup_date({{undefined, undefined, undefined}, _}) -> undefined;
cleanup_date(DateTime) -> DateTime.

group_date_parts(DatePartsQs) ->
    lists:foldl(
        fun
            ({K, V}, Acc) ->
                case binary:split(K, <<":">>, [global]) of
                    [ <<"dt">>, Pattern, EndFlag, Name ] ->
                        IsEnd = z_convert:to_bool(EndFlag),
                        group_date_part(Name, IsEnd, Pattern, V, Acc);
                    [ K ] ->
                        group_date_part(K, false, full, V, Acc);
                    _ ->
                        ?LOG_INFO(#{
                            text => <<"Dropping unknown date part, should be like 'dt:ymd:0:propname'">>,
                            in => zotonic_core,
                            result => error,
                            reason => format,
                            part => K
                        }),
                        Acc
                end
        end,
        #{},
        DatePartsQs).

group_date_part(Name, IsEnd, Pattern, V, Acc) ->
    group_date_part_1(basename(Name, IsEnd), Pattern, V, Acc).

group_date_part_1({IsEnd, Basename, Name}, Pattern, V, Acc) ->
    % Add to accumulator, combine all parts for Basename
    Parts = maps:get(Basename, Acc, #{}),
    {_, Patterns} = maps:get(IsEnd, Parts, {Name, []}),
    Patterns1 = [ {Pattern, V} | Patterns ],
    Parts1 = Parts#{
        IsEnd => {Name, Patterns1}
    },
    Acc#{ Basename => Parts1 }.


combine_date_parts(Parts, Now) ->
    maps:fold(
        fun(_, V, Acc) ->
            Ps = combine_date_part(V, Now),
            Ps ++ Acc
        end,
        [],
        Parts).

combine_date_part(#{ false := StartParts, true := EndParts }, Now) ->
    {StartKey, StartDate} = combine_part(StartParts, false, Now),
    {EndKey, EndDate} = combine_part(EndParts, true, Now),
    StartDate1 = set_default( default_date(StartKey, Now), StartDate ),
    EndDate1 = copy_missing(EndKey, StartDate1, EndDate),
    [
        {StartKey, StartDate1},
        {EndKey, EndDate1}
    ];
combine_date_part(#{ false := StartParts }, Now) ->
    {StartKey, StartDate} = combine_part(StartParts, false, Now),
    StartDate1 = set_default( default_date(StartKey, Now), StartDate ),
    [
        {StartKey, StartDate1}
    ];
combine_date_part(#{ true := EndParts }, Now) ->
    {EndKey, EndDate} = combine_part(EndParts, true, Now),
    EndDate1 = copy_missing(EndKey, default_date(EndKey, Now), EndDate),
    [
        {EndKey, EndDate1}
    ].

combine_part({Key, Ps}, _IsEnd, Now) ->
    DateParts = lists:map(
        fun({Pattern, V}) ->
            {Pattern, to_date_value(Pattern, V)}
        end,
        Ps),
    Date = lists:foldl(
        fun
            ({_Pattern, undefined}, DateAcc) ->
                DateAcc;
            ({_Pattern, <<>>}, DateAcc) ->
                DateAcc;
            ({Pattern, DatePart}, DateAcc) ->
                merge_date_part(DateAcc, Pattern, DatePart)
        end,
        default_date(Key, Now),
        DateParts),
    {Key, Date}.


default_date(<<"date">>, _Now) -> undefined_date();
default_date(<<"date_start">>, _Now) -> undefined_date();
default_date(<<"date_end">>, _Now) -> undefined_date();
default_date(<<"publication_start">>, _Now) -> undefined_date();
default_date(<<"publication_end">>, _Now) -> ?ST_JUTTEMIS;
default_date(<<"org_pubdate">>, _Now) -> undefined_date();
default_date(_, Now) -> Now.

undefined_date() ->
    { {undefined, undefined, undefined}, {undefined, undefined, undefined} }.

merge_date_part(_Date, full, V) -> V;
merge_date_part({{_Y, M, D}, {H, I, S}}, <<"y">>, V) -> {{V, M, D}, {H, I, S}};
merge_date_part({{Y, _M, D}, {H, I, S}}, <<"m">>, V) -> {{Y, V, D}, {H, I, S}};
merge_date_part({{Y, M, _D}, {H, I, S}}, <<"d">>, V) -> {{Y, M, V}, {H, I, S}};
merge_date_part({{Y, M, D}, {_H, I, S}}, <<"h">>, V) -> {{Y, M, D}, {V, I, S}};
merge_date_part({{Y, M, D}, {H, _I, S}}, <<"i">>, V) -> {{Y, M, D}, {H, V, S}};
merge_date_part({{Y, M, D}, {H, I, _S}}, <<"s">>, V) -> {{Y, M, D}, {H, I, V}};
merge_date_part({{Y, M, D}, {_H, _I, S}}, <<"hi">>, {H, I, _S}) -> {{Y, M, D}, {H, I, S}};
merge_date_part({{Y, M, D}, _Time}, <<"his">>, {_, _, _} = V) -> {{Y, M, D}, V};
merge_date_part({_Date, {H, I, S}}, <<"ymd">>, {_, _, _} = V) -> {V, {H, I, S}};
merge_date_part({_Date, {H, I, S}}, <<"dmy">>, {_, _, _} = V) -> {V, {H, I, S}}.


to_date_value(full, V) ->
    V;
to_date_value(<<"ymd">>, <<"-", V/binary>>) ->
    case to_date_value(<<"ymd">>, V) of
        {Y, M, D} when is_integer(Y) -> {-Y, M, D};
        YMD -> YMD
    end;
to_date_value(<<"dmy">>, V) ->
    case re:run(V, "([0-9]+)[-/: ]([0-9]+)[-/: ](-?[0-9]+)", [{capture, all_but_first, binary}]) of
        nomatch -> {undefined, undefined, undefined};
        % Negative years 13/7/-99
        {match, [D, M, Y]} -> {to_int(Y), to_int(M), to_int(D)}
    end;
to_date_value(Part, V) when Part =:= <<"ymd">>; Part =:= <<"his">> ->
    case binary:split(V, [<<"-">>, <<"/">>, <<":">>, <<" ">>], [global]) of
        [<<>>] -> {undefined, undefined, undefined};
        [Y, M, D] -> {to_int(Y), to_int(M), to_int(D)}
    end;
to_date_value(<<"hi">>, V) ->
    case binary:split(V, [<<"-">>, <<"/">>, <<":">>, <<" ">>], [global]) of
        [<<>>] -> {undefined, undefined, undefined};
        [H] -> {to_int(H), 0, undefined};
        [H, I] -> {to_int(H), to_int(I), undefined}
    end;
to_date_value(_, V) ->
    to_int(V).

copy_missing( <<"publication_end">>, _S, {{undefined,undefined,undefined},{undefined,undefined,_}} ) ->
    ?ST_JUTTEMIS;
copy_missing( _Name, _S, {{undefined,undefined,undefined},{undefined,undefined,_}} ) ->
    undefined;
copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{undefined,Me,De},{He,Ie,Se}} ) when is_integer(Ys) ->
    copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ys,Me,De},{He,Ie,Se}} );
copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ys,undefined,De},{He,Ie,Se}} ) when is_integer(Ms) ->
    copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}} ,{{Ys,Ms,De},{He,Ie,Se}} );
copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ys,Ms,undefined},{He,Ie,Se}} ) when is_integer(Ds) ->
    copy_missing( Name, {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ys,Ms,Ds},{He,Ie,Se}} );
copy_missing( Name, S, {{undefined,Me,De},{He,Ie,Se}} ) ->
    copy_missing( Name, S, {{?EPOCH_END_YEAR,Me,De},{He,Ie,Se}} );
copy_missing( Name, S, {{Ye,undefined,De},{He,Ie,Se}} ) ->
    copy_missing( Name, S ,{{Ye,12,De},{He,Ie,Se}} );
copy_missing( Name, S, {{Ye,Me,undefined},{He,Ie,Se}} ) ->
    De = z_datetime:last_day_of_the_month(Ye,Me),
    copy_missing( Name, S, {{Ye,Me,De},{He,Ie,Se}} );
copy_missing( Name, S, {{Ye,Me,De},{undefined,Ie,Se}} ) ->
    copy_missing( Name, S, {{Ye,Me,De},{23,Ie,Se}} );
copy_missing( Name, S, {{Ye,Me,De},{He,undefined,Se}} ) ->
    copy_missing( Name, S, {{Ye,Me,De},{He,59,Se}} );
copy_missing( Name, S, {{Ye,Me,De},{He,Ie,undefined}} ) ->
    copy_missing( Name, S, {{Ye,Me,De},{He,Ie,59}} );
copy_missing( _Name, _S, E ) ->
    E.

set_default(Default, {{undefined, undefined, undefined}, {undefined, undefined, undefined}}) ->
    Default;
set_default(Default, {{undefined, undefined, undefined}, {0, 0, 0}}) ->
    Default;
set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{undefined,Me,De},{He,Ie,Se}} ) when is_integer(Ys) ->
    set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ys,Me,De},{He,Ie,Se}} );
set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ye,undefined,De},{He,Ie,Se}} ) when is_integer(Ms) ->
    set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ye,Ms,De},{He,Ie,Se}} );
set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ye,Me,undefined},{He,Ie,Se}} ) when is_integer(Ds) ->
    set_default( {{Ys,Ms,Ds},{Hs,Is,Ss}}, {{Ye,Me,Ds},{He,Ie,Se}} );
set_default( S, {{undefined,Me,De},{He,Ie,Se}} ) ->
    set_default( S, {{?EPOCH_START_YEAR,Me,De},{He,Ie,Se}} );
set_default( S, {{Ye,undefined,De},{He,Ie,Se}} ) ->
    set_default( S, {{Ye,1,De},{He,Ie,Se}} );
set_default( S, {{Ye,Me,undefined},{He,Ie,Se}} ) ->
    set_default( S, {{Ye,Me,1},{He,Ie,Se}} );
set_default( S, {{Ye,Me,De},{undefined,Ie,Se}} ) ->
    set_default( S, {{Ye,Me,De},{0,Ie,Se}} );
set_default( S, {{Ye,Me,De},{He,undefined,Se}} ) ->
    set_default( S, {{Ye,Me,De},{He,0,Se}} );
set_default( S, {{Ye,Me,De},{He,Ie,undefined}} ) ->
    set_default( S, {{Ye,Me,De},{He,Ie,0}} );
set_default( _S, {{Ye,Me,De},{He,Ie,Se}} ) ->
    {{Ye,Me,De},{He,Ie,Se}};
set_default(_Default, Dt) ->
    Dt.

basename(Name, IsEnd) ->
    case has_suffix(Name, <<"_start">>) of
        true ->
            Length = size(Name) - size(<<"_start">>),
            <<Base:Length/binary, _/binary>> = Name,
            {false, Base, Name};
        false ->
            case has_suffix(Name, <<"_end">>) of
                true ->
                    Length = size(Name) - size(<<"_end">>),
                    <<Base:Length/binary, _/binary>> = Name,
                    {true, Base, Name};
                false ->
                    {IsEnd, Name, Name}
            end
    end.


has_suffix(B, Suffix) ->
    binary:longest_common_suffix([Suffix, B]) =:= size(Suffix).

to_int(<<>>) ->
    undefined;
to_int(A) ->
    try
        binary_to_integer(A)
    catch
        _:_ -> undefined
    end.



%% @doc Find all different language codes in the maps.
-spec extract_languages( map() ) -> [ atom() ].
extract_languages( Props ) when is_map(Props) ->
    Langs = maps:fold(fun extract_languages_1/3, #{}, Props),
    lists:sort( maps:keys(Langs) ).

extract_languages_1(_, V, Langs) when is_map(V) ->
    maps:fold(fun extract_languages_1/3, Langs, V);
extract_languages_1(_, V, Langs) when is_list(V) ->
    lists:foldl(
        fun(X, Acc) ->
            extract_languages_1(k, X, Acc)
        end,
        Langs,
        V);
extract_languages_1(_, #trans{ tr = Tr }, Langs) ->
    lists:foldl(
        fun
            ({Iso, _Trans}, Acc) ->
                case maps:is_key(Iso, Acc) of
                    false -> Acc#{ Iso => true };
                    true -> Acc
                end
        end,
        Langs,
        Tr);
extract_languages_1(_, _, Langs) ->
    Langs.


%% @doc Check all trans records, remove languages not mentioned.
-spec prune_languages(map(), list(atom())) -> map().
prune_languages(Props, Langs) ->
    prune_languages_1(Props, Langs).

prune_languages_1(M, Langs) when is_map(M) ->
    maps:map(
        fun(_K, V) -> prune_languages(V, Langs) end,
        M);
prune_languages_1(L, Langs) when is_list(L) ->
    lists:map(fun(E) -> prune_languages_1(E, Langs) end, L);
prune_languages_1(#trans{ tr = Tr }, Langs) ->
    Tr1 = lists:filter(
        fun({Iso, _}) -> lists:member(Iso, Langs) end,
        Tr),
    #trans{ tr = Tr1 };
prune_languages_1(V, _Langs) ->
    V.


%% @doc Normalize dates, ensure that all dates are in UTC
%%      and parsed to Erlang datetime format.
-spec normalize_dates( m_rsc:props(), boolean(), binary()|undefined ) -> m_rsc:props().
normalize_dates(#{ <<"tz">> := Tz } = Props, IsAllDay, undefined) ->
    normalize_dates_1(Props, IsAllDay, Tz);
normalize_dates(Props, IsAllDay, Tz) ->
    normalize_dates_1(Props, IsAllDay, Tz).

normalize_dates_1(Props, IsAllDay, undefined) ->
    normalize_dates_2(Props, IsAllDay, <<"UTC">>);
normalize_dates_1(Props, IsAllDay, Tz) ->
    normalize_dates_2(Props, IsAllDay, Tz).

normalize_dates_2(M, IsAllDay, Tz) when is_map(M) ->
    maps:map(
        fun(K, V) ->
            case is_date_key(K) orelse is_date_value(V) of
                true ->
                    try
                        norm_date(K, V, IsAllDay, Tz)
                    catch
                        _:_ -> undefined
                    end;
                false ->
                    V
            end
        end,
        M);
normalize_dates_2(L, IsAllDay, Tz) when is_list(L) ->
    lists:map(
        fun(V) -> normalize_dates_2(V, IsAllDay, Tz) end,
        L);
normalize_dates_2(V, IsAllDay, Tz) ->
    case is_date_value(V) of
        true ->
            try
                norm_date(<<>>, V, IsAllDay, Tz)
            catch
                _:_ -> undefined
            end;
        false ->
            V
    end.

norm_date(_K, undefined, _IsAllDay, _Tz) ->
    undefined;
norm_date(_K, <<>>, _IsAllDay, _Tz) ->
    undefined;
norm_date(_K, V, _IsAllDay, _Tz) when is_integer(V) ->
    z_datetime:timestamp_to_datetime(V);
norm_date(K, {{Y,M,D}, {H,I,S}} = DT, true, Tz) when
    is_integer(Y), is_integer(M), is_integer(D),
    is_integer(H), is_integer(I), is_integer(S) ->
    case K of
        <<"date_start">> -> DT;
        <<"date_end">> -> DT;
        _ -> z_datetime:to_utc(DT, Tz)
    end;
norm_date(_K, {{Y,M,D}, {H,I,S}} = DT, false, Tz) when
    is_integer(Y), is_integer(M), is_integer(D),
    is_integer(H), is_integer(I), is_integer(S) ->
    z_datetime:to_utc(DT, Tz);
norm_date(K, V, true, Tz) ->
    case K of
        <<"date_start">> -> z_datetime:to_datetime(V, <<"UTC">>);
        <<"date_end">> -> z_datetime:to_datetime(V, <<"UTC">>);
        _ -> z_datetime:to_datetime(V, Tz)
    end;
norm_date(_K, V, false, Tz) ->
    z_datetime:to_datetime(V, Tz).


is_date_value({{Y,M,D}, {H,I,S}}) when
    is_integer(Y), is_integer(M), is_integer(D),
    is_integer(H), is_integer(I), is_integer(S) ->
    true;
is_date_value(_) ->
    false.

is_date_key(<<"is_", _/binary>>) -> false;
is_date_key(<<"date_is_all_day">>) -> false;
is_date_key(<<"date_remarks">>) -> false;
is_date_key(<<"date_", _/binary>>) -> true;
is_date_key(<<"org_pubdate">>) -> true;
is_date_key(<<"publication_start">>) -> true;
is_date_key(<<"publication_end">>) -> true;
is_date_key(K) when is_binary(K) ->
    case binary:longest_common_suffix([ K, <<"_date">> ]) of
        5 -> true;
        _ -> false
    end;
is_date_key(_) -> false.