%% @author Arjan Scherpenisse <arjan@miraclethings.nl>
%% @copyright 2014-2017 Arjan Scherpenisse
%%
%% @doc Handle changed files
%% Copyright 2014-2017 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(zotonic_filewatcher_handler).
-author("Arjan Scherpenisse <arjan@miraclethings.nl>").
-behaviour(gen_server).
-export([
file_changed/2,
re_exclude/0,
is_file_blocked/1,
is_file_blocked/2
]).
-export([
start_link/0,
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
code_change/3,
terminate/2
]).
-include_lib("zotonic_notifier/include/zotonic_notifier.hrl").
-include_lib("zotonic_filewatcher.hrl").
-include_lib("kernel/include/logger.hrl").
-record(state, {
events = [] :: map(), % binary() => verb()
startline = undefined :: undefined | pos_integer(),
deadline = undefined :: undefined | pos_integer(),
is_changed = false :: boolean()
}).
%% Which files do we not consider at all in the file_changed handler
-define(FILENAME_BLOCKLIST_RE,
"_flymake$"
"|node_modules"
"|\\.#"
"|\\.bea#"
"|/priv/files/"
"|/priv/data/"
"|/\\.git/"
"|/\\.gitignore"
"|\\.hg/"
"|/log/"
"|\\.log$"
"|\\.dump$"
"|/translations/.*\\.mo$"
"|~$"
"|\\.bck$"
"|\\.swp$"
"|\\.pot$"
"|/\\."
"|/mnesia/"
"|/\\.rebar3"
"|\\.DS_Store").
-define(TIMER_DELAY, 300).
-define(DEADLINE_DELAY, 250).
-define(MAX_DELAY, 5000).
-spec start_link() -> {ok, pid()} | {error, term()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
%% @doc Called when a file is changed on disk, buffers changes before sending
%% a batch to the zotonic_filehandler.
-spec file_changed(zotonic_filehandler:verb(), file:filename_all()) -> ok.
file_changed(Verb, F) when is_list(F) ->
file_changed(Verb, unicode:characters_to_binary(F));
file_changed(Verb, F) when is_binary(F) ->
?LOG_DEBUG(#{
text => <<"Filewatcher event">>,
in => zotonic_filewatcher,
verb => Verb,
filename => F
}),
case is_file_blocked(F) of
true ->
ok;
false ->
gen_server:cast(?MODULE, {file_changed, Verb, F})
end.
-spec re_exclude() -> string().
re_exclude() ->
?FILENAME_BLOCKLIST_RE.
-spec is_file_blocked(binary()|string()) -> boolean().
is_file_blocked(<<".", _/binary>>) ->
true;
is_file_blocked(<<>>) ->
true;
is_file_blocked(F) when is_binary(F) ->
case binary:last(F) of
$# -> true;
_ -> re:run(F, re_compiled()) =/= nomatch
end;
is_file_blocked("." ++ _) ->
true;
is_file_blocked("") ->
true;
is_file_blocked(F) when is_list(F) ->
case lists:last(F) of
$# -> true;
_ -> re:run(F, re_compiled()) =/= nomatch
end.
re_compiled() ->
case erlang:get(blocklist_re) of
undefined ->
{ok, RE} = re:compile(?FILENAME_BLOCKLIST_RE),
erlang:put(blocklist_re, RE),
RE;
RE ->
RE
end.
%% @doc Called by zotonic_filewatcher_handler
is_file_blocked("priv", "files") -> true;
is_file_blocked("priv", "mnesia") -> true;
is_file_blocked("priv", "log") -> true;
is_file_blocked(<<"priv">>, <<"files">>) -> true;
is_file_blocked(<<"priv">>, <<"mnesia">>) -> true;
is_file_blocked(<<"priv">>, <<"log">>) -> true;
is_file_blocked(_Dir, "." ++ _) -> true;
is_file_blocked(_Dir, <<".", _/binary>>) -> true;
is_file_blocked(_Dir, File) -> is_file_blocked(File).
%% ------------------------------ gen_server Callbacks -----------------------------
-spec init(term()) -> {ok, #state{}}.
init(_) ->
{ok, #state{
events = #{},
is_changed = false
}}.
handle_call(Msg, _From, State) ->
{stop, {unknown_msg, Msg}, State}.
handle_cast({file_changed, Verb, F}, #state{ events = Es } = State) ->
State1 = State#state{ events = add_event(Es, Verb, F) },
State2 = set_timer(State1),
{noreply, State2};
handle_cast(Msg, State) ->
{stop, {unknown_cast, Msg}, State}.
handle_info(maybe_send, #state{ is_changed = false } = State) ->
{noreply, State};
handle_info(maybe_send, #state{ deadline = Deadline } = State) ->
Now = msec(),
case Deadline > Now of
true ->
{noreply, State};
false ->
send_changes(State#state.events),
State1 = State#state{
events = #{},
is_changed = false
},
{noreply, State1}
end;
handle_info(_Msg, State) ->
{noreply, State}.
code_change(_Vsn, State, _Extra) ->
{ok, State}.
terminate(_Reason, _State) ->
ok.
%% ------------------------------ Internal Functions -----------------------------
-spec send_changes(map()) -> ok.
send_changes(Es) ->
Msg = #filewatcher_changes{
changes = Es
},
zotonic_notifier:first(?SYSTEM_NOTIFIER, filewatcher_changes, Msg, undefined).
-spec add_event(map(), zotonic_filehandler:verb(), binary()) -> map().
add_event(Es, Verb, Filename) ->
OldVerb = maps:get(Filename, Es, undefined),
Es#{ Filename => select_verb(Verb, OldVerb) }.
%% @doc Select the verb to be passed if there are multiple updates to a file.
-spec select_verb(NewVerb, PrevVerb) -> Verb
when NewVerb :: zotonic_filehandler:verb(),
PrevVerb :: zotonic_filehandler:verb() | undefined,
Verb :: zotonic_filehandler:verb().
select_verb(Verb, undefined) -> Verb;
select_verb(delete, _Verb) -> delete;
select_verb(create, _Verb) -> create;
select_verb(modify, Verb) -> Verb.
set_timer(#state{ is_changed = false } = State) ->
erlang:send_after(?TIMER_DELAY, self(), maybe_send),
Now = msec(),
State#state{
startline = Now,
deadline = Now + ?DEADLINE_DELAY,
is_changed = true
};
set_timer(#state{ startline = Startline } = State) ->
erlang:send_after(?TIMER_DELAY, self(), maybe_send),
Now = msec(),
CurrentDelay = Now - Startline,
if
CurrentDelay > ?MAX_DELAY ->
State#state{
is_changed = true
};
true ->
State#state{
deadline = Now + ?DEADLINE_DELAY,
is_changed = true
}
end.
msec() ->
{Mega, Secs, Micro} = os:timestamp(),
((Mega * 1000000) + Secs) * 1000 + (Micro div 1000).