src/models/m_rsc_update.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2009-2020 Marc Worrell, Arjan Scherpenisse
%% @doc Update routines for resources.  For use by the m_rsc module.

%% Copyright 2009-2020 Marc Worrell, Arjan Scherpenisse
%%
%% 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(m_rsc_update).
-author("Marc Worrell <marc@worrell.nl").

%% interface functions
-export([
    insert/2,
    insert/3,
    delete/2,
    delete/3,
    update/3,
    update/4,
    duplicate/3,
    duplicate/4,
    merge_delete/4,

    flush/2,

    delete_nocheck/2,

    to_slug/1
]).

-include_lib("zotonic.hrl").

-record(rscupd, {
    id = undefined,
    is_escape_texts = true,
    is_acl_check = true,
    is_import = false,
    is_no_touch = false,
    tz = <<"UTC">>,
    expected = []
}).

-define(is_empty(V), (V =:= undefined orelse V =:= <<>> orelse V =:= "" orelse V =:= null)).


%% @doc Insert a new resource. Crashes when insertion is not allowed.
-spec insert(m_rsc:props_all(), z:context()) -> {ok, m_rsc:resource_id()} | {error, term()}.
insert(Props, Context) ->
    insert(Props, [{is_escape_texts, true}], Context).

-spec insert(m_rsc:props_all(), list(), z:context()) -> {ok, m_rsc:resource_id()} | {error, term()}.
insert(Props, Options, Context) when is_list(Props) ->
    {ok, Map} = z_props:from_list(Props),
    insert(Map, Options, Context);
insert(Props, Options, Context) when is_map(Props) ->
    PropsDefaults = props_defaults(Props, Context),
    update(insert_rsc, PropsDefaults, Options, Context).


%% @doc Delete a resource
-spec delete(m_rsc:resource(), z:context()) -> ok | {error, atom()}.
delete(Id, Context) ->
    delete(Id, undefined, Context).

-spec delete(m_rsc:resource(), m_rsc:resource(), z:context()) -> ok | {error, atom()}.
delete(1, _FollowUpId, _Context) ->
    {error, eacces};
delete(undefined, _FollowUpId, _Context) ->
    {error, enoent};
delete(Id, FollowUpId, Context)
    when is_integer(Id),
        (is_integer(FollowUpId) orelse FollowUpId =:= undefined) ->
    case {
        z_acl:rsc_deletable(Id, Context),
        FollowUpId =:= undefined orelse m_rsc:exists(FollowUpId, Context)
        }
    of
        {true, true} ->
            case m_rsc:is_a(Id, category, Context) of
                true ->
                    m_category:delete(Id, FollowUpId, Context);
                false ->
                    delete_nocheck(Id, FollowUpId, Context)
            end;
        {false, _} ->
            {error, eacces};
        {_, false} ->
            {error, unknown_followup}
    end;
delete(Name, FollowUpId, Context) ->
    delete(
        m_rsc:rid(Name, Context),
        m_rsc:rid(FollowUpId, Context),
        Context).

%% @doc Delete a resource, no check on rights etc is made. This is called by m_category:delete/3
-spec delete_nocheck(m_rsc:resource(), z:context()) -> ok | {error, atom()}.
delete_nocheck(Id, Context) ->
    delete_nocheck(m_rsc:rid(Id, Context), undefined, Context).

delete_nocheck(1, _OptFollowUpId, _Context) ->
    {error, eacces};
delete_nocheck(Id, OptFollowUpId, Context) when is_integer(Id) ->
    Referrers = m_edge:subjects(Id, Context),
    CatList = m_rsc:is_a(Id, Context),
    Props = m_rsc:get(Id, Context),

    % Outside transaction as due to race conditions we might
    % have a duplicate insert into the rsc_gone table. That
    % triggers an error which cancels the transaction F below.
    _ = m_rsc_gone:gone(Id, OptFollowUpId, Context),

    F = fun(Ctx) ->
        z_notifier:notify_sync(#rsc_delete{id = Id, is_a = CatList}, Ctx),
        z_db:delete(rsc, Id, Ctx)
    end,
    case z_db:transaction(F, Context) of
        {ok, _RowsDeleted} ->
            % Sync the caches
            [z_depcache:flush(SubjectId, Context) || SubjectId <- Referrers],
            flush(Id, CatList, Context),
            %% Notify all modules that the rsc has been deleted
            z_notifier:notify_sync(
                #rsc_update_done{
                    action = delete,
                    id = Id,
                    pre_is_a = CatList,
                    post_is_a = [],
                    pre_props = Props,
                    post_props = #{}
                }, Context),
             z_mqtt:publish(
                 [ <<"model">>, <<"rsc">>, <<"event">>, Id, <<"delete">> ],
                 #{
                    id => Id,
                    pre_is_a => CatList
                 },
                 Context),
            z_edge_log_server:check(Context),
            ok;
        {error, _} = Error ->
            Error
    end.

%% @doc Merge two resources, delete the losing resource.
-spec merge_delete(m_rsc:resource(), m_rsc:resource(), list(), #context{}) -> ok | {error, term()}.
merge_delete(WinnerId, WinnerId, _Options, _Context) ->
    ok;
merge_delete(_WinnerId, 1, _Options, _Context) ->
    {error, eacces};
merge_delete(_WinnerId, admin, _Options, _Context) ->
    {error, eacces};
merge_delete(WinnerId, LoserId, Options, Context) ->
    case z_acl:rsc_deletable(LoserId, Context)
        andalso z_acl:rsc_editable(WinnerId, Context)
    of
        true ->
            case m_rsc:is_a(WinnerId, category, Context) of
                true ->
                    m_category:delete(LoserId, WinnerId, Context);
                false ->
                    merge_delete_nocheck(m_rsc:rid(WinnerId, Context), m_rsc:rid(LoserId, Context), Options, Context)
            end;
        false ->
            {error, eacces}
    end.

%% @doc Merge two resources, delete the 'loser'
-spec merge_delete_nocheck(integer(), integer(), list(), #context{}) -> ok.
merge_delete_nocheck(WinnerId, LoserId, Opts, Context) ->
    IsMergeTrans = proplists:get_value(is_merge_trans, Opts, false),
    z_notifier:map(#rsc_merge{
            winner_id = WinnerId,
            loser_id = LoserId,
            is_merge_trans = IsMergeTrans
        },
        Context),
    ok = m_edge:merge(WinnerId, LoserId, Context),
    m_media:merge(WinnerId, LoserId, Context),
    m_identity:merge(WinnerId, LoserId, Context),
    move_creator_modifier_ids(WinnerId, LoserId, Context),
    PropsLoser = m_rsc:get(LoserId, Context),
    ok = delete_nocheck(LoserId, WinnerId, Context),
    case merge_copy_props(WinnerId, PropsLoser, IsMergeTrans, Context) of
        [] ->
            ok;
        UpdProps ->
            {ok, _} = update(WinnerId, UpdProps, [{escape_texts, false}], Context)
    end,
    ok.

move_creator_modifier_ids(WinnerId, LoserId, Context) ->
    Ids = z_db:q("select id
                  from rsc
                  where (creator_id = $1 or modifier_id = $1)
                    and id <> $1",
        [LoserId],
        Context),
    z_db:q("update rsc set creator_id = $1 where creator_id = $2",
        [WinnerId, LoserId],
        Context,
        1200000),
    z_db:q("update rsc set modifier_id = $1 where modifier_id = $2",
        [WinnerId, LoserId],
        Context,
        1200000),
    lists:foreach(
        fun({Id}) ->
            flush(Id, [], Context)
        end,
        Ids).

merge_copy_props(WinnerId, LoserProps, IsMergeTrans, Context) ->
    LoserProps1 = ensure_merge_language(LoserProps, Context),
    maps:fold(
        fun(K, V, Acc) ->
            case merge_copy_props_1(WinnerId, K, V, IsMergeTrans, Context) of
                {ok, V1} ->
                    Acc#{ K => V1 };
                false ->
                    Acc
            end
        end,
        #{},
        LoserProps1).

merge_copy_props_1(_WinnerId, P, _V, _IsMergeTrans, _Context)
    when P =:= <<"creator">>; P =:= <<"creator_id">>; P =:= <<"modifier">>; P =:= <<"modifier_id">>;
         P =:= <<"created">>; P =:= <<"modified">>; P =:= <<"version">>;
         P =:= <<"id">>; P =:= <<"is_published">>; P =:= <<"is_protected">>; P =:= <<"is_dependent">>;
         P =:= <<"is_authoritative">>; P =:= <<"pivot_geocode">>; P =:= <<"pivot_geocode_qhash">>;
         P =:= <<"category_id">> ->
    false;
merge_copy_props_1(WinnerId, <<"blocks">>, LoserBs, IsMergeTrans, Context) ->
    WinnerBs = m_rsc:p_no_acl(WinnerId, <<"blocks">>, Context),
    {ok, merge_copy_props_blocks(WinnerBs, LoserBs, IsMergeTrans, Context)};
merge_copy_props_1(_WinnerId, _K, undefined, _IsMergeTrans, _Context) -> false;
merge_copy_props_1(_WinnerId, _K, [], _IsMergeTrans, _Context) -> false;
merge_copy_props_1(_WinnerId, _K, <<>>, _IsMergeTrans, _Context) -> false;
merge_copy_props_1(WinnerId, P, LoserValue, IsMergeTrans, Context) ->
    case m_rsc:p_no_acl(WinnerId, P, Context) of
        undefined when IsMergeTrans, P =:= <<"language">>, is_list(LoserValue) ->
            V1 = lists:usort([ z_language:default_language(Context) ] ++ LoserValue),
            {ok, V1};
        undefined ->
            false;
        <<>> ->
            false;
        [] ->
            false;
        Value when IsMergeTrans, P =:= <<"language">>, is_list(Value), is_list(LoserValue) ->
            V1 = lists:usort(Value ++ LoserValue),
            {ok, V1};
        Value when IsMergeTrans ->
            {ok, merge_trans(Value, LoserValue, Context)};
        _Value ->
            false
    end.

ensure_merge_language(Props, Context) ->
    case maps:get(<<"language">>, Props, undefined) of
        Languages when ?is_empty(Languages) ->
            Props#{
                <<"language">> => [ z_language:default_language(Context) ]
            };
        _ ->
            Props
    end.

merge_trans(#trans{ tr = Winner }, #trans{ tr = Loser }, _Context) ->
    Tr = lists:foldl(
        fun ({Lang,Text}, Acc) ->
            case proplists:get_value(Lang, Acc) of
                undefined -> [ {Lang,Text} | Acc ];
                _ -> Acc
            end
        end,
        Winner,
        Loser),
    #trans{ tr = Tr };
merge_trans(Winner, #trans{} = Loser, Context) when is_binary(Winner) ->
    V1 = #trans{ tr = [ {z_language:default_language(Context), Winner} ] },
    merge_trans(V1, Loser, Context);
merge_trans(#trans{} = Winner, Loser, Context) when is_binary(Loser) ->
    V1 = #trans{ tr = [ {z_language:default_language(Context), Loser} ] },
    merge_trans(Winner, V1, Context);
merge_trans(Winner, _Loser, _Context) ->
    Winner.


% Merge the blocks.
% Problem is that we don't know for sure if we want to merge blocks to a superset.
% Merging might have some unintentional side effects (think of surveys, and randomly named blocks).
% So for now we only merge the translations in the like-named blocks, and only if their type is the
% same.
merge_copy_props_blocks(WinnerBs, LoserBs, _IsMergeTrans, _Context) when not is_list(LoserBs) ->  WinnerBs;
merge_copy_props_blocks(WinnerBs, LoserBs, _IsMergeTrans, _Context) when not is_list(WinnerBs) ->  LoserBs;
merge_copy_props_blocks(WinnerBs, _LoserBs, false, _Context) -> WinnerBs;
merge_copy_props_blocks(WinnerBs, LoserBs, true, Context) ->
    lists:map(
        fun(WB) ->
            case find_block( maps:get(<<"name">>, WB, undefined), LoserBs ) of
                undefined ->
                    WB;
                LB ->
                    WT = maps:get(<<"type">>, WB, undefined),
                    case maps:get(<<"type">>, LB) of
                        WT -> merge_block_single(WB, LB, Context);
                        _ -> WB
                    end
            end
        end,
        WinnerBs).

find_block(_Name, []) -> undefined;
find_block(Name, [ #{ name := Name } = B | _Bs ]) -> B;
find_block(Name, [ _ | Bs ]) -> find_block(Name, Bs).

merge_block_single(W, L, Context) ->
    maps:map(
        fun(K, WV) ->
            case maps:get(K, L, undefined) of
                undefined ->
                    WV;
                LV ->
                    WV1 = merge_trans(WV, LV, Context),
                    WV1
            end
        end,
        W).


%% Flush all cached entries depending on this entry, one of its subjects or its categories.
flush(Id, Context) ->
    CatList = m_rsc:is_a(Id, Context),
    flush(Id, CatList, Context).

flush(Id, CatList, Context) ->
    z_depcache:flush(m_rsc:rid(Id, Context), Context),
    [z_depcache:flush(Cat, Context) || Cat <- CatList],
    ok.


%% @doc Duplicate a resource, creating a new resource with the given title.
-spec duplicate(m_rsc:resource(), m_rsc:props_all(), z:context()) -> {ok, m_rsc:resource_id()} | {error, term()}.
duplicate(Id, DupProps, Context) ->
    duplicate(Id, DupProps, [], Context).

-spec duplicate(m_rsc:resource(), m_rsc:props_all(), m_rsc:duplicate_options(), z:context()) -> {ok, m_rsc:resource_id()} | {error, term()}.
duplicate(Id, DupProps, DupOpts, Context) when is_list(DupProps) ->
    {ok, DupMap} = z_props:from_list(DupProps),
    duplicate(Id, DupMap, DupOpts, Context);
duplicate(Id, DupProps, DupOpts, Context) when is_integer(Id) ->
    case z_acl:rsc_visible(Id, Context) of
        true ->
            case m_rsc:get_raw(Id, Context) of
                {ok, RawProps} ->
                    RscUpd = #rscupd{
                        id = insert_rsc,
                        is_escape_texts = false
                    },
                    FilteredProps = props_filter_protected(RawProps, RscUpd),
                    SafeDupProps = escape_props(true, DupProps, Context),
                    InsProps = maps:fold(
                        fun(Key, Value, Acc) ->
                            Acc#{ Key => Value }
                        end,
                        FilteredProps,
                        SafeDupProps#{
                            <<"name">> => undefined,
                            <<"uri">> => undefined,
                            <<"page_path">> => undefined,
                            <<"is_authoritative">> => true,
                            <<"is_protected">> => false
                        }),
                    case insert(InsProps, [{is_escape_texts, false}], Context) of
                        {ok, NewId} ->
                            case proplists:get_value(edges, DupOpts, true) of
                                true ->
                                    m_edge:duplicate(Id, NewId, Context);
                                _ ->
                                    ok
                            end,
                            case proplists:get_value(medium, DupOpts, true) of
                                true ->
                                    m_media:duplicate(Id, NewId, Context);
                                _ ->
                                    ok
                            end,
                            {ok, NewId};
                        {error, _} = Error ->
                            Error
                    end;
                {error, _} = Error ->
                    Error
            end;
        false ->
            {error, eacces}
    end;
duplicate(undefined, _DupProps, _DupOpts, _Context) ->
    {error, enoent};
duplicate(Id, DupProps, DupOpts, Context) ->
    duplicate(m_rsc:rid(Id, Context), DupProps, DupOpts, Context).

%% @doc Update a resource
-spec update(
        m_rsc:resource() | insert_rsc,
        m_rsc:props_all() | m_rsc:update_function(),
        z:context()
    ) ->
    {ok, m_rsc:resource_id()} | {error, term()}.
update(Id, Props, Context) ->
    update(Id, Props, [], Context).

%% @doc Update a resource.
%% Options flags:
%%      is_escape_texts (default: true}
%%      is_acl_check    (default: true)
%%      no_touch        (default: false)
%%      is_import       (default: false)
%% Other options:
%%      tz - timezone for date conversions
%%      expected - list with property value pairs that
%%                 are expected, fail if the properties
%%                 are different.
%%
%% {is_escape_texts, false} checks if the texts are escaped, and if not then it
%% will escape. This prevents "double-escaping" of texts.
-spec update(
        m_rsc:resource() | insert_rsc,
        m_rsc:props_all() | m_rsc:update_function(),
        list() | boolean(),
        z:context()
    ) ->
    {ok, m_rsc:resource_id()} | {error, term()}.
update(Id, Props, false, Context) ->
    update(Id, Props, [{is_escape_texts, false}], Context);
update(Id, Props, true, Context) ->
    update(Id, Props, [{is_escape_texts, true}], Context);
update(Id, Props, Options, Context) when is_list(Props) ->
    {ok, Props1} = z_props:from_list(Props),
    OptionsTz = case proplists:lookup(tz, Options) of
        {tz, _} ->
            % Timezone set in the update options
            Options;
        none ->
            case maps:find(<<"tz">>, Props1) of
                {ok, Tz} ->
                    % Timezone specified in the update
                    [ {tz, Tz} | Options ];
                error ->
                    case Props of
                        [ {K, _} | _ ] when is_binary(K); is_list(K) ->
                            % On a form post input we use the timezone of
                            % of the request context.
                            [ {tz, z_context:tz(Context)} | Options ];
                        _ ->
                            % Assume UTC
                            Options
                    end
            end
    end,
    update(Id, Props1, OptionsTz, Context);
update(Id, PropsOrFun, Options, Context) when is_integer(Id); Id =:= insert_rsc ->
    IsImport = proplists:get_value(is_import, Options, false),
    Tz0 = case is_map(PropsOrFun) of
        true when not IsImport ->
            % Timezone in the update props is leading over the timezone in the options
            case maps:get(<<"tz">>, PropsOrFun, undefined) of
                undefined -> proplists:get_value(tz, Options, <<"UTC">>);
                <<>> -> proplists:get_value(tz, Options, <<"UTC">>);
                PropTz -> PropTz
            end;
        _ ->
            proplists:get_value(tz, Options, <<"UTC">>)
    end,
    % Sanity fallback for 'undefined' tz in the options
    Tz = case Tz0 of
        undefined -> <<"UTC">>;
        <<>> -> <<"UTC">>;
        _ -> Tz0
    end,
    % Also accept the old non "is_.." options
    RscUpd = #rscupd{
        id = Id,
        is_escape_texts = proplists:get_value(is_escape_texts, Options,
                                proplists:get_value(escape_texts, Options, true)),
        is_acl_check = proplists:get_value(is_acl_check, Options,
                                proplists:get_value(acl_check, Options, true)),
        is_import = proplists:get_value(is_import, Options, false),
        is_no_touch = proplists:get_value(no_touch, Options, false)
                      andalso z_acl:is_admin(Context),
        tz = Tz,
        expected = proplists:get_value(expected, Options, [])
    },
    update_imported_check(RscUpd, PropsOrFun, Context);
update(Name, PropsOrFun, Options, Context) ->
    case m_rsc:name_to_id(Name, Context) of
        {ok, Id} ->
            update(Id, PropsOrFun, Options, Context);
        {error, _} = Error ->
            Error
    end.

update_imported_check(#rscupd{is_import = true, id = Id} = RscUpd, PropsOrFun, Context) when is_integer(Id) ->
    case m_rsc:exists(Id, Context) of
        false ->
            {ok, CatId} = m_category:name_to_id(other, Context),
            1 = z_db:q("insert into rsc (id, creator_id, is_published, category_id)
                        values ($1, $2, false, $3)",
                [Id, z_acl:user(Context), CatId],
                Context);
        true ->
            ok
    end,
    update_editable_check(RscUpd, PropsOrFun, Context);
update_imported_check(RscUpd, PropsOrFun, Context) ->
    update_editable_check(RscUpd, PropsOrFun, Context).


update_editable_check(#rscupd{id = Id, is_acl_check = true } = RscUpd, PropsOrFun, Context) when is_integer(Id) ->
    case z_acl:rsc_editable(Id, Context) of
        true ->
            update_normalize_props(RscUpd, PropsOrFun, Context);
        false ->
            {error, eacces}
    end;
update_editable_check(RscUpd, PropsOrFun, Context) ->
    update_normalize_props(RscUpd, PropsOrFun, Context).

update_normalize_props(#rscupd{id = Id, tz = Tz} = RscUpd, Props, Context) when is_map(Props) ->
    % Convert the dates in the properties to Erlang DateTime and optionally convert
    % them to UTC as well.
    IsAllDay = is_all_day(Id, Props, Context),
    PropsDates = z_props:normalize_dates(Props, IsAllDay, Tz),
    % The 'blocks' must be a list (of maps), if anything else then
    % the blocks are set to 'undefined' and removed from the rsc
    PropsBlocks = case maps:find(<<"blocks">>, PropsDates) of
        {ok, L} when is_list(L) ->
            L1 = lists:filter(
                fun
                    (B) when is_map(B) -> maps:is_key(<<"type">>, B);
                    (_) -> false
                end,
                L),
            PropsDates#{ <<"blocks">> => L1 };
        {ok, _} ->
            PropsDates#{ <<"blocks">> => undefined };
        error ->
            PropsDates
    end,
    update_transaction(RscUpd, fun(_, _, _) -> {ok, PropsBlocks} end, Context);
update_normalize_props(RscUpd, Func, Context) when is_function(Func) ->
    update_transaction(RscUpd, Func, Context).


%% If the "all day" flag is set then the date_start and date_end are specified
%% in UTC. This is used for dates that are fixed across timezones. An example
%% is Christmas, which is always on the 25th of december, local time.
%% All other dates are not affected by the flag and are subject to the timezone
%% specified in the update options or resource properties.
is_all_day(_Id, #{ <<"date_is_all_day">> := IsAllDay }, _Context) ->
    z_convert:to_bool(IsAllDay);
is_all_day(Id, _Props, Context) when is_integer(Id) ->
    z_convert:to_bool(m_rsc:p_no_acl(Id, date_is_all_day, Context));
is_all_day(_Id, _Props, _Context) ->
    false.


update_transaction(RscUpd, Func, Context) ->
    Result = z_db:transaction(
        fun(Ctx) ->
            update_transaction_fun_props(RscUpd, Func, Ctx)
        end,
        Context),
    update_result(Result, RscUpd, Context).

update_result({ok, NewId, notchanged}, _RscUpd, _Context) ->
    {ok, NewId};
update_result({ok, NewId, {OldProps, NewProps, OldCatList, IsCatInsert}}, #rscupd{id = Id}, Context) ->
    % Flush some low level caches
    case maps:get(<<"name">>, NewProps, undefined) of
        undefined -> nop;
        Name -> z_depcache:flush({rsc_name, z_string:to_name(Name)}, Context)
    end,
    case maps:get(<<"uri">>, NewProps, undefined) of
        undefined -> nop;
        Uri -> z_depcache:flush({rsc_uri, z_convert:to_binary(Uri)}, Context)
    end,

    % Flush category caches if a category is inserted.
    case IsCatInsert of
        true -> m_category:flush(Context);
        false -> nop
    end,

    % Flush all cached content that is depending on one of the updated categories
    z_depcache:flush(NewId, Context),
    NewCatList = m_rsc:is_a(NewId, Context),
    Cats = lists:usort(NewCatList ++ OldCatList),
    [z_depcache:flush(Cat, Context) || Cat <- Cats],

    % Notify that a new resource has been inserted, or that an existing one is updated
    Note = #rsc_update_done{
        action = case Id of insert_rsc -> insert; _ -> update end,
        id = NewId,
        pre_is_a = OldCatList,
        post_is_a = NewCatList,
        pre_props = OldProps,
        post_props = NewProps
    },
    z_notifier:notify_sync(Note, Context),
    Topic = [
        <<"model">>, <<"rsc">>, <<"event">>, NewId,
        case Id of
            insert_rsc -> <<"insert">>;
            _ -> <<"update">>
        end
    ],
    z_mqtt:publish(
        Topic,
        #{
            id => NewId,
            pre_is_a => OldCatList,
            post_is_a => NewCatList
        },
        Context),

    % Return the updated or inserted id
    {ok, NewId};
update_result({rollback, {error, _} = Er}, _RscUpd, _Context) ->
    Er;
update_result({rollback, {_Why, _} = Er}, _RscUpd, _Context) ->
    {error, Er};
update_result({error, _} = Error, _RscUpd, _Context) ->
    Error.


%% @doc This is running inside the rsc update db transaction
update_transaction_fun_props(#rscupd{id = Id} = RscUpd, Func, Context) ->
    case get_raw_lock(Id, Context) of
        {ok, Raw} ->
            update_transaction_fun_props_1(RscUpd, Raw, Func, Context);
        {error, _} = Error ->
            {rollback, Error}
    end.

update_transaction_fun_props_1(#rscupd{id = Id} = RscUpd, Raw, Func, Context) ->
    case Func(Id, Raw, Context) of
        {ok, UpdateProps} ->
            update_transaction_filter_props(RscUpd, UpdateProps, Raw, Context);
        {error, _} = Error ->
            {rollback, Error}
    end.

update_transaction_filter_props(#rscupd{id = Id} = RscUpd, UpdateProps, Raw, Context) ->
    {Edges, UpdateProps1} = split_edges(UpdateProps),
    EditableProps = props_filter_protected( props_filter( props_trim(UpdateProps1), Context), RscUpd),
    SafeProps = escape_props(RscUpd#rscupd.is_escape_texts, EditableProps, Context),
    SafeSlugProps = generate_slug(Id, SafeProps, Context),
    DefaultProps = props_defaults(Id, SafeSlugProps, Context),
    case preflight_check(Id, DefaultProps, Context) of
        ok ->
            try
                throw_if_category_not_allowed(Id, DefaultProps, RscUpd#rscupd.is_acl_check, Context),
                Result = update_transaction_fun_insert(RscUpd, DefaultProps, Raw, UpdateProps, Context),
                insert_edges(Result, Edges, Context)
            catch
                throw:{error, _} = Error -> {rollback, Error}
            end;
        {error, _} = Error ->
            {rollback, Error}
    end.

split_edges(Props) ->
    maps:fold(
        fun
            (<<"s.", Pred/binary>>, E, {Es, Ps}) ->
                Es1 = [ {subject, Pred, E}  | Es ],
                {Es1, Ps};
            (<<"o.", Pred/binary>>, E, {Es, Ps}) ->
                Es1 = [ {object, Pred, E}  | Es ],
                {Es1, Ps};
            (<<"o">>, Map, {Es, Ps}) when is_map(Map) ->
                Es1 = split_edges_map(object, Map, Es),
                {Es1, Ps};
            (<<"s">>, Map, {Es, Ps}) when is_map(Map) ->
                Es1 = split_edges_map(subject, Map, Es),
                {Es1, Ps};
            (K, V, {Es, Ps}) ->
                {Es, Ps#{ K => V }}
        end,
        {[], #{}},
        Props).

split_edges_map(What, Map, Acc) ->
    maps:fold(
        fun
            (Pred, IdOrIds, PAcc) ->
                [ {What, Pred, IdOrIds} | PAcc ]
        end,
        Acc,
        Map).

insert_edges({ok, _Id, _Res} = Result, [], _Context) ->
    Result;
insert_edges({ok, Id, _Res} = Result, Edges, Context) ->
    case z_acl:is_sudo(Context) of
        true ->
            ?LOG_ERROR(#{
                text => <<"Not allowed to insert edges during rsc update with sudo">>,
                result => error,
                error => eacces,
                in => zotonic_core,
                rsc_id => Id,
                edges => Edges
            }),
            % ignore edge insertion error
            Result;
        false ->
            lists:foreach(
                fun
                    ({_ ,<<>>, _}) ->
                        ok;
                    ({_ ,undefined, _}) ->
                        ok;
                    ({_ ,_, undefined}) ->
                        ok;
                    ({object, Pred, Es}) when is_list(Es) ->
                        Es1 = lists:filtermap(
                            fun(EId) ->
                                case m_rsc:rid(EId, Context) of
                                    undefined -> false;
                                    Rid -> {true, Rid}
                                end
                            end,
                            Es),
                        m_edge:replace(Id, Pred, Es1, Context);
                    ({object, Pred, E}) ->
                        m_edge:insert(Id, Pred, E, Context);
                    ({subject, Pred, Es}) when is_list(Es) ->
                        lists:map(
                            fun(E) -> m_edge:insert(E, Pred, Id, Context) end,
                            Es);
                    ({subject, Pred, E}) ->
                        m_edge:insert(E, Pred, Id, Context)
                end,
                Edges),
            Result
    end;
insert_edges({error, _} = Error , _, _Context) ->
    Error.

escape_props(true, Props, Context) ->
    z_sanitize:escape_props(Props, Context);
escape_props(false, Props, Context) ->
    z_sanitize:escape_props_check(Props, Context).

%% @doc Fetch the complete resource from the database, lock for subsequent updates.
%%      The resource is not filtered by the ACL as this is the basis for the update.
get_raw_lock(insert_rsc, _Context) ->
    {ok, #{}};
get_raw_lock(Id, Context) ->
    m_rsc:get_raw_lock(Id, Context).

update_transaction_fun_insert(#rscupd{id = insert_rsc} = RscUpd, Props, _Raw, UpdateProps, Context) ->
    % Allow the initial insertion props to be modified.
    CategoryId = z_convert:to_integer(maps:get(<<"category_id">>, Props)),
    InitProps = #{
        <<"version">> => 0,
        <<"category_id">> => CategoryId,
        <<"content_group_id">> => maps:get(<<"content_group_id">>, Props, undefined)
    },
    InsProps = z_notifier:foldr(#rsc_insert{ props = Props }, InitProps, Context),

    % Create dummy resource with correct creator, category and content group.
    % This resource will be updated with the other properties.
    InsertId = case maps:get(<<"creator_id">>, UpdateProps, undefined) of
                   self ->
                        {ok, InsId} = z_db:insert(
                            rsc,
                            InsProps#{ <<"creator_id">> => undefined },
                            Context),
                        1 = z_db:q("update rsc set creator_id = id where id = $1", [InsId], Context),
                        InsId;
                   CreatorId when is_integer(CreatorId) ->
                        {ok, InsId} = case z_acl:is_admin(Context) of
                            true ->
                                z_db:insert(
                                    rsc,
                                    InsProps#{ <<"creator_id">> => CreatorId },
                                    Context);
                            false ->
                                z_db:insert(
                                    rsc,
                                    InsProps#{ <<"creator_id">> => z_acl:user(Context) },
                                    Context)
                        end,
                        InsId;
                   undefined ->
                       {ok, InsId} = z_db:insert(
                            rsc,
                            InsProps#{ <<"creator_id">> => z_acl:user(Context) },
                            Context),
                       InsId
               end,

    % Insert a category record for categories. Categories are so low level that we want
    % to make sure that all categories always have a category record attached.
    IsA = m_category:is_a(CategoryId, Context),
    IsCatInsert = case lists:member(category, IsA) of
                      true ->
                          m_hierarchy:append('$category', [InsertId], Context),
                          true;
                      false ->
                          false
                  end,
     % Place the inserted properties over the update properties.
     Props1 = maps:fold(
        fun
            (<<"version">>, _V, Acc) -> Acc;
            (<<"creator_id">>, _V, Acc) -> Acc;
            (P, V, Acc) when is_binary(P) ->
                Acc#{ P => V }
        end,
        Props,
        InsProps),
    update_transaction_fun_db(RscUpd, InsertId, Props1, InsProps, [], IsCatInsert, Context);
update_transaction_fun_insert(#rscupd{id = Id} = RscUpd, Props, Raw, UpdateProps, Context) ->
    Props1 = maps:remove(<<"creator_id">>, Props),
    Props2 = case z_acl:is_admin(Context) of
                 true ->
                     case maps:get(<<"creator_id">>, UpdateProps, undefined) of
                         <<"self">> ->
                             Props#{ <<"creator_id">> => Id };
                         CreatorId when is_integer(CreatorId) ->
                             Props#{ <<"creator_id">> => CreatorId };
                         undefined ->
                             Props1
                     end;
                 false ->
                     Props1
             end,
    IsA = m_rsc:is_a(Id, Context),
    update_transaction_fun_expected(RscUpd, Id, Props2, Raw, IsA, false, Context).

update_transaction_fun_expected(#rscupd{expected = Expected} = RscUpd, Id, Props1, Raw, IsA, false,
    Context) ->
    case check_expected(Raw, Expected, Context) of
        ok ->
            update_transaction_fun_db(RscUpd, Id, Props1, Raw, IsA, false, Context);
        {error, _} = Error ->
            Error
    end.


check_expected(_Raw, [], _Context) ->
    ok;
check_expected(Raw, [{Key, F} | Es], Context) when is_function(F) ->
    case F(z_convert:to_binary(Key), Raw, Context) of
        true -> check_expected(Raw, Es, Context);
        false -> {error, {expected, Key, maps:get(Key, Raw, undefined)}}
    end;
check_expected(Raw, [{Key, Value} | Es], Context) ->
    case maps:get(z_convert:to_binary(Key), Raw, undefined) of
        Value -> check_expected(Raw, Es, Context);
        Other -> {error, {expected, Key, Other}}
    end.


update_transaction_fun_db(RscUpd, Id, Props, Raw, IsABefore, IsCatInsert, Context) ->
    {ok, Version} = maps:find(<<"version">>, Raw),
    UpdateProps = Props#{
        <<"version">> => Version + 1
    },
    IsInsert = (RscUpd#rscupd.id =:= insert_rsc),
    UpdateProps1 = set_if_normal_update(RscUpd, <<"modified">>, erlang:universaltime(), UpdateProps),
    UpdateProps2 = set_if_normal_update(RscUpd, <<"modifier_id">>, z_acl:user(Context), UpdateProps1),
    UpdResult = z_notifier:foldr(
        #rsc_update{
            action = case IsInsert of
                        true -> insert;
                        false -> update
                     end,
            id = Id,
            props = Raw
        },
        {ok, UpdateProps2},
        Context),
    update_transaction_fun_db_1(UpdResult, Id, RscUpd, Raw, IsABefore, IsCatInsert, Context).

update_transaction_fun_db_1({error, _} = Error, _Id, _RscUpd, _Raw, _IsABefore, _IsCatInsert, _Context) ->
    Error;
update_transaction_fun_db_1({ok, UpdatePropsN}, Id, RscUpd, Raw, IsABefore, IsCatInsert, Context) ->
    % Pre-pivot of the category-id to the category sequence nr.
    UpdatePropsN1 = case maps:get(<<"category_id">>, UpdatePropsN, undefined) of
                        undefined ->
                            UpdatePropsN;
                        CatId ->
                            CatNr = z_db:q1("
                                    select nr
                                    from hierarchy
                                    where id = $1
                                      and name = '$category'",
                                [CatId],
                                Context),
                            UpdatePropsN#{ <<"pivot_category_nr">> => CatNr }
                    end,

    % 1. Merge UpdatePropsN into Raw for complete view
    NewProps = maps:merge(Raw, UpdatePropsN1),

    % 2. Ensure language tag
    Langs = case maps:get(<<"language">>, NewProps, []) of
        [ _ | _ ] = Lngs -> Lngs;
        _ -> z_props:extract_languages(NewProps)
    end,
    Langs1 = case Langs of
        [] -> [ z_context:language(Context) ];
        _ -> Langs
    end,
    % Only editable languages
    Langs2 = lists:filter(
        fun(Iso) ->
            case z_language:is_language_editable(Iso, Context) of
                false ->
                    ?LOG_INFO(#{
                        text => <<"Dropping non editable language from resource">>,
                        in => zotonic_core,
                        language => Iso,
                        rsc_id => Id
                    }),
                    false;
                true ->
                    true
            end
        end,
        Langs1),
    NewPropsLang = maybe_set_langs(NewProps, Langs2),

    % 4. Prune languages
    NewPropsLangPruned = z_props:prune_languages(NewPropsLang, maps:get(<<"language">>, NewPropsLang)),

    % 5. Diff the update
    NewPropsLangPruned1 = clear_empty(NewPropsLangPruned, Context),
    NewPropsDiff = diff(NewPropsLangPruned1, Raw),

    % 6. Ensure that there is a Timezone set in the saved resource
    PropsTz = maps:get(<<"tz">>, NewPropsDiff, undefined),
    MapsTz = maps:get(<<"tz">>, Raw, undefined),
    NewPropsDiffTz = case {MapsTz, PropsTz} of
        {undefined, undefined} ->
            NewPropsDiff#{ <<"tz">> => RscUpd#rscupd.tz };
        _ ->
            NewPropsDiff
    end,

    % 7. Perform optional update, check diff
    IsInsert = (RscUpd#rscupd.id =:= insert_rsc),
    case is_update_allowed(IsInsert, Id, NewPropsLangPruned, Context) of
        true ->
            case (IsInsert orelse is_changed(Raw, NewPropsDiffTz)) of
                true ->
                    UpdatePropsPrePivoted = z_pivot_rsc:pivot_resource_update(Id, NewPropsDiffTz, Raw, Context),
                    {ok, 1} = z_db:update(rsc, Id, UpdatePropsPrePivoted, Context),
                    ok = update_page_path_log(Id, Raw, NewPropsDiffTz, Context),
                    NewPropsFinal = maps:merge(NewPropsLangPruned, UpdatePropsPrePivoted),
                    {ok, Id, {Raw, NewPropsFinal, IsABefore, IsCatInsert}};
                false ->
                    {ok, Id, notchanged}
            end;
        false ->
            {error, eacces}
    end.

%% Set all non-column fields with empty values to 'undefined'.
%% This makes the props blob smaller by removing all empty address (etc) fields.
clear_empty(Props, Context) ->
    Cols = z_db:column_names_bin(rsc, Context),
    maps:fold(
        fun
            (K, <<>>, Acc) ->
                case lists:member(K, Cols) of
                    true -> Acc#{ K => <<>> };
                    false -> Acc#{ K => undefined }
                end;
            (K, #trans{} = V, Acc) ->
                case z_utils:is_empty(V) of
                    true -> Acc#{ K => undefined };
                    false -> Acc#{ K => V }
                end;
            (K, V, Acc) ->
                Acc#{ K => V }
        end,
        #{},
        Props).


is_update_allowed(true, Id, NewProps, Context) ->
    z_acl:is_allowed(insert, #acl_rsc{ id = Id, props = NewProps }, Context);
is_update_allowed(false, Id, NewProps, Context) ->
    z_acl:is_allowed(update, #acl_rsc{ id = Id, props = NewProps }, Context).


maybe_set_langs(#{ <<"language">> := Langs } = Props, NewLangs) when is_list(Langs) ->
    case Langs of
        NewLangs -> Props;
        _ -> Props#{ <<"language">> => NewLangs }
    end;
maybe_set_langs(Props, NewLangs) ->
    Props#{ <<"language">> => NewLangs }.

%% @doc Remove everything from New that has the same value in Old.
diff(New, Old) ->
    maps:fold(
        fun(K, V, Acc) ->
            case maps:find(K, Old) of
                {ok, V} -> maps:remove(K, Acc);
                _ -> Acc
            end
        end,
        New,
        New).

set_if_normal_update(#rscupd{} = RscUpd, K, V, Props) ->
    set_if_normal_update_1(
        is_normal_update(RscUpd),
        K, V, Props).

set_if_normal_update_1(false, _K, _V, Props) ->
    Props;
set_if_normal_update_1(true, K, V, Props) ->
    Props#{ K => V }.

is_normal_update(#rscupd{is_import = true}) -> false;
is_normal_update(#rscupd{is_no_touch = true}) -> false;
is_normal_update(#rscupd{}) -> true.


%% @doc Check if the update will change the data in the database
-spec is_changed( m_rsc:props(), m_rsc:props() ) -> boolean().
is_changed(Current, Props) ->
    maps:fold(
        fun
            (_K, _V, true) ->
                true;
            (K, V, false) ->
                is_prop_changed(K, V, Current)
        end,
        false,
        Props).

is_prop_changed(<<"version">>, _V, _Current) -> false;
is_prop_changed(<<"modifier_id">>, _V, _Current) -> false;
is_prop_changed(<<"modified">>, _V, _Current) -> false;
is_prop_changed(<<"pivot_category_nr">>, _V, _Current) -> false;
is_prop_changed(Prop, Value, Current) ->
    not is_equal(Value, maps:get(Prop, Current, undefined)).

is_equal(A, A) -> true;
is_equal(_, undefined) -> false;
is_equal(undefined, _) -> false;
is_equal(A, B) -> z_utils:are_equal(A, B).


%% @doc Check if all props are acceptable. Examples are unique name, uri etc.
-spec preflight_check( insert_rsc | m_rsc:resource_id(), map(), z:context()) ->
              ok
            | {error, duplicate_name | duplicate_page_path | duplicate_uri | invalid_query}.
preflight_check(insert_rsc, Props, Context) ->
    preflight_check(-1, Props, Context);
preflight_check(Id, Props, Context) when is_integer(Id) ->
    lists:foldl(
        fun
            (_F, {error, _} = Error) ->
                Error;
            (F, ok) ->
                F(Id, Props, Context)
        end,
        ok,
        [
            fun preflight_check_name/3,
            fun preflight_check_page_path/3,
            fun preflight_check_uri/3,
            fun preflight_check_query/3
        ]).

preflight_check_name(Id, #{ <<"name">> := Name }, Context) when Name =/= undefined ->
    case z_db:q1("select count(*) from rsc where name = $1 and id <> $2", [Name, Id], Context) of
        0 ->
            ok;
        _N ->
            ?LOG_WARNING(#{
                text => <<"Trying to insert duplicate name">>,
                in => zotonic_core,
                name => Name,
                rsc_id => Id,
                result => error,
                reason => duplicate_name
            }),
            {error, duplicate_name}
    end;
preflight_check_name(_Id, _Props, _Context) ->
    ok.


preflight_check_page_path(Id, #{ <<"page_path">> := Path }, Context) when Path =/= undefined ->
    case z_db:q1("select count(*) from rsc where page_path = $1 and id <> $2", [Path, Id], Context) of
        0 ->
            ok;
        _N ->
            ?LOG_WARNING(#{
                text => <<"Trying to insert duplicate page_path">>,
                in => zotonic_core,
                result => error,
                reason => duplicate_page_path,
                rsc_id => Id,
                page_path => Path
            }),
            {error, duplicate_page_path}
    end;
preflight_check_page_path(_Id, _Props, _Context) ->
    ok.

preflight_check_uri(Id, #{ <<"uri">> := Uri }, Context) when Uri =/= undefined ->
    case z_db:q1("select count(*) from rsc where uri = $1 and id <> $2", [Uri, Id], Context) of
        0 ->
            ok;
        _N ->
            ?LOG_WARNING(#{
                text => <<"Trying to insert duplicate uri">>,
                in => zotonic_core,
                result => error,
                reason => duplicate_uri,
                rsc_id => Id,
                uri => Uri
            }),
            {error, duplicate_uri}
    end;
preflight_check_uri(_Id, _Props, _Context) ->
    ok.

preflight_check_query(_Id, #{ <<"query">> := Query }, Context) when Query =/= undefined ->
    try
        SearchContext = z_context:new( Context ),
        search_query:search(search_query:parse_query_text(z_html:unescape(Query)), SearchContext),
        ok
    catch
        _: {error, {_, _}} ->
            {error, invalid_query}
    end;
preflight_check_query(_Id, _Props, _Context) ->
    ok.


throw_if_category_not_allowed(_Id, _SafeProps, false, _Context) ->
    ok;
throw_if_category_not_allowed(insert_rsc, SafeProps, _True, Context) ->
    case maps:get(<<"category_id">>, SafeProps, undefined) of
        CatId when ?is_empty(CatId) ->
            throw({error, nocategory});
        CatId ->
            throw_if_category_not_allowed_1(undefined, SafeProps, CatId, Context)
    end;
throw_if_category_not_allowed(Id, SafeProps, _True, Context) ->
    case maps:get(<<"category_id">>, SafeProps, undefined) of
        undefined ->
            ok;
        CatId ->
            PrevCatId = z_db:q1("select category_id from rsc where id = $1", [Id], Context),
            throw_if_category_not_allowed_1(PrevCatId, SafeProps, CatId, Context)
    end.

throw_if_category_not_allowed_1(CatId, _SafeProps, CatId, _Context) ->
    ok;
throw_if_category_not_allowed_1(_PrevCatId, SafeProps, CatId, Context) ->
    CategoryName = m_category:id_to_name(CatId, Context),
    case z_acl:is_allowed(insert, #acl_rsc{category = CategoryName, props = SafeProps}, Context) of
        true -> ok;
        _False -> throw({error, eacces})
    end.


%% @doc Remove whitespace around some predefined fields
props_trim(Props) ->
    maps:map(
        fun(K, V) ->
            case is_trimmable(K, V) of
                true -> z_string:trim(V);
                false -> V
            end
        end,
        Props).


%% @doc Remove properties the user is not allowed to change and convert some other to the correct data type
props_filter(Props, Context) ->
    maps:fold(
        fun(K, V, Acc) ->
            props_filter(K, V, Acc, Context)
        end,
        #{},
        Props).

props_filter(<<"uri">>, Uri, Acc, _Context) when ?is_empty(Uri) ->
    Acc#{ <<"uri">> => undefined };
props_filter(<<"uri">>, Uri, Acc, _Context) ->
    case z_sanitize:uri(Uri) of
        <<"#script-removed">> ->
            Acc#{ <<"uri">> => undefined };
        CleanUri ->
            Acc#{ <<"uri">> => CleanUri }
    end;
props_filter(<<"name">>, Name, Acc, Context) ->
    case z_acl:is_allowed(use, mod_admin, Context) of
        true ->
            case z_utils:is_empty(Name) of
                true ->
                    Acc#{ <<"name">> => undefined };
                false ->
                    Name1 = case z_string:to_name(Name) of
                        <<"_">> -> undefined;
                        N -> N
                    end,
                    Acc#{ <<"name">> => Name1 }
            end;
        false ->
            Acc
    end;
props_filter(<<"page_path">>, Path, Acc, Context) ->
    case z_acl:is_allowed(use, mod_admin, Context) of
        true when ?is_empty(Path) ->
            Acc#{ <<"page_path">> => undefined };
        true ->
            P = iolist_to_binary([
                $/, z_string:trim(z_url:url_path_encode(Path), $/)
            ]),
            Acc#{ <<"page_path">> => P };
        false ->
            Acc
    end;
props_filter(<<"title_slug">>, Slug, Acc, _Context) when ?is_empty(Slug) ->
    Acc;
props_filter(<<"title_slug">>, Slug, Acc, Context) ->
    Slug1 = to_slug(Slug),
    SlugNoTr = z_trans:lookup_fallback(Slug1, en, Context),
    Acc#{
        <<"slug">> => z_convert:to_binary(SlugNoTr),
        <<"title_slug">> => Slug1
    };
props_filter(<<"slug">>, Slug, Acc, _Context) ->
    case maps:is_key(<<"slug">>, Acc) of
        true ->
            Acc;
        false ->
            Acc#{
                <<"slug">> => to_slug(Slug)
            }
    end;
props_filter(<<"is_", _/binary>> = B, P, Acc, _Context) ->
    Acc#{ B => z_convert:to_bool(P) };
props_filter(<<"custom_slug">> = B, P, Acc, _Context) ->
    Acc#{ B => z_convert:to_bool(P) };
props_filter(<<"date_is_all_day">> = B, P, Acc, _Context) ->
    Acc#{ B => z_convert:to_bool(P) };
props_filter(<<"seo_noindex">> = B, P, Acc, _Context) ->
    Acc#{ B => z_convert:to_bool(P) };
props_filter(P, DT, Acc, _Context)
    when P =:= <<"created">>;           P =:= <<"modified">>;
         P =:= <<"date_start">>;        P =:= <<"date_end">>;
         P =:= <<"publication_start">>; P =:= <<"publication_end">>  ->
    Acc#{
        P => z_datetime:to_datetime(DT)
    };
props_filter(P, Id, Acc, Context)
    when P =:= <<"creator_id">>;
         P =:= <<"modifier_id">> ->
    case m_rsc:rid(Id, Context) of
        undefined ->
            Acc;
        RId ->
            Acc#{ P => RId }
    end;
props_filter(<<"category">>, CatName, Acc, Context) ->
    {ok, CategoryId} = m_category:name_to_id(CatName, Context),
    Acc#{ <<"category_id">> => CategoryId };
props_filter(<<"category_id">>, CatId, Acc, Context) ->
    CatId1 = m_rsc:rid(CatId, Context),
    case m_rsc:is_a(CatId1, category, Context) of
        true ->
            Acc#{ <<"category_id">> => CatId1 };
        false ->
            ?LOG_WARNING(#{
                text => <<"Ignoring unknown category in update, using 'other' instead.">>,
                in => zotonic_core,
                category_id => CatId
            }),
            {ok, OtherId} = m_category:name_to_id(other, Context),
            Acc#{ <<"category_id">> => OtherId }
    end;
props_filter(<<"content_group">>, CG, Acc, _Context) when ?is_empty(CG) ->
    Acc#{ <<"content_group_id">> => undefined };
props_filter(<<"content_group">>, CgName, Acc, Context) ->
    props_filter(<<"content_group_id">>, CgName, Acc, Context);
props_filter(<<"content_group_id">>, CgId, Acc, _Context) when ?is_empty(CgId) ->
    Acc#{ <<"content_group_id">> => undefined };
props_filter(<<"content_group_id">>, CgId, Acc, Context) ->
    CgId1 = m_rsc:rid(CgId, Context),
    case m_rsc:is_a(CgId1, content_group, Context)
        orelse m_rsc:is_a(CgId1, acl_collaboration_group, Context)
    of
        true ->
            Acc#{ <<"content_group_id">> => CgId1 };
        false ->
            ?LOG_WARNING(#{
                text => <<"Ignoring unknown content group">>,
                in => zotonic_core,
                content_group_id => CgId
            }),
            Acc
    end;
props_filter(Location, P, Acc, _Context)
    when Location =:= <<"location_lat">>;
         Location =:= <<"location_lng">> ->
    X = try
            z_convert:to_float(P)
        catch
            _:_ -> undefined
        end,
    Acc#{ Location => X };
props_filter(<<"pref_language">>, Lang, Acc, _Context) ->
    Lang1 = case z_language:to_language_atom(Lang) of
        {ok, LangAtom} -> LangAtom;
        {error, not_a_language} -> undefined
    end,
    Acc#{ <<"pref_language">> => Lang1 };
props_filter(<<"language">>, Langs, Acc, _Context) ->
    Acc#{ <<"language">> => filter_languages(Langs) };
props_filter(<<"crop_center">>, CropCenter, Acc, _Context) when ?is_empty(CropCenter) ->
    Acc#{ <<"crop_center">> => undefined };
props_filter(<<"crop_center">>, CropCenter, Acc, _Context) ->
    CropCenter1 = case z_string:trim(CropCenter) of
        <<>> -> undefined;
        Trimmed ->
            case re:run(Trimmed, "^\\+[0-9]+\\+[0-9]+$") of
                nomatch -> undefined;
                {match, _} -> Trimmed
            end
    end,
    Acc#{ <<"crop_center">> => CropCenter1 };
props_filter(<<"privacy">>, Privacy, Acc, _Context) when ?is_empty(Privacy) ->
    Acc#{ <<"privacy">> => undefined };
props_filter(<<"privacy">>, Privacy, Acc, _Context) ->
    P = try
            z_convert:to_integer(Privacy)
        catch
            _:_ -> undefined
        end,
    Acc#{ <<"privacy">> => P };
props_filter(P, V, Acc, _Context) ->
    Acc#{ P => V }.


%% Filter all given languages, drop unknown languages.
%% Ensure that the languages are a list of atoms.
filter_languages([]) -> [];
filter_languages(<<>>) -> [];
filter_languages(Lang) when is_binary(Lang); is_atom(Lang) ->
    filter_languages([Lang]);
filter_languages([C | _] = Lang) when is_integer(C) ->
    filter_languages([Lang]);
filter_languages([L | _] = Langs) when is_list(L); is_binary(L); is_atom(L) ->
    Langs1 = lists:foldl(
        fun(Lang, Acc) ->
            case z_language:to_language_atom(Lang) of
                {ok, LangAtom} -> [LangAtom | Acc];
                {error, not_a_language} -> Acc
            end
        end,
        [],
        Langs),
    lists:sort(Langs1).

%% @doc If title is updating, check the rsc 'custom_slug' field to see if we need to update the slug or not.
generate_slug(Id, Props, Context) ->
    case maps:find(<<"title">>, Props) of
         {ok, Title} ->
            case {maps:get(<<"custom_slug">>, Props, false), m_rsc:p(Id, <<"custom_slug">>, Context)} of
                 {true, _} -> Props;
                 {_, true} -> Props;
                 _X ->
                    %% Determine the slug from the title.
                    Slug = to_slug(Title),
                    SlugNoTr = z_trans:lookup_fallback(Slug, en, Context),
                    Props#{
                        <<"slug">> => z_convert:to_binary(SlugNoTr),
                        <<"title_slug">> => Slug
                    }
            end;
         error ->
            Props
    end.


%% @doc Fill in some defaults for empty props on insert.
props_defaults(Props, Context) ->
    % Generate slug from the title (when there is a title)
    Props1 = case maps:find(<<"title_slug">>, Props) of
        error ->
            case maps:find(<<"title">>, Props) of
                {ok, Title} ->
                    Slug = to_slug(Title),
                    SlugNoTr = z_trans:lookup_fallback(Slug, en, Context),
                    Props#{
                        <<"slug">> => z_convert:to_binary(SlugNoTr),
                        <<"title_slug">> => Slug
                    };
                error ->
                    Props
            end;
        {ok, undefined} ->
            Props#{
                <<"title_slug">> => <<"-">>
            };
        {ok, _} ->
            Props
    end,
    % Assume content is authoritative, unless stated otherwise
    case maps:get(<<"is_authoritative">>, Props1, undefined) of
        undefined -> Props1#{ <<"is_authoritative">> => true };
        _ -> Props
    end.

%% @doc Set default properties on resource insert and update.
-spec props_defaults(m_rsc:resource(), m_rsc:properties(), z:context()) -> m_rsc:properties().
props_defaults(_Id, Props, Context) ->
    lists:foldl(
        fun(Key, Acc) ->
            prop_default(Key, maps:get(Key, Props, undefined), Acc, Context)
        end,
        Props,
        [ <<"publication_start">> ]
    ).

-spec prop_default(binary(), any(), m_rsc:properties(), z:context()) -> m_rsc:properties().
prop_default(<<"publication_start">>, undefined, Props, _Context) ->
    case maps:get(<<"is_published">>, Props, false) of
        true ->
            Props#{ <<"publication_start">> => erlang:universaltime() };
        _ ->
            Props
    end;
prop_default(_Key, _Value, Props, _Context) ->
    Props.

props_filter_protected(Props, RscUpd) ->
    IsNormalUpdate = is_normal_update(RscUpd),
    maps:filter(
        fun (K, _) -> not is_protected(K, IsNormalUpdate) end,
        Props).


to_slug(undefined) ->
    <<>>;
to_slug(#trans{ tr = Tr }) ->
    Tr1 = lists:map(
        fun({Lang, V}) -> {Lang, to_slug(V)} end,
        Tr),
    #trans{ tr = Tr1 };
to_slug(B) when is_binary(B) ->
    B1 = z_string:to_lower(z_html:unescape(B)),
    truncate_slug(slugify(B1, false, <<>>));
to_slug(X) ->
    to_slug(z_convert:to_binary(X)).

truncate_slug(Slug) ->
    z_string:truncate(Slug, 70, <<>>).

slugify(<<>>, _Last, Acc) ->
    Acc;
slugify(<<C/utf8, T/binary>>, $-, Acc) ->
    case is_slugchar(C) of
        false -> slugify(T, $-, Acc);
        true when Acc =:= <<>> -> slugify(T, false, <<C/utf8>>);
        true -> slugify(T, false, <<Acc/binary, $-, C/utf8>>)
    end;
slugify(<<C/utf8, T/binary>>, false, Acc) ->
    case is_slugchar(C) of
        false -> slugify(T, $-, Acc);
        true -> slugify(T, false, <<Acc/binary, C/utf8>>)
    end.

is_slugchar(C) when C =< 32 -> false;
is_slugchar(254) -> false;
is_slugchar(255) -> false;
is_slugchar(C) when C > 128 -> true;
is_slugchar(C) -> z_url:url_unreserved_char(C).


%% @doc Properties that can't be updated with m_rsc_update:update/3 or m_rsc_update:insert/2
is_protected(<<"id">>, _IsNormal) -> true;
is_protected(<<"created">>, true) -> true;
is_protected(<<"creator_id">>, true) -> true;
is_protected(<<"modified">>, true) -> true;
is_protected(<<"modifier_id">>, true) -> true;
is_protected(<<"props">>, _IsNormal) -> true;
is_protected(<<"version">>, _IsNormal) -> true;
is_protected(<<"short_url">>, _IsNormal) -> true;
is_protected(<<"page_url">>, _IsNormal) -> true;
is_protected(<<"page_url_abs">>, _IsNormal) -> true;
is_protected(<<"alternate_page_url">>, _IsNormal) -> true;
is_protected(<<"alternate_page_url_abs">>, _IsNormal) -> true;
is_protected(<<"medium">>, _IsNormal) -> true;
is_protected(<<"pivot_", _binary>>, _IsNormal) -> true;
is_protected(<<"computed_", _/binary>>, _IsNormal) -> true;
is_protected(<<"*", _/binary>>, _IsNormal) -> true;
is_protected(_, _IsNormal) -> false.

is_trimmable(_, V) when not is_binary(V) -> false;
is_trimmable(<<"title">>, _) -> true;
is_trimmable(<<"title_short">>, _) -> true;
is_trimmable(<<"summary">>, _) -> true;
is_trimmable(<<"chapeau">>, _) -> true;
is_trimmable(<<"subtitle">>, _) -> true;
is_trimmable(<<"email">>, _) -> true;
is_trimmable(<<"uri">>, _) -> true;
is_trimmable(<<"website">>, _) -> true;
is_trimmable(<<"page_path">>, _) -> true;
is_trimmable(<<"name">>, _) -> true;
is_trimmable(<<"slug">>, _) -> true;
is_trimmable(<<"category">>, _) -> true;
is_trimmable(<<"rsc_id">>, _) -> true;
is_trimmable(_, _) -> false.


update_page_path_log(RscId, OldProps, NewProps, Context) ->
    Old = maps:get(<<"page_path">>, OldProps, undefined),
    New = maps:get(<<"page_path">>, NewProps, not_updated),
    case {Old, New} of
        {_, not_updated} ->
            ok;
        {Old, Old} ->
            %% not changed
            ok;
        {undefined, _} ->
            %% no old page path
            ok;
        {Old, New} ->
            %% update
            z_db:q("DELETE FROM rsc_page_path_log WHERE page_path = $1 OR page_path = $2", [New, Old],
                Context),
            z_db:q("INSERT INTO rsc_page_path_log(id, page_path) VALUES ($1, $2)", [RscId, Old], Context),
            ok
    end.