%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2009-2026 Marc Worrell, Arjan Scherpenisse
%% @doc Update routines for resources. For use by the m_rsc module.
%% @end
%% Copyright 2009-2026 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).
-moduledoc("
Resource update helper module used by `m_rsc`.
This module contains insert/update/delete/merge routines for resources and is logically part of the `m_rsc` model implementation.
It is not exposed as a standalone model path endpoint.
").
-author("Marc Worrell <marc@worrell.nl").
%% interface functions
-export([
insert/2,
insert/3,
delete/2,
delete/3,
update/3,
update/4,
update_translation/4,
update_translation/5,
duplicate/3,
duplicate/4,
merge_delete/4,
flush/2,
delete_nocheck/2,
to_slug/1,
normalize_page_path/1
]).
-include_lib("zotonic.hrl").
-record(rscupd, {
id = undefined :: m_rsc:resource_id() | insert_rsc,
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)).
%% Minimum amount of characters for a language detect on the basis of texts.
-define(LANGUAGE_DETECT_THRESHOLD, 10).
%% @doc Insert a new resource. Crashes when insertion is not allowed.
-spec insert(Props, Context) -> {ok, ResourceId} | {error, term()} when
Props :: m_rsc:props_all(),
Context :: z:context(),
ResourceId :: m_rsc:resource_id().
insert(Props, Context) ->
insert(Props, [{is_escape_texts, true}], Context).
-spec insert(Props, Options, Context) -> {ok, ResourceId} | {error, term()} when
Props :: m_rsc:props_all(),
Options :: m_rsc:update_options(),
Context :: z:context(),
ResourceId :: m_rsc:resource_id().
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, Options, Context),
update(insert_rsc, PropsDefaults, Options, Context).
%% @doc Delete a resource.
-spec delete(Id, Context) -> ok | {error, atom()} when
Id :: m_rsc:resource(),
Context :: z:context().
delete(Id, Context) ->
delete(Id, undefined, Context).
%% @doc Delete a resource, optionally set a resource that 'replaces' the
%% the deleted resources. Requests for the deleted resource will be
%% redirected to the follow-up resource.
-spec delete(Id, FollowUpId, Context) -> ok | {error, atom()} when
Id :: m_rsc:resource(),
FollowUpId :: m_rsc:resource(),
Context :: z:context().
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(Id, Context) -> ok | {error, atom()} when
Id :: m_rsc:resource(),
Context :: z:context().
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, update the winner, delete the loser. The losing resource is
%% deleted with the winning one set as the follow-up resource.
-spec merge_delete(WinnerId, LoserId, Options, Context) -> ok | {error, term()} when
WinnerId :: m_rsc:resource(),
LoserId :: m_rsc:resource(),
Options :: list(),
Context :: #context{}.
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(WinnerId, LoserId, Options, Context) -> ok when
WinnerId :: integer(),
LoserId :: integer(),
Options :: list(),
Context :: #context{}.
merge_delete_nocheck(WinnerId, LoserId, Opts, Context) ->
IsMergeTrans = proplists:get_value(is_merge_trans, Opts, false),
z_notifier:notify_sync(#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, [{is_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) ->
RscId = m_rsc:rid(Id, Context),
CatList = m_rsc:is_a(RscId, Context),
flush(RscId, CatList, Context).
flush(Id, CatList, Context) ->
RscId = m_rsc:rid(Id, Context),
z_depcache:flush(RscId, Context),
[z_depcache:flush(Cat, Context) || Cat <- CatList],
z_acl:flush(RscId),
ok.
%% @doc Duplicate a resource, creating a new resource with the given title.
-spec duplicate(Id, Props, Context) -> {ok, NewId} | {error, term()} when
Id :: m_rsc:resource(),
Props :: m_rsc:props_all(),
Context :: z:context(),
NewId :: m_rsc:resource_id().
duplicate(Id, DupProps, Context) ->
duplicate(Id, DupProps, [], Context).
-spec duplicate(Id, Props, Options, Context) -> {ok, NewId} | {error, term()} when
Id :: m_rsc:resource(),
Props :: m_rsc:props_all(),
Options :: m_rsc:duplicate_options(),
Context :: z:context(),
NewId :: m_rsc:resource_id().
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),
% Convert date in DupProps to UTC, determine timezone
Tz = timezone(Id, SafeDupProps, DupOpts, Context),
IsAllDay = case maps:get(<<"date_is_all_day">>, DupProps, undefined) of
undefined -> maps:get(<<"date_is_all_day">>, RawProps, false);
DupIsAllDay -> DupIsAllDay
end,
SafeDupProps1 = z_props:normalize_dates(SafeDupProps, z_convert:to_bool(IsAllDay), Tz),
InsProps = maps:fold(
fun(Key, Value, Acc) ->
Acc#{ Key => Value }
end,
FilteredProps,
SafeDupProps1#{
<<"name">> => undefined,
<<"uri">> => undefined,
<<"page_path">> => undefined,
<<"is_authoritative">> => true,
<<"is_protected">> => false
}),
InsOpts = [
{is_escape_texts, false},
{is_import, true},
{default_tz, <<"UTC">>}
],
case insert(InsProps, InsOpts, 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 using default options.
-spec update(IdOrInsert, PropsOrFun, Context) -> {ok, UpdatedId} | {error, term()} when
IdOrInsert :: m_rsc:resource() | insert_rsc,
PropsOrFun :: m_rsc:props_all() | m_rsc:update_function(),
Context :: z:context(),
UpdatedId :: m_rsc:resource_id().
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 - forced timezone for date conversions
%% default_tz - timezone if no other timezone in update or options, this
%% timezone is selected above the resource timezone.
%% 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(IdOrInsert, PropsOrFun, Options, Context) -> {ok, UpdatedId} | {error, term()} when
IdOrInsert :: m_rsc:resource() | insert_rsc,
PropsOrFun :: m_rsc:props_all() | m_rsc:update_function(),
Options :: m_rsc:update_options() | boolean(),
Context :: z:context(),
UpdatedId :: m_rsc:resource_id().
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(Name, PropsOrFun, Options, Context) when not is_integer(Name), Name =/= insert_rsc ->
case m_rsc:name_to_id(Name, Context) of
{ok, Id} ->
update(Id, PropsOrFun, Options, Context);
{error, _} ->
case m_rsc:rid(Name, Context) of
undefined -> {error, enoent};
Id -> update(Id, PropsOrFun, Options, Context)
end
end;
update(Id, Props, Options, Context) when is_list(Props) ->
{ok, PropsMap} = z_props:from_list(Props),
OptionsTz = case timezone(Id, PropsMap, Options, Context) of
undefined -> Options;
Tz -> [ {tz, Tz} | proplists:delete(tz, Options) ]
end,
update_1(Id, PropsMap, OptionsTz, Context);
update(Id, PropsOrFun0, Options, Context) when is_integer(Id); Id =:= insert_rsc ->
PropsOrFun = binary_keys(PropsOrFun0),
update_1(Id, PropsOrFun, Options, Context).
%% @doc Update a specific language translation of (possibly nested) properties.
%% The incoming values are escaped/sanitized before merge and the resulting
%% update is sent to update/4 with the option {is_escape_texts, false}.
-spec update_translation(Id, Language, Props, Context) -> {ok, UpdatedId} | {error, term()} when
Id :: m_rsc:resource(),
Language :: z_language:language(),
Props :: m_rsc:props_all(),
Context :: z:context(),
UpdatedId :: m_rsc:resource_id().
update_translation(Id, Language, Props, Context) ->
update_translation(Id, Language, Props, [], Context).
%% @doc Like update_translation/4, with extra update options.
%% For nested values the top-level property is merged from raw resource
%% properties. For blocks, merge is by block name while preserving order
%% and disallowing add/remove/reorder of blocks.
-spec update_translation(Id, Language, Props, Options, Context) -> {ok, UpdatedId} | {error, term()} when
Id :: m_rsc:resource(),
Language :: z_language:language(),
Props :: m_rsc:props_all(),
Options :: m_rsc:update_options(),
Context :: z:context(),
UpdatedId :: m_rsc:resource_id().
update_translation(Name, Language, Props, Options, Context) when not is_integer(Name) ->
case m_rsc:name_to_id(Name, Context) of
{ok, Id} ->
update_translation(Id, Language, Props, Options, Context);
{error, _} ->
case m_rsc:rid(Name, Context) of
undefined -> {error, enoent};
Id -> update_translation(Id, Language, Props, Options, Context)
end
end;
update_translation(Id, Language, Props, Options, Context) when is_list(Props) ->
{ok, PropsMap} = z_props:from_list(Props),
update_translation(Id, Language, PropsMap, Options, Context);
update_translation(Id, Language, Props, Options, Context) when is_integer(Id), is_map(Props) ->
case z_language:to_language_atom(Language) of
{ok, LanguageAtom} ->
PropsMap = z_props:from_map(Props),
SafeProps = z_sanitize:escape_props(PropsMap, Context),
update_translation_1(Id, LanguageAtom, SafeProps, Options, Context);
{error, _} = Error ->
Error
end.
update_translation_1(Id, Language, SafeProps, Options, Context) ->
case m_rsc:get_raw(Id, Context) of
{ok, Raw} ->
{OptionsExpected, IsSetExpected} = maybe_set_expected_version_option(Raw, Options),
Merge = update_translation_merge_props(Raw, SafeProps, Language, Context),
UpdateOptions = [ {is_escape_texts, false} | proplists:delete(is_escape_texts, proplists:delete(escape_texts, OptionsExpected)) ],
case update(Id, Merge, UpdateOptions, Context) of
{error, {expected, _, _}} when IsSetExpected ->
update_translation_retry(Id, Language, SafeProps, Options, Context);
Result ->
Result
end;
{error, _} = Error ->
Error
end.
update_translation_retry(Id, Language, SafeProps, Options, Context) ->
case m_rsc:get_raw(Id, Context) of
{ok, Raw} ->
{OptionsExpected, _} = maybe_set_expected_version_option(Raw, Options),
Merge = update_translation_merge_props(Raw, SafeProps, Language, Context),
UpdateOptions = [ {is_escape_texts, false} | proplists:delete(is_escape_texts, proplists:delete(escape_texts, OptionsExpected)) ],
update(Id, Merge, UpdateOptions, Context);
{error, _} = Error ->
Error
end.
maybe_set_expected_version_option(Raw, Options) ->
case proplists:get_value(expected, Options, undefined) of
undefined ->
Version = maps:get(<<"version">>, Raw),
{[ {expected, [ {<<"version">>, Version} ]} | Options ], true};
_ ->
{Options, false}
end.
update_translation_merge_props(Raw, Props, Language, Context) ->
RawLanguages = maps:get(<<"language">>, Raw, []),
Languages = case lists:member(Language, RawLanguages) of
true -> RawLanguages;
false -> lists:usort([ Language | RawLanguages ])
end,
Props1 = maps:remove(<<"language">>, Props),
MergedTop = lists:foldl(
fun(Key, Acc) ->
Existing = maps:get(Key, Raw, undefined),
Incoming = maps:get(Key, Props1),
Value = update_translation_merge_value(
[ Key ],
Existing,
Incoming,
Language,
Languages,
Context),
Acc#{ Key => Value }
end,
#{},
maps:keys(Props1)),
MergedTop#{ <<"language">> => Languages }.
update_translation_merge_value([ <<"blocks">> ], Existing, Incoming, Language, Languages, Context) when is_map(Incoming); is_list(Incoming) ->
update_translation_merge_blocks(Existing, Incoming, Language, Languages, Context);
update_translation_merge_value(Path, Existing, Incoming, Language, Languages, Context) when is_map(Incoming) ->
Base = case is_map(Existing) of
true -> Existing;
false -> #{}
end,
maps:fold(
fun(K, V, Acc) ->
ExistingValue = maps:get(K, Acc, undefined),
Value = update_translation_merge_value(
[ K | Path ],
ExistingValue,
V,
Language,
Languages,
Context),
Acc#{ K => Value }
end,
Base,
Incoming);
update_translation_merge_value(Path, Existing, Incoming, Language, Languages, Context) when is_list(Incoming) ->
ExistingList = case is_list(Existing) of
true -> Existing;
false when Existing =:= <<>> -> [];
false when Existing =:= undefined -> [];
false -> [ Existing ]
end,
update_translation_merge_list(Path, ExistingList, Incoming, Language, Languages, Context);
update_translation_merge_value(_Path, #trans{} = Existing, Incoming, Language, Languages, _Context) ->
Value = update_translation_incoming_value(Incoming, Language),
update_translation_set_trans(Existing, Language, Value, Languages, false);
update_translation_merge_value(Path, Existing, Incoming, Language, Languages, _Context) ->
case update_translation_is_translatable(Path, Existing, Incoming) of
true ->
BaseLanguage = case Languages of
[ L | _ ] -> L;
[] -> Language
end,
Trans0 = update_translation_init_trans(Existing, BaseLanguage, Languages),
Value = update_translation_incoming_value(Incoming, Language),
update_translation_set_trans(Trans0, Language, Value, Languages, true);
false ->
Incoming
end.
update_translation_merge_list(_Path, Existing, [], _Language, _Languages, _Context) ->
Existing;
update_translation_merge_list(Path, [ E | Es ], [ I | Is ], Language, Languages, Context) ->
[ update_translation_merge_value(Path, E, I, Language, Languages, Context)
| update_translation_merge_list(Path, Es, Is, Language, Languages, Context)
];
update_translation_merge_list(Path, [], [ I | Is ], Language, Languages, Context) ->
[ update_translation_merge_value(Path, undefined, I, Language, Languages, Context)
| update_translation_merge_list(Path, [], Is, Language, Languages, Context)
].
update_translation_merge_blocks(ExistingBlocks, IncomingBlocks, Language, Languages, Context)
when is_list(ExistingBlocks) ->
IncomingByName = update_translation_blocks_by_name(IncomingBlocks),
update_translation_log_missing_blocks(ExistingBlocks, IncomingByName),
lists:map(
fun
(#{ <<"name">> := Name } = Block) ->
case maps:get(Name, IncomingByName, undefined) of
undefined ->
Block;
Incoming when is_map(Incoming) ->
Block1 = update_translation_merge_value(
[ Name, <<"blocks">> ],
Block,
Incoming,
Language,
Languages,
Context),
Block1#{
<<"name">> => maps:get(<<"name">>, Block),
<<"type">> => maps:get(<<"type">>, Block, maps:get(<<"type">>, Block1, undefined))
};
_ ->
Block
end;
(Block) ->
Block
end,
ExistingBlocks);
update_translation_merge_blocks(ExistingBlocks, _IncomingBlocks, _Language, _Languages, _Context) ->
ExistingBlocks.
update_translation_log_missing_blocks(ExistingBlocks, IncomingByName) ->
ExistingNames = maps:from_list(
lists:filtermap(
fun
(#{ <<"name">> := Name }) when is_binary(Name) -> {true, {Name, true}};
(_) -> false
end,
ExistingBlocks)),
lists:foreach(
fun({Name, _Block}) ->
case maps:is_key(Name, ExistingNames) of
true ->
ok;
false ->
?LOG_NOTICE(#{
in => zotonic_core,
text => <<"Ignoring translation update for non-existing block">>,
block_name => Name
}),
ok
end
end,
maps:to_list(IncomingByName)).
update_translation_blocks_by_name(Blocks) when is_list(Blocks) ->
lists:foldl(
fun
(#{ <<"name">> := Name } = Block, Acc) ->
Acc#{ Name => Block };
(_, Acc) ->
Acc
end,
#{},
Blocks);
update_translation_blocks_by_name(Blocks) when is_map(Blocks) ->
maps:fold(
fun
(Name, Block, Acc) when is_binary(Name), is_map(Block) ->
Acc#{ Name => Block#{ <<"name">> => Name } };
(_, _, Acc) ->
Acc
end,
#{},
Blocks);
update_translation_blocks_by_name(_) ->
#{}.
update_translation_is_translatable(Path, Existing, Incoming) ->
case update_translation_is_clear_typed(Path, Existing, Incoming) of
true -> false;
false ->
update_translation_is_text_like(Existing) orelse update_translation_is_text_like(Incoming)
end.
update_translation_is_clear_typed(Path, Existing, Incoming) ->
update_translation_is_clear_type_value(Existing)
orelse update_translation_is_clear_type_value(Incoming)
orelse not update_translation_is_translatable_type_key(Path).
update_translation_is_clear_type_value(undefined) -> false;
update_translation_is_clear_type_value(#trans{}) -> false;
update_translation_is_clear_type_value(V) when is_binary(V) -> false;
update_translation_is_clear_type_value([ C | _ ]) when is_integer(C) -> false;
update_translation_is_clear_type_value(V) when is_map(V); is_list(V) -> true;
update_translation_is_clear_type_value(V) when is_boolean(V); is_integer(V); is_float(V) -> true;
update_translation_is_clear_type_value(V) when is_atom(V) -> true;
update_translation_is_clear_type_value(_) -> false.
update_translation_is_translatable_type_key([ Key | _ ]) ->
case z_props:property_name_type_hint(Key) of
text -> true;
html -> true;
undefined -> true;
_ -> false
end.
update_translation_is_text_like(#trans{}) -> true;
update_translation_is_text_like(V) when is_binary(V) -> true;
update_translation_is_text_like([ C | _ ]) when is_integer(C) -> true;
update_translation_is_text_like(_) -> false.
update_translation_incoming_value(#trans{ tr = Tr }, Language) ->
proplists:get_value(Language, Tr, <<>>);
update_translation_incoming_value(undefined, _Language) ->
<<>>;
update_translation_incoming_value([ C | _ ] = Text, _Language) when is_integer(C) ->
unicode_characters_to_binary(Text);
update_translation_incoming_value(Value, _Language) ->
z_convert:to_binary(Value).
update_translation_init_trans(#trans{} = Trans, _BaseLanguage, _Languages) ->
Trans;
update_translation_init_trans(Existing, BaseLanguage, Languages) ->
Tr0 = [ {Lang, <<>>} || Lang <- Languages ],
Tr1 = case Existing of
V when is_binary(V) ->
[ {BaseLanguage, V} | proplists:delete(BaseLanguage, Tr0) ];
[ C | _ ] = Text when is_integer(C) ->
[ {BaseLanguage, unicode_characters_to_binary(Text)} | proplists:delete(BaseLanguage, Tr0) ];
_ ->
Tr0
end,
#trans{ tr = Tr1 }.
update_translation_set_trans(#trans{ tr = Tr0 }, Language, Value, Languages, IsEnsureAllLanguages) ->
Tr1 = case IsEnsureAllLanguages of
true -> update_translation_ensure_langs(Tr0, Languages);
false -> Tr0
end,
Value1 = update_translation_incoming_value(Value, Language),
#trans{ tr = [ {Language, Value1} | proplists:delete(Language, Tr1) ] }.
update_translation_ensure_langs(Tr, []) ->
Tr;
update_translation_ensure_langs(Tr, [ Lang | Rest ]) ->
Tr1 = case proplists:is_defined(Lang, Tr) of
true -> Tr;
false -> [ {Lang, <<>>} | Tr ]
end,
update_translation_ensure_langs(Tr1, Rest).
%% @doc Determine the timezone for date conversions, if it is not given in the options.
%% Order:
%% 1. 'tz' option in options
%% 2. 'tz' value in update props
%% 3. 'default_tz' option in options
%% 4. 'tz' property of resource
%% 5. timezone of context.
timezone(Id, PropsMap, Options, Context) ->
case proplists:lookup(tz, Options) of
{tz, Tz} when Tz =/= <<>>, Tz =/= undefined ->
% Timezone forced in the update options
Tz;
_ ->
timezone_1(Id, PropsMap, Options, Context)
end.
%% @doc Determine the timezone for date conversions, if it is not given in the options.
timezone_1(_Id, #{ <<"tz">> := Tz }, _Options, _Context) when Tz =/= undefined, Tz =/= <<>> ->
% Timezone specified in the update.
Tz;
timezone_1(Id, _PropsMap, Options, Context) ->
case proplists:lookup(default_tz, Options) of
{default_tz, Tz} when Tz =/= <<>>, Tz =/= undefined ->
Tz;
_ ->
timezone_2(Id, Context)
end.
timezone_2(Id, Context) when is_integer(Id) ->
% Assume a resource is edited in its own timezone, if the timezone is not
% part of the update or update options.
case m_rsc:p_no_acl(Id, <<"tz">>, Context) of
None when None =:= undefined; None =:= <<>> ->
% Assume requestor's timezone.
z_context:tz(Context);
Tz ->
Tz
end;
timezone_2(_Id, Context) ->
% Assume the timezone of the request context -- when inserting new resources
% we do assume the requestor's timezone.
z_context:tz(Context).
update_1(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(Id, PropsOrFun, Options, Context);
_ ->
% Take timezone from options or request
timezone(undefined, #{}, Options, Context)
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).
binary_keys(Map) when is_map(Map) ->
z_props:from_map(Map);
binary_keys(Fun) ->
Fun.
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) ->
z_acl:flush(NewId),
{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,
z_acl:flush(NewId),
% 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),
% If a new or updated resource becomes dependent, then schedule a check
% if it is connected. If not then it should be deleted.
case maps:get(<<"is_dependent">>, NewProps, false) of
true ->
case maps:get(<<"is_dependent">>, OldProps, false) of
true -> ok;
false -> z_edge_log_server:maybe_schedule_dependent_check(NewId, Context)
end;
false ->
ok
end,
% 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, {expected, _, _}} = Error, #rscupd{ id = Id }, Context) ->
% We might have an out-of-date cached value, flush caches to force
% an update from the database.
z_depcache:flush_process_dict(),
flush(Id, Context),
Error;
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), RscUpd, Context), RscUpd),
SafeProps = escape_props(RscUpd#rscupd.is_escape_texts, EditableProps, Context),
SafeSlugProps = generate_slug(Id, SafeProps, Context),
case preflight_check(Id, SafeSlugProps, Context) of
ok ->
try
throw_if_category_not_allowed(Id, SafeSlugProps, RscUpd#rscupd.is_acl_check, Context),
Result = update_transaction_fun_insert(RscUpd, SafeSlugProps, 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)),
InitProps0 = #{
<<"version">> => 0,
<<"category_id">> => CategoryId,
<<"content_group_id">> => maps:get(<<"content_group_id">>, Props, undefined),
<<"is_published">> => false,
<<"publication_start">> => undefined
},
InitProps = case z_convert:to_integer(maps:get(<<"visible_for">>, Props, undefined)) of
undefined -> InitProps0;
VisFor -> InitProps0#{ <<"visible_for">> => VisFor }
end,
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 when Self =:= <<"self">>; Self =:= 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;
(<<"is_published">>, _V, Acc) -> Acc;
(<<"publication_start">>, _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 when Self =:= <<"self">>; Self =:= 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
[] ->
% If no language, but medium_language is defined, then assume
% content is also in the medium language
case maps:get(<<"medium_language">>, NewProps, undefined) of
undefined -> detect_language(NewProps, Context);
<<>> -> detect_language(NewProps, Context);
MLang -> [ MLang ]
end;
_ ->
Langs
end,
% Only editable languages
Langs2 = lists:filtermap(
fun(Lang) ->
case z_language:to_language_atom(Lang) of
{ok, 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, Iso}
end;
{error, not_a_language} ->
false
end
end,
Langs1),
% Ensure there is always a language
Langs3 = case Langs2 of
[] -> [ z_context:language(Context ) ];
_ -> Langs2
end,
NewPropsLang = maybe_set_langs(NewProps, Langs3),
% 4. Prune languages
NewPropsLangPruned = z_props:prune_languages(NewPropsLang, maps:get(<<"language">>, NewPropsLang)),
% 5. Diff the update
NewPropsLangPruned1 = set_forced_props(Id, 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. Ensure that the publication_start is set if the is_published flag is set
IsPublished = case NewPropsDiffTz of
#{ <<"is_published">> := IsPub } ->
IsPub;
_ ->
case Raw of
#{ <<"is_published">> := IsPub } -> IsPub;
_ -> false
end
end,
HasPubStart = case NewPropsDiffTz of
#{ <<"publication_start">> := {_, _} } ->
true;
#{ <<"publication_start">> := undefined } ->
false;
_ ->
case Raw of
#{ <<"publication_start">> := {_, _} } -> true;
_ -> false
end
end,
NewPropsDiffPub = if
IsPublished and not HasPubStart ->
NewPropsDiffTz#{
<<"publication_start">> => calendar:universal_time()
};
true ->
NewPropsDiffTz
end,
% 8. Perform optional update, check diff
IsInsert = (RscUpd#rscupd.id =:= insert_rsc),
case RscUpd#rscupd.is_acl_check =:= false
orelse is_update_allowed(IsInsert, Id, NewPropsLangPruned, Context)
of
true ->
case (IsInsert orelse is_changed(Raw, NewPropsDiffPub)) of
true ->
UpdatePropsPrePivoted = z_pivot_rsc:pivot_resource_update(Id, NewPropsDiffPub, Raw, Context),
case z_db:update(rsc, Id, UpdatePropsPrePivoted, Context) of
{ok, 1} ->
ok = update_page_path_log(Id, Raw, NewPropsDiffPub, Context),
NewPropsFinal = maps:merge(NewPropsLangPruned, UpdatePropsPrePivoted),
{ok, Id, {Raw, NewPropsFinal, IsABefore, IsCatInsert}};
{error, _} = Error ->
Error
end;
false ->
{ok, Id, notchanged}
end;
false ->
{error, eacces}
end.
detect_language(NewProps, Context) ->
Text = z_html:unescape(z_html:strip(extract_text(NewProps))),
case z_string:trim(Text) of
Text1 when size(Text1) > ?LANGUAGE_DETECT_THRESHOLD ->
case z_notifier:first(#language_detect{ text = Text1 }, Context) of
undefined ->
[ z_context:language(Context) ];
Language ->
[ Language ]
end;
_ ->
[ z_context:language(Context) ]
end.
extract_text(NewProps) ->
Texts = [
extract_text_prop(<<"title">>, NewProps),
extract_text_prop(<<"chapeau">>, NewProps),
extract_text_prop(<<"subtitle">>, NewProps),
extract_text_prop(<<"summary">>, NewProps),
extract_text_prop(<<"body">>, NewProps)
],
Texts1 = [ T || T <- Texts, T =/= <<>> ],
iolist_to_binary(lists:join(32, Texts1)).
extract_text_prop(K, Props) ->
case maps:get(K, Props, undefined) of
T when is_binary(T) -> T;
_ -> <<>>
end.
%% @doc Some forced props, depending on the resource being updated.
set_forced_props(1, Props) ->
Props#{
<<"is_protected">> => true
};
set_forced_props(_Id, Props) ->
Props.
%% 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.
%% @doc Preflight checks are done after the page_path is normalized.
preflight_check_page_path(Id, #{ <<"page_path">> := PagePath }, Context) ->
case page_paths(PagePath) of
[] ->
ok;
Paths ->
case z_db:q1("
select count(*) from rsc
where pivot_page_path && $1
and id <> $2", [ Paths, 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 => Paths
}),
{error, duplicate_page_path}
end
end;
preflight_check_page_path(_Id, _Props, _Context) ->
ok.
page_paths(undefined) -> [];
page_paths(<<>>) -> [];
page_paths(B) when is_binary(B) -> [B];
page_paths(#trans{ tr = Tr }) -> lists:usort([ T || {_, T} <- Tr, T =/= <<>> ]).
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(z_search_props:from_text(z_html:unescape(Query)), SearchContext),
ok
catch
_:Reason:Stack ->
?LOG_WARNING(#{
in => zotonic_core,
text => <<"Error in preflight test of query text">>,
rsc_id => Id,
result => error,
reason => Reason,
stack => Stack,
query => Query
}),
{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, RscUpd, Context) ->
maps:fold(
fun(K, V, Acc) ->
props_filter(K, V, Acc, RscUpd, Context)
end,
#{},
Props).
props_filter(<<"uri">>, Uri, Acc, _RscUpd, _Context) when ?is_empty(Uri) ->
Acc#{ <<"uri">> => undefined };
props_filter(<<"uri">>, Uri, Acc, _RscUpd, _Context) ->
case z_sanitize:uri(Uri) of
<<"#script-removed">> ->
Acc#{ <<"uri">> => undefined };
CleanUri ->
Acc#{ <<"uri">> => CleanUri }
end;
props_filter(<<"name">>, Name, Acc, _RscUpd, 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, RscUpd, Context) ->
case z_acl:is_allowed(use, mod_admin, Context) of
true when ?is_empty(Path) ->
Acc#{ <<"page_path">> => undefined };
true ->
IsEncoded = not RscUpd#rscupd.is_escape_texts,
Path1 = if
is_binary(Path) ->
normalize_page_path(Path, IsEncoded);
is_list(Path) ->
normalize_page_path(unicode_characters_to_binary(Path), IsEncoded);
true ->
case Path of
#trans{ tr = [] } ->
undefined;
#trans{ tr = Tr } ->
Tr1 = [ {Lang, normalize_page_path(P, IsEncoded)} || {Lang, P} <- Tr ],
#trans{ tr = Tr1 };
_ ->
undefined
end
end,
Acc#{ <<"page_path">> => Path1 };
false ->
Acc
end;
props_filter(<<"title_slug">>, Slug, Acc, _RscUpd, _Context) when ?is_empty(Slug) ->
Acc;
props_filter(<<"title_slug">>, Slug, Acc, _RscUpd, 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, _RscUpd, _Context) ->
case maps:is_key(<<"slug">>, Acc) of
true ->
Acc;
false ->
Acc#{
<<"slug">> => to_slug(Slug)
}
end;
props_filter(<<"is_", _/binary>> = B, P, Acc, _RscUpd, _Context) ->
Acc#{ B => z_convert:to_bool(P) };
props_filter(<<"visible_for">> = B, P, Acc, _RscUpd, _Context) ->
Acc#{ B => z_convert:to_integer(P) };
props_filter(<<"custom_slug">> = B, P, Acc, _RscUpd, _Context) ->
Acc#{ B => z_convert:to_bool(P) };
props_filter(<<"date_is_", _/binary>> = B, P, Acc, _RscUpd, _Context) ->
Acc#{ B => z_convert:to_bool(P) };
props_filter(<<"seo_noindex">> = B, P, Acc, _RscUpd, _Context) ->
Acc#{ B => z_convert:to_bool(P) };
props_filter(P, DT, Acc, _RscUpd, _Context)
when P =:= <<"created">>; P =:= <<"modified">>;
P =:= <<"date_start">>; P =:= <<"date_end">>;
P =:= <<"publication_start">>; P =:= <<"publication_end">> ->
DateTime = case z_datetime:to_datetime(DT) of
undefined when P =:= <<"publication_end">> ->
?ST_JUTTEMIS;
DT1 ->
DT1
end,
Acc#{
P => DateTime
};
props_filter(P, Id, Acc, _RscUpd, 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, _RscUpd, Context) ->
{ok, CategoryId} = m_category:name_to_id(CatName, Context),
Acc#{ <<"category_id">> => CategoryId };
props_filter(<<"category_id">>, CatId, Acc, _RscUpd, 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, _RscUpd, _Context) when ?is_empty(CG) ->
Acc#{ <<"content_group_id">> => undefined };
props_filter(<<"content_group">>, CgName, Acc, RscUpd, Context) ->
props_filter(<<"content_group_id">>, CgName, Acc, RscUpd, Context);
props_filter(<<"content_group_id">>, CgId, Acc, _RscUpd, _Context) when ?is_empty(CgId) ->
Acc#{ <<"content_group_id">> => undefined };
props_filter(<<"content_group_id">>, CgId, Acc, _RscUpd, 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, _RscUpd, _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, _RscUpd, _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(<<"medium_language">>, Lang, Acc, _RscUpd, _Context) ->
Lang1 = case z_language:to_language_atom(Lang) of
{ok, LangAtom} -> LangAtom;
{error, not_a_language} -> undefined
end,
Acc#{ <<"medium_language">> => Lang1 };
props_filter(<<"language">>, Langs, Acc, _RscUpd, _Context) ->
Acc#{ <<"language">> => filter_languages(Langs) };
props_filter(<<"crop_center">>, CropCenter, Acc, _RscUpd, _Context) when ?is_empty(CropCenter) ->
Acc#{ <<"crop_center">> => undefined };
props_filter(<<"crop_center">>, CropCenter, Acc, _RscUpd, _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, _RscUpd, _Context) when ?is_empty(Privacy) ->
Acc#{ <<"privacy">> => undefined };
props_filter(<<"privacy">>, Privacy, Acc, _RscUpd, _Context) ->
P = try
z_convert:to_integer(Privacy)
catch
_:_ -> undefined
end,
Acc#{ <<"privacy">> => P };
props_filter(P, V, Acc, _RscUpd, _Context) ->
Acc#{ P => V }.
unicode_characters_to_binary(Path) ->
case unicode:characters_to_binary(Path) of
Bin when is_binary(Bin) -> Bin;
{error, Bin, _} -> Bin;
{incomplete, Bin, _} -> Bin
end.
-spec normalize_page_path(Path, IsEncoded) -> binary() when
Path :: binary(),
IsEncoded :: boolean().
normalize_page_path(<<>>, _IsEncoded) -> <<>>;
normalize_page_path(Path, true) ->
try
Path1 = z_url:url_decode(Path),
normalize_page_path(Path1)
catch
_:_ ->
% Reject improper encoded page paths
<<>>
end;
normalize_page_path(Path, false) ->
normalize_page_path(Path).
-spec normalize_page_path(Path) -> binary() when
Path :: binary() | string() | undefined.
normalize_page_path(undefined) -> <<>>;
normalize_page_path(<<>>) -> <<>>;
normalize_page_path("") -> <<>>;
normalize_page_path(Path) ->
Path1 = z_string:trim(z_string:trim(Path), $/),
Path2 = iolist_to_binary([
$/, z_url:url_path_encode(Path1)
]),
binary:replace(Path2, [ <<"&">>, <<"=">> ], <<"-">>, [ global ]).
%% 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, Options, 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
Props2 = case maps:get(<<"is_authoritative">>, Props1, undefined) of
undefined -> Props1#{ <<"is_authoritative">> => true };
_ -> Props1
end,
% Default timezone of resource to options or request
Props2#{
<<"tz">> => timezone(undefined, Props2, Options, Context)
}.
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(<<"email_raw">>, _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(<<"short_title">>, _) -> 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, #{ <<"page_path">> := NewPath }, Context) ->
Old = page_paths(maps:get(<<"page_path">>, OldProps, undefined)),
New = page_paths(NewPath),
% Store dropped page paths
lists:foreach(
fun(P) ->
z_db:q("
INSERT INTO rsc_page_path_log(id, page_path)
VALUES ($1, $2)
ON CONFLICT (page_path) DO UPDATE
SET id = EXCLUDED.id",
[RscId, P],
Context)
end,
Old -- New);
update_page_path_log(_RscId, _OldProps, _NewProps, _Context) ->
ok.