0a5e86c4a0
Expand weekdays in the DTSTART week and advance by Interval weeks from Monday; add unit coverage for MO/WE/FR and mid-week starts.
213 lines
7.0 KiB
Erlang
213 lines
7.0 KiB
Erlang
-module(logic_recurrence).
|
|
-include("records.hrl").
|
|
|
|
-export([parse_rrule/1, generate_occurrences/3]).
|
|
-export([validate_rrule/1]).
|
|
|
|
%% Типы частоты повторения
|
|
-define(FREQ_DAILY, <<"DAILY">>).
|
|
-define(FREQ_WEEKLY, <<"WEEKLY">>).
|
|
-define(FREQ_MONTHLY, <<"MONTHLY">>).
|
|
|
|
%% Парсинг RRULE из JSON
|
|
parse_rrule(RRuleMap) when is_map(RRuleMap) ->
|
|
Freq = maps:get(<<"freq">>, RRuleMap, maps:get(freq, RRuleMap, undefined)),
|
|
Interval = maps:get(<<"interval">>, RRuleMap, maps:get(interval, RRuleMap, undefined)),
|
|
Until = maps:get(<<"until">>, RRuleMap, maps:get(until, RRuleMap, undefined)),
|
|
Count = maps:get(<<"count">>, RRuleMap, maps:get(count, RRuleMap, undefined)),
|
|
ByDay = maps:get(<<"byday">>, RRuleMap, maps:get(byday, RRuleMap, [])),
|
|
|
|
{ok, #{
|
|
freq => Freq,
|
|
interval => Interval,
|
|
until => Until,
|
|
count => Count,
|
|
byday => ByDay
|
|
}}.
|
|
|
|
%% Валидация RRULE
|
|
validate_rrule(RRule) ->
|
|
try
|
|
% Поддерживаем и атомы, и бинарные ключи
|
|
Freq = get_freq(RRule),
|
|
Interval = get_interval(RRule),
|
|
ValidFreq = (Freq =:= ?FREQ_DAILY orelse
|
|
Freq =:= ?FREQ_WEEKLY orelse
|
|
Freq =:= ?FREQ_MONTHLY orelse
|
|
Freq =:= <<"DAILY">> orelse
|
|
Freq =:= <<"WEEKLY">> orelse
|
|
Freq =:= <<"MONTHLY">>),
|
|
ValidInterval = is_integer(Interval) andalso Interval >= 1,
|
|
ValidFreq andalso ValidInterval
|
|
catch
|
|
_:_ -> false
|
|
end.
|
|
|
|
get_freq(#{freq := Freq}) -> Freq;
|
|
get_freq(#{<<"freq">> := Freq}) -> Freq.
|
|
|
|
get_interval(#{interval := Interval}) -> Interval;
|
|
get_interval(#{<<"interval">> := Interval}) -> Interval.
|
|
|
|
%% Генерация вхождений в заданном диапазоне
|
|
generate_occurrences(StartTime, RRule, RangeEnd) ->
|
|
Freq = normalize_freq(get_freq(RRule)),
|
|
Interval = get_interval(RRule),
|
|
UntilRaw = maps:get(until, RRule, maps:get(<<"until">>, RRule, undefined)),
|
|
Count = maps:get(count, RRule, maps:get(<<"count">>, RRule, undefined)),
|
|
ByDay = maps:get(byday, RRule, maps:get(<<"byday">>, RRule, [])),
|
|
|
|
EndBoundary = case UntilRaw of
|
|
undefined -> RangeEnd;
|
|
U when is_binary(U) ->
|
|
{ok, UntilDt} = parse_datetime_str(U),
|
|
erlang:min(UntilDt, RangeEnd)
|
|
end,
|
|
|
|
generate_by_freq(StartTime, Freq, Interval, ByDay, EndBoundary, Count, 0, []).
|
|
|
|
normalize_freq(<<"DAILY">>) -> ?FREQ_DAILY;
|
|
normalize_freq(<<"WEEKLY">>) -> ?FREQ_WEEKLY;
|
|
normalize_freq(<<"MONTHLY">>) -> ?FREQ_MONTHLY;
|
|
normalize_freq(Freq) when is_atom(Freq) -> Freq.
|
|
|
|
parse_datetime_str(Str) ->
|
|
[DateStr, TimeStr] = string:split(Str, "T"),
|
|
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"),
|
|
|
|
[YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all),
|
|
[HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all),
|
|
|
|
Year = binary_to_integer(YearStr),
|
|
Month = binary_to_integer(MonthStr),
|
|
Day = binary_to_integer(DayStr),
|
|
Hour = binary_to_integer(HourStr),
|
|
Minute = binary_to_integer(MinuteStr),
|
|
Second = binary_to_integer(SecondStr),
|
|
|
|
{ok, {{Year, Month, Day}, {Hour, Minute, Second}}}.
|
|
|
|
minus(Dt1, Dt2) when Dt1 < Dt2 -> Dt1;
|
|
minus(_, Dt2) -> Dt2.
|
|
|
|
%% Генерация по частоте
|
|
generate_by_freq(Current, ?FREQ_DAILY, Interval, _ByDay, EndBoundary, MaxCount, Count, Acc) ->
|
|
case should_stop(Current, EndBoundary, MaxCount, Count) of
|
|
true -> lists:reverse(Acc);
|
|
false ->
|
|
generate_by_freq(
|
|
add_days(Current, Interval),
|
|
?FREQ_DAILY, Interval, _ByDay, EndBoundary, MaxCount,
|
|
Count + 1, [Current | Acc]
|
|
)
|
|
end;
|
|
|
|
generate_by_freq(Current, ?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount, Count, Acc) ->
|
|
case should_stop(Current, EndBoundary, MaxCount, Count) of
|
|
true -> lists:reverse(Acc);
|
|
false ->
|
|
{NewOccurrences, Next} =
|
|
case ByDay of
|
|
[] ->
|
|
{[Current], add_weeks(Current, Interval)};
|
|
Days ->
|
|
%% BYDAY: expand weekdays in Current's week (on/after Current),
|
|
%% then jump Interval weeks from that week's Monday.
|
|
Occs = [O || O <- filter_by_weekday(Current, Days), O =< EndBoundary],
|
|
{Occs, add_weeks(week_start_monday(Current), Interval)}
|
|
end,
|
|
%% Prepend reversed batch so final lists:reverse/1 yields chronological order.
|
|
generate_by_freq(
|
|
Next,
|
|
?FREQ_WEEKLY, Interval, ByDay, EndBoundary, MaxCount,
|
|
Count + 1, lists:reverse(NewOccurrences) ++ Acc
|
|
)
|
|
end;
|
|
|
|
generate_by_freq(Current, ?FREQ_MONTHLY, Interval, ByDay, EndBoundary, MaxCount, Count, Acc) ->
|
|
case should_stop(Current, EndBoundary, MaxCount, Count) of
|
|
true -> lists:reverse(Acc);
|
|
false ->
|
|
NewOccurrences = case ByDay of
|
|
[] -> [Current];
|
|
_ -> filter_by_month_day(Current, ByDay)
|
|
end,
|
|
generate_by_freq(
|
|
add_months(Current, Interval),
|
|
?FREQ_MONTHLY, Interval, ByDay, EndBoundary, MaxCount,
|
|
Count + 1, NewOccurrences ++ Acc
|
|
)
|
|
end.
|
|
|
|
%% Вспомогательные функции
|
|
should_stop(Current, EndBoundary, MaxCount, Count) ->
|
|
(Current > EndBoundary) orelse (MaxCount =/= undefined andalso Count >= MaxCount).
|
|
|
|
add_days({{Y, M, D}, Time}, N) ->
|
|
Days = calendar:date_to_gregorian_days(Y, M, D) + N,
|
|
{calendar:gregorian_days_to_date(Days), Time}.
|
|
|
|
add_weeks(DateTime, N) ->
|
|
add_days(DateTime, N * 7).
|
|
|
|
add_months({{Y, M, D}, Time}, N) ->
|
|
TotalMonths = Y * 12 + M - 1 + N,
|
|
NewYear = TotalMonths div 12,
|
|
NewMonth = TotalMonths rem 12 + 1,
|
|
NewDay = minus(D, calendar:last_day_of_the_month(NewYear, NewMonth)),
|
|
{{NewYear, NewMonth, NewDay}, Time}.
|
|
|
|
%% BYDAY codes (RFC 5545): MO..SU. calendar:day_of_the_week = 1=Mon .. 7=Sun.
|
|
week_start_monday({{Y, M, D}, Time}) ->
|
|
Dow = calendar:day_of_the_week({Y, M, D}),
|
|
add_days({{Y, M, D}, Time}, -(Dow - 1)).
|
|
|
|
byday_to_dow(<<"MO">>) -> 1;
|
|
byday_to_dow(<<"TU">>) -> 2;
|
|
byday_to_dow(<<"WE">>) -> 3;
|
|
byday_to_dow(<<"TH">>) -> 4;
|
|
byday_to_dow(<<"FR">>) -> 5;
|
|
byday_to_dow(<<"SA">>) -> 6;
|
|
byday_to_dow(<<"SU">>) -> 7;
|
|
byday_to_dow("MO") -> 1;
|
|
byday_to_dow("TU") -> 2;
|
|
byday_to_dow("WE") -> 3;
|
|
byday_to_dow("TH") -> 4;
|
|
byday_to_dow("FR") -> 5;
|
|
byday_to_dow("SA") -> 6;
|
|
byday_to_dow("SU") -> 7;
|
|
byday_to_dow(mo) -> 1;
|
|
byday_to_dow(tu) -> 2;
|
|
byday_to_dow(we) -> 3;
|
|
byday_to_dow(th) -> 4;
|
|
byday_to_dow(fr) -> 5;
|
|
byday_to_dow(sa) -> 6;
|
|
byday_to_dow(su) -> 7;
|
|
byday_to_dow(_) -> undefined.
|
|
|
|
%% Occurrences for BYDAY in the week of Current, same time-of-day.
|
|
%% Only dates on or after Current (so DTSTART mid-week does not emit earlier weekdays).
|
|
filter_by_weekday(Current, Days) when is_list(Days) ->
|
|
Monday = week_start_monday(Current),
|
|
Occs =
|
|
lists:foldl(
|
|
fun(DayCode, Acc) ->
|
|
case byday_to_dow(DayCode) of
|
|
undefined -> Acc;
|
|
TargetDow ->
|
|
Occ = add_days(Monday, TargetDow - 1),
|
|
case Occ >= Current of
|
|
true -> [Occ | Acc];
|
|
false -> Acc
|
|
end
|
|
end
|
|
end,
|
|
[],
|
|
Days
|
|
),
|
|
lists:sort(Occs);
|
|
filter_by_weekday(DateTime, _) ->
|
|
[DateTime].
|
|
|
|
filter_by_month_day(DateTime, _Days) ->
|
|
[DateTime]. |