-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].