diff --git a/.credo.exs b/.credo.exs index 888e7ea..1a35529 100644 --- a/.credo.exs +++ b/.credo.exs @@ -4,6 +4,9 @@ %{ name: "default", checks: %{ + extra: [ + {Credo.Check.Refactor.Nesting, [max_nesting: 3]} + ], disabled: [ {Credo.Check.Design.AliasUsage, []} ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 17668d6..3c400b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,29 +4,33 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## vNEXT +## v2.0.0 -This release drops support for Elixir 1.15 and 1.16 in order to gain -access to the improved date and calendaring APIs introduced in 1.17. +The minimum version of Elixir required is 1.17. Support for Elixir 1.15 and 1.16 was +dropped so `ICal` may use the improved date and calendaring APIs introduced in 1.17. -Timex was also removed as a dependency, along with its transitive dependencies -such as gettext. - -It is recommended to add a timezone database such as `tz` to applications that use +It is recommended to add a timezone database such as `tz` to applications that use ICal in order to benefit fully from these changes. - Improvements + - Recurrence generation was re-written and is now feature complete + - The entirety of the RFC5545 RRULE specification is supported + - Works with `ICal.Event`, `ICal.Todo` and `ICal.Journal` + - Recurrence dates (`RDATE`) are included + - Excluded dates (`EXDATE`) are respected - New functions in `ICal.Alarm` - `next_activation/2`: calculates when an alarm should next activate (if ever) - `next_alarms/1`: returns all next alarms with activation times for a compoonent with alarms (`ICal.Event`, `ICal.Todo`) - - Recurrence now supports `ICal.Todo` - Fixes - Gap and ambiguous times are properly handled when a datetime lands in a timezone shift period + - Properly parse lists of excluded dates + - Fix serializing of components with status of draft - Janitorial + - The dependency on `Timex` was removed - Documentation improvements -Contributors to this release included: +Contributors to this release include: - [Matthew Lehner](https://github.com/matthewlehner) - [Patrick Wendo](https://github.com/W3NDO) diff --git a/README.md b/README.md index 6514242..ac37719 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ A library for reading and writing iCalendar data. * Journals * Timezones * Alarms -* Recurrence calculations (currrently only `BYDAY` is supported) +* Recurrence calculations +* Alarm calculations * Compatibility * RFC 5545 compliant * Support for common non-standard properties, including: @@ -28,11 +29,6 @@ Components that will eventually be supported (in rough order): * Free/busy (VFREEBUSY) -Planned features: - -* Alarm calculation -* Expanded recurrency calculation - ## Usage Full documentation can be found on [Hexdocs](https://hexdocs.pm/ical). @@ -75,41 +71,31 @@ Inline attachments can be decoded via `ICal.Attachment.decoded_data/1`. ## Installation -- Add `:ical` to your list of dependencies in `mix.exs`: +- Add `:ical` to your list of dependencies in `mix.exs`, along with a timezone database: ```elixir def deps do [{:ical, "~> 1.0"}] - end - ``` - -- ICal needs a timezone database for timezone-aware operations (parsing events - with `TZID`, recurrence calculations, etc.). Add a package that implements the - `Calendar.TimeZoneDatabase` behaviour to your dependencies: - - ```elixir - def deps do - [ {:ical, "~> 1.0"}, - {:tzdata, "~> 1.1"} + {:tz, "~> 1.1"} ] end ``` - Then configure it in your `config/config.exs`: + Then configure the timezone database in e.g. `config/config.exs`: ```elixir - config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase + config :elixir, :time_zone_database, Tz.TimeZoneDatabase ``` + For auto-updating the timezone database at runtime, check the documentation for the timezone + library you are using. - The following timezone database packages are known to be compatible: + The following timezone database packages are known to work well with ICal: | Package | Module | |---|---| - | [`tzdata`](https://hex.pm/packages/tzdata) | `Tzdata.TimeZoneDatabase` | | [`tz`](https://hex.pm/packages/tz) | `Tz.TimeZoneDatabase` | | [`time_zone_info`](https://hex.pm/packages/time_zone_info) | `TimeZoneInfo.TimeZoneDatabase` | - | [`zoneinfo`](https://hex.pm/packages/zoneinfo) | `Zoneinfo.TimeZoneDatabase` | See [tzdb_test](https://github.com/mathieuprog/tzdb_test) for more information on the available timezone database libraries. diff --git a/config/config.exs b/config/config.exs index 0655d56..3b2d2b5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -4,3 +4,8 @@ if Mix.env() == :dev do config :mix_test_watch, extra_extensions: [".ics"] end + +if Mix.env() == :dev or Mix.env() == :test do + config :ical, show_test_timings: false + config :elixir, :time_zone_database, Tz.TimeZoneDatabase +end diff --git a/lib/ical.ex b/lib/ical.ex index 923c376..5dc7cdd 100644 --- a/lib/ical.ex +++ b/lib/ical.ex @@ -119,4 +119,41 @@ defmodule ICal do def encode_to_iodata!(calendar, _options \\ []) do to_ics(calendar) end + + @doc false + @spec as_valid_datetime(Date.t(), Time.t(), timezone :: String.t()) :: DateTime.t() | nil + def as_valid_datetime(date, time, timezone) do + case DateTime.new(date, time, timezone) do + {:ok, dt} -> dt + {:ambiguous, first, _second} -> first + {:gap, just_before, just_after} -> adjust_to_gap(time, just_before, just_after) + _ -> nil + end + rescue + _ -> nil + end + + defp adjust_to_gap(original_time, before_gap, after_gap) do + before_gap = + before_gap + |> DateTime.to_time() + |> round_off_micros() + + diff = Time.diff(original_time, before_gap) + + time = Time.shift(DateTime.to_time(after_gap), second: diff) + + DateTime.new!(DateTime.to_date(after_gap), time, after_gap.time_zone) + end + + defp round_off_micros(time) do + # gap times are often 59.9999 seconds, the moment RIGHT before. + # snug those times up to the minute + {ms, precision} = time.microsecond + offset = round(ms / Integer.pow(10, precision)) + + time + |> Time.truncate(:second) + |> Time.shift(second: offset) + end end diff --git a/lib/ical/alarm.ex b/lib/ical/alarm.ex index 85d3a95..400e306 100644 --- a/lib/ical/alarm.ex +++ b/lib/ical/alarm.ex @@ -35,8 +35,13 @@ defmodule ICal.Alarm do |> Enum.take(1) case recurrences do - [recurrence] -> calculate_alarms(recurrence) - _ -> [] + [recurrence] -> + recurrence + |> ICal.Recurrence.apply(component) + |> calculate_alarms() + + _ -> + [] end end diff --git a/lib/ical/deserialize.ex b/lib/ical/deserialize.ex index d7089f9..8909010 100644 --- a/lib/ical/deserialize.ex +++ b/lib/ical/deserialize.ex @@ -489,12 +489,7 @@ defmodule ICal.Deserialize do # DATE-TIME value is interpreted using the UTC offset before the gap." # e.g. 2:30 AM in a spring-forward gap → apply pre-gap offset (EST) to # get UTC, then express in the post-gap offset (EDT) → 3:30 AM EDT. - case DateTime.new(date, time, timezone) do - {:ok, dt} -> dt - {:ambiguous, first, _second} -> first - {:gap, just_before, just_after} -> adjust_to_gap(time, just_before, just_after) - _ -> nil - end + ICal.as_valid_datetime(date, time, timezone) else _ -> nil end @@ -542,28 +537,4 @@ defmodule ICal.Deserialize do def status(%ICal.Journal{}, "FINAL"), do: :final def status(_, "CANCELLED"), do: :cancelled def status(_, _), do: nil - - defp adjust_to_gap(original_time, before_gap, after_gap) do - before_gap = - before_gap - |> DateTime.to_time() - |> round_off_micros() - - diff = Time.diff(original_time, before_gap) - - time = Time.shift(DateTime.to_time(after_gap), second: diff) - - DateTime.new!(DateTime.to_date(after_gap), time, after_gap.time_zone) - end - - defp round_off_micros(time) do - # gap times are often 59.9999 seconds, the moment RIGHT before. - # snug those times up to the minute - {ms, precision} = time.microsecond - offset = round(ms / Integer.pow(10, precision)) - - time - |> Time.truncate(:second) - |> Time.shift(second: offset) - end end diff --git a/lib/ical/deserialize/recurrence.ex b/lib/ical/deserialize/recurrence.ex index 01963a7..350cc33 100644 --- a/lib/ical/deserialize/recurrence.ex +++ b/lib/ical/deserialize/recurrence.ex @@ -9,14 +9,6 @@ defmodule ICal.Deserialize.Recurrence do add_frequency(%ICal.Recurrence{}, params) end - @spec from_event(map | ICal.Event.t()) :: ICal.Recurrence.t() | nil - def from_event(%ICal.Event{rrule: %ICal.Recurrence{} = rule}), do: rule - def from_event(%ICal.Event{rrule: nil}), do: nil - - def from_event(%ICal.Event{} = event) do - add_frequency(%ICal.Recurrence{}, event.rrule) - end - defp add_frequency(recurrence, params) do with freq when not is_nil(freq) <- Map.get(params, "FREQ"), {:ok, frequency} <- to_frequency_atom(freq) do @@ -25,6 +17,7 @@ defmodule ICal.Deserialize.Recurrence do %{recurrence | frequency: frequency}, &add_to_recurrence/2 ) + |> ICal.Recurrence.normalize() else _ -> nil end @@ -50,7 +43,7 @@ defmodule ICal.Deserialize.Recurrence do defp add_to_recurrence({"WKST", value}, recurrence) do case to_weekday(value) do :error -> recurrence - {weekday_atom, _} -> %{recurrence | weekday: weekday_atom} + {weekday_atom, _} -> %{recurrence | week_start_day: weekday_atom} end end @@ -61,17 +54,17 @@ defmodule ICal.Deserialize.Recurrence do # sets of numbres, except BYDAY which is extra complicated. # see to_weekdays for more on that. defp add_by_list_to_recurrence("SECOND", value, recurrence) do - number_list = to_clamped_numbers(value, 0, 59) + number_list = parse_number_list(value) %{recurrence | by_second: number_list} end defp add_by_list_to_recurrence("MINUTE", value, recurrence) do - number_list = to_clamped_numbers(value, 0, 59) + number_list = parse_number_list(value) %{recurrence | by_minute: number_list} end defp add_by_list_to_recurrence("HOUR", value, recurrence) do - number_list = to_clamped_numbers(value, 0, 23) + number_list = parse_number_list(value) %{recurrence | by_hour: number_list} end @@ -81,27 +74,27 @@ defmodule ICal.Deserialize.Recurrence do end defp add_by_list_to_recurrence("MONTH", value, recurrence) do - number_list = to_clamped_numbers(value, 1, 12) + number_list = parse_number_list(value) %{recurrence | by_month: number_list} end defp add_by_list_to_recurrence("MONTHDAY", value, recurrence) do - number_list = to_clamped_numbers(value, -31, 31) + number_list = parse_number_list(value) %{recurrence | by_month_day: number_list} end defp add_by_list_to_recurrence("YEARDAY", value, recurrence) do - number_list = to_clamped_numbers(value, -366, 366) + number_list = parse_number_list(value) %{recurrence | by_year_day: number_list} end defp add_by_list_to_recurrence("WEEKNO", value, recurrence) do - number_list = to_clamped_numbers(value, -53, 53) + number_list = parse_number_list(value) %{recurrence | by_week_number: number_list} end defp add_by_list_to_recurrence("SETPOS", value, recurrence) do - number_list = to_clamped_numbers(value, -366, 366) + number_list = parse_number_list(value) %{recurrence | by_set_position: number_list} end @@ -112,33 +105,33 @@ defmodule ICal.Deserialize.Recurrence do end end - defp to_clamped_numbers(string, min, max) do - to_clamped_numbers(string, min, max, "", []) + def parse_number_list(string) do + string + |> accumulate_numbers("", []) + |> Enum.sort() + |> Enum.uniq() end - defp to_clamped_numbers(<<>>, min, max, value, acc) do - clamp_number(value, min, max, acc) + defp accumulate_numbers(<<>>, value, acc) do + accumulate_if_number(value, acc) end - defp to_clamped_numbers(<>, min, max, value, acc) do - acc = clamp_number(value, min, max, acc) - to_clamped_numbers(string, min, max, "", acc) + defp accumulate_numbers(<>, value, acc) do + acc = accumulate_if_number(value, acc) + accumulate_numbers(string, "", acc) end - defp to_clamped_numbers(<>, min, max, value, acc) do - to_clamped_numbers(string, min, max, <>, acc) + defp accumulate_numbers(<>, value, acc) do + accumulate_numbers(string, <>, acc) end - defp clamp_number(value, min, max, acc) do + defp accumulate_if_number(value, acc) do # zeros are only allowed when the min value is also zero: # a negative min means no zeros, and if the min is above zero, obviously # zero is not ok case Deserialize.to_integer(value) do - 0 when min == 0 -> - acc ++ [0] - - number when is_number(number) and number != 0 and number <= max and number >= min -> - acc ++ [number] + number when is_number(number) -> + [number | acc] _ -> acc @@ -168,7 +161,7 @@ defmodule ICal.Deserialize.Recurrence do # no offset, so just send it in with a 0 for the offset, which the user will ignore # in a {0, someweekday} tuple defp to_weekdays(string, acc) do - to_weekday_with_offset(string, nil, acc) + to_weekday_with_offset(string, 0, acc) end # doing an entry with an offset, and we have more numbers, so keep going diff --git a/lib/ical/recurrence.ex b/lib/ical/recurrence.ex index 9c064cc..11d731e 100644 --- a/lib/ical/recurrence.ex +++ b/lib/ical/recurrence.ex @@ -2,13 +2,11 @@ # credo:disable-for-this-file defmodule ICal.Recurrence do @moduledoc """ - Adds support for ICal recurring. - - Events can recur by frequency, count, interval, and/or start/end date. To - see the specific rules and examples, see `add_recurring_events/2` below. + Support for recurring events, todos, and journals. """ require Logger + alias ICal.Recurrence.{Generate, State} defstruct [ :until, @@ -22,315 +20,267 @@ defmodule ICal.Recurrence do :by_month, :by_set_position, :by_week_number, - :weekday, + week_start_day: :default, frequency: :daily, interval: 1 ] + @type recurrence_date :: Date.t() | DateTime.t() + @type stream_option :: + {:start_date, recurrence_date} + | {:end_date, recurrence_date} + | {:include, [recurrence_date]} + | {:exclude, [recurrence_date]} + @type frequency :: :secondly | :minutely | :hourly | :daily | :weekly | :monthly | :yearly @type weekday :: :monday | :tuesday | :wednesday | :thursday | :friday | :saturday | :sunday @type t :: %__MODULE__{ frequency: frequency, until: DateTime.t() | nil, - count: integer, - interval: integer, - by_second: [non_neg_integer], - by_minute: [non_neg_integer], - by_hour: [non_neg_integer], - by_day: [{offset :: integer, byday :: weekday}], - by_month_day: [non_neg_integer], - by_year_day: [non_neg_integer], - by_month: [non_neg_integer], - by_week_number: [non_neg_integer], - by_set_position: [non_neg_integer], - weekday: weekday + count: non_neg_integer, + interval: non_neg_integer, + by_second: [non_neg_integer] | nil, + by_minute: [non_neg_integer] | nil, + by_hour: [non_neg_integer] | nil, + by_day: [{offset :: integer, byday :: weekday}] | nil, + by_month_day: [non_neg_integer] | nil, + by_year_day: [non_neg_integer] | nil, + by_month: [non_neg_integer] | nil, + by_week_number: [non_neg_integer] | nil, + by_set_position: [non_neg_integer] | nil, + week_start_day: weekday | :default } - # ignore :byhour, :monthday, :byyearday, :byweekno, :bymonth for now - @supported_by_x_rrules [:by_day] - @doc """ - Given a component with a recurrence rule, return a stream of recurrences for it. - - Warning: this may create a very large sequence of recurrences. - - ## Parameters - - - `component`: The ICal component (e.g. event or todo) that may contain an rrule. See `ICal.Event`. - - - `end_date` *(optional)*: A date time that represents the fallback end date - for recurrence. This value is only used when the options specified - in the rrule result in an infinite recurrance (ie. when neither `count` nor - `until` is set). If no end_date is set, it will default to - `DateTime.utc_now()`. - - ## Event rrule options - - Event recurrance details are specified in the `rrule`. The following options - are considered: + Takes a string starting with "RRULE:" and returns a recurrence struct. + """ + def from_ics(<<"RRULE", data::binary>>) do + data = ICal.Deserialize.skip_params(data) + {_data, values} = ICal.Deserialize.param_list(data) + ICal.Deserialize.Recurrence.from_params(values) + end - - `freq`: Represents how frequently it recurs. Allowed frequencies - are `DAILY`, `WEEKLY`, and `MONTHLY`. These can be further modified by - the `interval` option. + @doc """ + Normalizes a recurrence, to ensure it is within the boundaries defined by RFC5545. - - `count` *(optional)*: Represents the number of times that it will - recur. This takes precedence over the `end_date` parameter and the - `until` option. + Call this before using the recurrence if creating recurrences manually. Recurrences + parsed from ics data are automatically normalized. + """ + def normalize(%__MODULE__{} = recurrence) do + %{ + recurrence + | by_second: clamped_numbers(recurrence.by_second, 0, 59), + by_minute: clamped_numbers(recurrence.by_minute, 0, 59), + by_hour: clamped_numbers(recurrence.by_hour, 0, 23), + by_day: normalize_weekdays(recurrence.by_day, recurrence.week_start_day), + by_month_day: clamped_numbers(recurrence.by_month_day, -31, 31), + by_year_day: clamped_numbers(recurrence.by_year_day, -366, 366), + by_month: clamped_numbers(recurrence.by_month, 1, 12), + by_set_position: clamped_numbers(recurrence.by_set_position, -366, 366), + by_week_number: clamped_numbers(recurrence.by_week_number, -53, 53), + count: nil_or_positive(recurrence.count), + interval: positive(recurrence.interval, 1) + } + end - - `interval` *(optional)*: Represents the interval at which it occurs. - This option works in concert with `freq` above; by using the `interval` - option, it could recur every 5 days or every 3 weeks. + @doc """ + Applies a generated date or datetime recurrence to an `ICal` component such as + an event, todo, or journal entry. + """ + def apply(%x{} = recurrence, component) when x == Date or x == DateTime do + %{ + component + | dtstart: recurrence, + dtend: offset(recurrence, diff(component.dtend, component.dtstart)) + } + end - - `until` *(optional)*: Represents the end date for the recurrances. - This takes precedence over the `end_date` parameter. + @doc """ + Returns true if the recurrence terminates eventually, false if it has no + defined end and instead continues indefinitely. + """ + def terminates?(%__MODULE__{count: count, until: until}) do + is_integer(count) or until != nil + end - - `by_day` *(optional)*: Represents the days of the week at which recurrences occur. + @doc """ + Creates a stream of recurrences for a recurrence rule or a component with a recurrence rule. - The `freq` option is required for a valid rrule, but the others are - optional. They may be used either individually (ex. just `freq`) or in - concert (ex. `freq` + `interval` + `until`). + The stream takes into consideration any recurrence rules (RRULE), recurrence dates (RDATE), + and excluded dates (EXDATE). It starts at the start date (DTSTART) defined in the component. - ## Future rrule options (not yet supported) + As recurrences may not have an end, continuing on forever, the resulting stream can be also + be infinite. For this reason, unless absolutely sure the stream terminates do not request the + entire contents of the stream by calling `Enum.to_list()` on it, for isntance. Instead, always + consume the stream in chunks until it is exausted. - - `byhour` *(optional)*: Represents the hours of the day at which it occurs. - - `byweekno` *(optional)*: Represents the week number at which it occurs. - - `bymonthday` *(optional)*: Represents the days of the month at which it occurs. - - `bymonth` *(optional)*: Represents the months at which it occurs. - - `byyearday` *(optional)*: Represents the days of the year at which it occurs. + ## Parameters + - `component`: An `ICal.Event`, `ICal.Todo`, `ICal.Journal`, or a bare `ICal.Recurrence` struct + - `options`: An optional list of `t:stream_option/0` values. Can be used to set explicit start and end + dates as well as recurrences to include in or exclude from the stream. ## Examples - iex> dt = ~D[2016-08-13] - iex> dt_end = ~D[2016-08-23] - iex> event = %ICal.Event{rrule: %ICal.Recurrence{frequency: :daily}, dtstart: dt, dtend: dt} - iex> recurrences = - ICal.Recurrence.stream(event) - |> Enum.to_list() + ``` + iex> dt = ~D[2016-08-13] + iex> dt_end = ~D[2016-08-23] + iex> recurrence = %ICal.Recurrence{frequency: :daily} + iex> event = %ICal.Event{rrule: recurrence, dtstart: dt, dtend: dt_end} + iex> ICal.Recurrence.stream(event) |> Enum.take(5) + [~D[2016-08-13], ~D[2016-08-14], ~D[2016-08-15], ~D[2016-08-16], ~D[2016-08-17]] + iex> rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY") + iex> ICal.Recurrence.stream(rule, start_date: dt, end_date: dt_end) |> Enum.take(5) + [~D[2016-08-13], ~D[2016-08-14], ~D[2016-08-15], ~D[2016-08-16], ~D[2016-08-17]] + ``` """ @type recurrable_component :: %{ required(:rrule) => t() | nil, required(:dtstart) => Date.t() | DateTime.t() | nil, - optional(:dtend) => Date.t() | DateTime.t() | nil + optional(:exdates) => [Date.t() | DateTime.t()], + optional(:dtend) => Date.t() | DateTime.t() | nil, + optional(:rdates) => [Date.t() | DateTime.t() | ICal.period()] } - @spec stream(recurrable_component) :: Enumerable.t() - def stream(component) do - create_recurrence_stream(component, nil, component.rrule) - end - - @spec stream(recurrable_component, %Date{} | %DateTime{}) :: Enumerable.t() - def stream(component, end_date) do - create_recurrence_stream(component, end_date, component.rrule) - end - - # no occurences, so simply drop out, and return the component itself as the only recurrence - defp create_recurrence_stream(_component, _end_date, nil) do - Stream.transform([], [], fn _, acc -> {:halt, acc} end) - end - - defp create_recurrence_stream(component, end_date, rule) do - references = - Map.from_struct(rule) - |> Map.take(@supported_by_x_rrules) - |> build_references_by_x_rules(component) - - # Two types of recurrence are supported: by count or until, with until being the default - # If not until date is specifically provided, then the end_date is used - # An interval may be given, which alters the amount the date is shifted by - case rule do - %__MODULE__{frequency: frequency, count: count, interval: interval} when count != nil -> - # the main component counts as 1 occurance, so look for `count - 1` more - add_recurrences_for_count( - component, - references, - count - 1, - shift_opts(frequency, interval) - ) - - %__MODULE__{frequency: frequency, until: until, interval: interval} -> - add_recurrences_until( - component, - references, - until || resolve_end_date(end_date, component), - shift_opts(frequency, interval) - ) - end - end - - # The end date and the original's dtstart must be the same sort of date - # The user *should* take care of this, but let's not expect to much of ourselves - # and instead ensure that they match! - defp resolve_end_date(end_date, %{dtstart: match_to}), do: resolve_end_date(end_date, match_to) - defp resolve_end_date(%x{} = end_date, %x{}), do: end_date - defp resolve_end_date(nil, %Date{}), do: DateTime.to_date(DateTime.utc_now()) - defp resolve_end_date(nil, %DateTime{}), do: DateTime.utc_now() + @spec stream(t() | recurrable_component, options :: [stream_option()]) :: Enumerable.t() + def stream(component, options \\ []) - defp resolve_end_date(%Date{} = end_date, %DateTime{} = match_to) do - DateTime.new(end_date, ~T[00:00:00], match_to.time_zone) + def stream(%__MODULE__{} = rule, options) do + Generate.init(rule, options) + |> create_stream() end - defp resolve_end_date(%DateTime{} = end_date, %Date{}), do: DateTime.to_date(end_date) - - defp shift_opts(:daily, interval), do: [day: interval] - defp shift_opts(:weekly, interval), do: [week: interval] - defp shift_opts(:monthly, interval), do: [month: interval] - defp shift_opts(:yearly, interval), do: [year: interval] - - defp add_recurrences_until(original_event, references, until, shift_opts) do + def stream(%{rrule: rule, dtstart: start_date} = component, _options) + when is_nil(rule) or is_nil(start_date) do + # this creates a stream with only the rdates of the component, if any, + # when the component lacks a rule or a start date Stream.resource( - fn -> references end, - fn references -> - next_recurring_event_until( - references, - original_event, - until, - shift_opts - ) + fn -> Map.get(component, :rdates) || [] end, + fn + nil -> {:halt, nil} + rdates -> {rdates, nil} end, - fn recurrences -> recurrences end + fn state -> state end ) end - defp next_recurring_event_until([], _original_event, _until, _shift_opts) do - {:halt, []} + def stream(%{rrule: rule, dtstart: start_date} = component, options) do + all_options = + options + |> Keyword.put_new_lazy(:include, fn -> default_option(component, :rdates) end) + |> Keyword.put_new_lazy(:exclude, fn -> default_option(component, :exdates) end) + |> Keyword.put_new(:start_date, start_date) + + Generate.init(rule, all_options) + |> create_stream() end - defp next_recurring_event_until( - [reference_event | remaining_references], - original_event, - until, - shift_opts - ) do - new_event = shift(reference_event, shift_opts) - - case compare(new_event.dtstart, until) do - :gt -> - {:halt, {[], []}} - - _ -> - references = remaining_references ++ [new_event] - - if exclude?(new_event, original_event) do - next_recurring_event_until( - references, - original_event, - until, - shift_opts - ) - else - {[new_event], references} - end + defp default_option(component, key) do + case Map.get(component, key) do + [] -> nil + nil -> nil + rdates -> rdates end end - defp add_recurrences_for_count(original_event, references, count, shift_opts) do - Stream.resource( - fn -> {references, count} end, - fn {references, count} -> - next_recurring_event(references, count, original_event, shift_opts) - end, - fn recurrences -> recurrences end - ) - end + defp diff(%Date{} = l, r), do: [day: Date.diff(l, r)] + defp diff(%DateTime{} = l, r), do: [second: DateTime.diff(l, r)] - defp next_recurring_event(_references, count, _original_event, _shift_opts) - when count < 1 do - {:halt, {[], 0}} - end + defp offset(%DateTime{} = l, offset), do: DateTime.shift(l, offset) - defp next_recurring_event([], _count, _original_event, _shift_opts) do - {:halt, {[], 0}} + defp offset(%Date{} = l, second: seconds) do + days = Integer.floor_div(seconds, 60 * 60 * 24) + Date.shift(l, day: days) end - defp next_recurring_event( - [reference_event | remaining_references], - count, - original_event, - shift_opts - ) do - new_event = shift(reference_event, shift_opts) - references = remaining_references ++ [new_event] - - if exclude?(new_event, original_event) do - next_recurring_event( - references, - count, - original_event, - shift_opts - ) - else - {[new_event], {references, count - 1}} - end + defp offset(%Date{} = l, offset) do + Date.shift(l, offset) end - defp shift(%{dtstart: starts, dtend: ends} = component, shift_opts) do - Map.merge(component, %{ - dtstart: shift_date(starts, shift_opts), - dtend: shift_date(ends, shift_opts) - }) - end + defp nil_or_positive(value) when is_integer(value) and value > 0, do: value + defp nil_or_positive(_), do: nil - defp shift(%{dtstart: starts} = component, shift_opts) do - Map.merge(component, %{ - dtstart: shift_date(starts, shift_opts) - }) - end + defp positive(value, _default) when is_integer(value) and value > 0, do: value + defp positive(_, default), do: default - defp shift_date(%Date{} = date, shift_opts), do: Date.shift(date, shift_opts) - defp shift_date(%DateTime{} = date, shift_opts), do: DateTime.shift(date, shift_opts) + defp clamped_numbers(nil, _min, __max), do: nil - defp build_references_by_x_rules(by_x_rrules, component) when by_x_rrules == %{} do - [component] + defp clamped_numbers(numbers, min, max) do + numbers + |> Enum.sort() + |> Enum.uniq() + |> Enum.reduce( + [], + fn number, acc -> + case number do + 0 when min == 0 -> + acc ++ [0] + + number when is_number(number) and number != 0 and number <= max and number >= min -> + acc ++ [number] + + _ -> + acc + end + end + ) end - defp build_references_by_x_rules(by_x_rrules, component) do - by_x_rrules - |> Enum.map(fn {by_x, entries} -> - build_references_by_x_rule(component, by_x, entries) - end) - |> List.flatten() + defp normalize_weekdays(nil, _week_start) do + nil end - defp build_references_by_x_rule(component, _by_x, nil), do: [component] - - defp build_references_by_x_rule(component, :by_day, entries) do - day_values = %{ - monday: 1, - tuesday: 2, - wednesday: 3, - thursday: 4, - friday: 5, - saturday: 6, - sunday: 7 - } + defp normalize_weekdays(weekdays, week_start) do + valid_weekdays = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] + + weekday_order = + if week_start == nil do + valid_weekdays + else + index = Enum.find_index(valid_weekdays, fn wk -> wk == week_start end) || 0 + {l, r} = Enum.split(valid_weekdays, max(0, index)) + r ++ l + end + |> Enum.with_index() + |> Enum.into(%{}) - entries - |> Enum.sort(fn {loffset, lday}, {roffset, rday} -> + weekdays + |> Enum.uniq() + |> Enum.sort(fn {loffset, l}, {roffset, r} -> if loffset == roffset do - Map.get(day_values, lday) <= Map.get(day_values, rday) + Map.get(weekday_order, l) < Map.get(weekday_order, r) else - loffset <= roffset + l_is_neg = loffset < 0 + r_is_neg = roffset < 0 + + if l_is_neg == r_is_neg do + loffset < roffset + else + r_is_neg + end end end) - |> Enum.map(fn {_offset, by_day} -> - # TODO: support offsets other than the trivial case of 0 - # determine the difference between the by_day and dtstart - day_offset_for_reference = Map.get(day_values, by_day) - Date.day_of_week(component.dtstart) - shift(component, day: day_offset_for_reference) - end) end - defp compare(%Date{} = l, r), do: Date.compare(l, r) - defp compare(%DateTime{} = l, r), do: DateTime.compare(l, r) + defp create_stream(state) do + Stream.resource( + fn -> {[], state} end, + fn state -> next_recurring_event(state) end, + fn state -> state end + ) + end + + defp next_recurring_event({[], %State{limit: :reached}} = state) do + {:halt, state} + end - defp exclude?(recurrence, original) do - # 1. The component doesn't fall on an EXDATE - # 2. The recurrence is not before the original component (created as a reference) - recurrence.dtstart in original.exdates or - compare_dates(recurrence.dtstart, original.dtstart) == :lt + defp next_recurring_event({[], %State{} = generate_state}) do + generate_state + |> Generate.one_set() + |> next_recurring_event() end - defp compare_dates(%Date{} = l, r), do: Date.compare(l, r) - defp compare_dates(%DateTime{} = l, r), do: Date.compare(l, r) + defp next_recurring_event({recurrences, %State{} = generate_state}) do + {recurrences, {[], generate_state}} + end end diff --git a/lib/ical/recurrence/generate.ex b/lib/ical/recurrence/generate.ex new file mode 100644 index 0000000..971b77b --- /dev/null +++ b/lib/ical/recurrence/generate.ex @@ -0,0 +1,855 @@ +defmodule ICal.Recurrence.Generate do + @moduledoc false + + require Logger + alias ICal.Recurrence.State + + @fruitless_search_start_count 0 + @max_fruitless_search_depth 1000 + + defguard has_some(x) when is_list(x) and x != [] + defguard has_none(x) when not has_some(x) + + @spec init( + ICal.Recurrence.t(), + options :: [ICal.Recurrence.stream_option()] + ) :: State.t() + def init(rule, options) do + other_recurrences = + options + |> resolve_option(:include, []) + |> Enum.sort(&compare_recurrences/2) + + start_date = resolve_option(options, :start_date, DateTime.utc_now()) + + %State{ + earliest_date: start_date, + start_date: start_date, + interval: rule_interval(rule), + modifiers: rule_modifiers(rule), + rule: rule, + exclude_dates: resolve_option(options, :exclude, []), + other_recurrences: other_recurrences, + error: :none + } + |> add_rule_limits(rule, Keyword.get(options, :end_date)) + end + + @spec all(ICal.Recurrence.t(), options :: [ICal.Recurrence.stream_option()]) :: + {:ok, [ICal.Recurrence.recurrence_date()]} + | {:error, State.error_reason(), [ICal.Recurrence.recurrence_date()]} + def all(rule, options) do + rule + |> init(options) + |> generate_all() + end + + @spec one_set(State.t()) :: {[ICal.Recurrence.recurrence_date()], State.t()} + def one_set(%State{} = state) do + generate_set(state) + end + + defp resolve_option(options, key, default) do + case Keyword.get(options, key) do + nil -> default + value -> value + end + end + + defp rule_interval(%ICal.Recurrence{frequency: :yearly, interval: interval}) do + {:date, [year: interval]} + end + + defp rule_interval(%ICal.Recurrence{frequency: :monthly, interval: interval}) do + {:date, [month: interval]} + end + + defp rule_interval(%ICal.Recurrence{frequency: :weekly, interval: interval}) do + {:date, [week: interval]} + end + + defp rule_interval(%ICal.Recurrence{frequency: :daily, interval: interval}) do + {:date, [day: interval]} + end + + defp rule_interval(%ICal.Recurrence{frequency: :hourly, interval: interval}) do + {:time, [hour: interval]} + end + + defp rule_interval(%ICal.Recurrence{frequency: :minutely, interval: interval}) do + {:time, [minute: interval]} + end + + defp rule_interval(%ICal.Recurrence{frequency: :secondly, interval: interval}) do + {:time, [second: interval]} + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :yearly} = rule) do + week_number_application = + if has_some(rule.by_month), do: :limit, else: :expand + + year_day_appliaction = + if has_some(rule.by_month) or has_some(rule.by_week_number), do: :limit, else: :expand + + by_day_application = + cond do + has_some(rule.by_year_day) -> :limit + has_some(rule.by_month_day) -> :limit + has_some(rule.by_week_number) -> :limit + has_some(rule.by_month) -> :expand_month + true -> :expand_year + end + + [ + {:by_month, :expand}, + {:by_week_number, week_number_application}, + {:by_year_day, year_day_appliaction}, + {:by_month_day, :expand}, + {:by_day, by_day_application}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :monthly} = rule) do + by_day_application = if has_some(rule.by_month_day), do: :limit, else: :expand_month + + [ + {:by_month, :limit}, + {:by_month_day, :expand}, + {:by_day, by_day_application}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :weekly}) do + [ + {:by_month, :limit}, + {:by_day, :expand_week}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :daily}) do + [ + {:by_month, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :expand}, + {:by_minute, :expand}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :hourly}) do + [ + {:by_month, :limit}, + {:by_year_day, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :limit}, + {:by_minute, :expand}, + {:by_set_position, :limit} + ] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :minutely}) do + [ + {:by_month, :limit}, + {:by_year_day, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :limit}, + {:by_minute, :limit}, + {:by_second, :expand}, + {:by_set_position, :limit} + ] + end + + defp rule_modifiers(%ICal.Recurrence{frequency: :secondly}) do + [ + {:by_month, :limit}, + {:by_year_day, :limit}, + {:by_month_day, :limit}, + {:by_day, :limit}, + {:by_hour, :limit}, + {:by_minute, :limit}, + {:by_set_position, :limit} + ] + end + + @spec generate_all(State.t()) :: + {:ok, [ICal.Recurrence.recurrence_date()]} + | {:error, State.error_reason(), [ICal.Recurrence.recurrence_date()]} + defp generate_all(state) do + generate_all(state, []) + end + + defp generate_all(%State{limit: nil}, acc) do + {:error, :no_defined_limit, acc} + end + + defp generate_all(%State{limit: limit}, acc) when is_integer(limit) and limit < 1 do + {:ok, acc} + end + + defp generate_all(%State{} = state, acc) do + {recurrences, new_state} = generate_set(state) + + case new_state do + %{limit: :reached, error: :none} -> {:ok, acc ++ recurrences} + %{limit: :reached, error: error} -> {:error, error, acc ++ recurrences} + new_state -> generate_all(new_state, acc ++ recurrences) + end + end + + defp generate_set(%{fruitless_searches: fruitless_searches, rule: rule} = state) + when fruitless_searches > @max_fruitless_search_depth do + Logger.warning("Could not find all recurrences of #{inspect(rule)} due to search exhaustion") + {[], %{state | limit: :reached, error: :search_exhaustion}} + end + + defp generate_set(%State{} = state) do + recurrences = + [state.start_date] + |> apply_all_modifiers(state) + |> exclude(state) + + new_state = %{state | start_date: shift_interval(state.start_date, state.interval)} + update_limit(recurrences, new_state) + end + + defp apply_all_modifiers(recurrences, %{modifiers: modifiers, rule: rule}) do + Enum.reduce(modifiers, recurrences, fn modifier, acc -> + apply_modifier(modifier, rule, acc) + |> Enum.filter(&date_valid?/1) + |> Enum.sort(&compare_recurrences/2) + end) + end + + defp date_valid?(%Date{} = date) do + Calendar.ISO.valid_date?(date.year, date.month, date.day) + end + + defp date_valid?(%DateTime{} = datetime) do + Calendar.ISO.valid_date?(datetime.year, datetime.month, datetime.day) and + Calendar.ISO.valid_time?(datetime.hour, datetime.minute, datetime.second, {0, 0}) + end + + defp compare_recurrences(%DateTime{} = l, r), do: DateTime.compare(l, r) == :lt + defp compare_recurrences(%Date{} = l, r), do: Date.compare(l, r) == :lt + + defp apply_modifier({:by_month, :expand}, %{by_month: months}, acc) when has_some(months) do + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce(months, acc, fn month, acc -> + [%{recurrence | month: month} | acc] + end) + end) + end + + defp apply_modifier({:by_month, :limit}, %{by_month: months}, acc) when has_some(months) do + Enum.filter(acc, fn recurrence -> + Enum.member?(months, recurrence.month) + end) + end + + defp apply_modifier({:by_week_number, :expand}, %{by_week_number: weeks}, acc) + when has_some(weeks) do + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce(weeks, acc, fn week, acc -> + {first, last} = week_number_bookends(recurrence, week) + + acc ++ range(first, last, recurrence) + end) + end) + end + + defp apply_modifier({:by_week_number, :limit}, %{by_week_number: weeks}, acc) + when has_some(weeks) do + Enum.filter(acc, fn recurrence -> + Enum.find(weeks, fn week -> + {week_start, week_end} = week_number_bookends(recurrence, week) + inclusively_between?(week_start, recurrence, week_end) + end) != nil + end) + end + + defp apply_modifier({:by_year_day, :expand}, %{by_year_day: year_days}, acc) + when has_some(year_days) do + Enum.uniq_by(acc, fn recurrence -> recurrence.year end) + |> Enum.reduce([], fn recurrence, acc -> + first_of_jan = %{recurrence | month: 1, day: 1} + + Enum.reduce(year_days, acc, fn day_of_year, acc -> + [shift_date(first_of_jan, day: day_of_year - 1) | acc] + end) + end) + end + + defp apply_modifier({:by_year_day, :limit}, %{by_year_day: year_days}, acc) + when has_some(year_days) do + Enum.filter(acc, fn recurrence -> + Enum.member?(year_days, Date.day_of_year(recurrence)) + end) + end + + defp apply_modifier({:by_month_day, :expand}, %{by_month_day: month_days}, acc) + when has_some(month_days) do + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce(month_days, acc, fn month_day, acc -> + first = %{recurrence | day: 1} + + date = + if month_day > 0 do + shift_date(first, day: month_day - 1) + else + end_day = days_in_month(recurrence) + shift_date(first, day: end_day + month_day) + end + + if date.month == recurrence.month do + [date | acc] + else + acc + end + end) + end) + end + + defp apply_modifier({:by_month_day, :limit}, %{by_month_day: month_days}, acc) + when has_some(month_days) do + Enum.filter(acc, fn recurrence -> + Enum.member?(month_days, recurrence.day) + end) + end + + defp apply_modifier({:by_day, :expand_year}, %{by_day: weekdays}, acc) + when has_some(weekdays) do + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce(weekdays, acc, fn + {offset, weekday}, acc when offset >= 0 -> + order = weekday_order() + first_of_jan = %{recurrence | month: 1, day: 1} + first_week_day = order[weekday(first_of_jan)] + weekday_order = order[weekday] + # calculate when the first of this day occurs in the month + first = + case first_week_day - weekday_order do + diff when diff < 0 -> diff + 7 + diff -> 8 - diff + end + + # first day of the year this weekday appears + first_occurance = %{first_of_jan | day: first} + + # having found the first day in the year, move forward offset less one + # more weeks + offset = max(0, offset - 1) + + next = shift_date(first_occurance, week: offset) + + if next.year == recurrence.year do + [next | acc] + else + acc + end + + {offset, weekday}, acc -> + order = weekday_order() + dec_31 = %{recurrence | month: 12, day: 31} + last_week_day = order[weekday(dec_31)] + weekday_order = order[weekday] + + # the last day in the month for this weekday + last = + case weekday_order - last_week_day do + diff when diff < 1 -> 31 + diff + diff -> 24 + diff + end + + # last day in the year this weekday appears + last_occurance = %{dec_31 | day: last} + + next = shift_date(last_occurance, week: offset + 1) + + if next.year == recurrence.year do + [next | acc] + else + acc + end + end) + end) + end + + defp apply_modifier({:by_day, :expand_month}, %{by_day: weekdays}, acc) + when has_some(weekdays) do + Enum.reduce(acc, [], fn recurrence, acc -> + order = weekday_order() + + first_week_day = order[weekday(%{recurrence | day: 1})] + + Enum.reduce( + weekdays, + acc, + fn + {0, weekday}, acc -> + weekday_order = order[weekday] + + # calculate when the first of this day occurs in the month + # if the weekday order is before the first weekday + # of the month, then it appears one week later minus the difference + # if the weekday order is after, then it's just the diff + 1 days in + first = + case first_week_day - weekday_order do + diff when diff <= 0 -> abs(diff) + 1 + diff -> 8 - diff + end + + acc ++ generate_all_weekdays_in_month([%{recurrence | day: first}]) + + {offset, weekday}, acc when offset > 0 -> + first_day = %{recurrence | day: 1} + month_starts = order[weekday(first_day)] + days_in_month = days_in_month(recurrence) + shift_days = Integer.mod(order[weekday] - month_starts, 7) + 7 * (offset - 1) + + if shift_days >= days_in_month do + acc + else + [shift_date(first_day, day: shift_days) | acc] + end + + {offset, weekday}, acc -> + last_day = %{recurrence | day: days_in_month(recurrence)} + month_ends = order[weekday(last_day)] + + # shift by the difference between the month end weekday and the target weekday, + # plus one week per additional offset + shift_days = + Integer.mod(month_ends - order[weekday], 7) + 7 * (abs(offset) - 1) + + if shift_days >= last_day.day do + acc + else + [shift_date(last_day, day: -shift_days) | acc] + end + end + ) + end) + end + + defp apply_modifier( + {:by_day, :expand_week}, + %{by_day: weekdays, week_start_day: week_start_day}, + acc + ) + when has_some(weekdays) do + Enum.reduce(acc, [], fn recurrence, acc -> + order = weekday_order() + first_week_day = beginning_of_week(recurrence, week_start_day) + week_start_ordinal = order[weekday(first_week_day)] + + Enum.reduce( + weekdays, + acc, + fn {_, weekday}, acc -> + # mod by 7 in case of negative difference + shift_days = Integer.mod(order[weekday] - week_start_ordinal, 7) + [shift_date(first_week_day, day: shift_days) | acc] + end + ) + end) + end + + defp apply_modifier({:by_day, :limit}, %{by_day: weekdays}, acc) when has_some(weekdays) do + Enum.filter(acc, fn recurrence -> + target = weekday(recurrence) + Enum.find(weekdays, fn {_, allowed_day} -> allowed_day == target end) != nil + end) + end + + defp apply_modifier({:by_hour, :expand}, %{by_hour: hours}, acc) when has_some(hours) do + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce( + hours, + acc, + fn hour, acc -> + date = + case recurrence do + %Date{} = date -> DateTime.new!(date, Time.new!(hour, 0, 0)) + %DateTime{} = date -> %{date | hour: hour} + end + + [date | acc] + end + ) + end) + end + + defp apply_modifier({:by_hour, :limit}, %{by_hour: hours}, acc) when has_some(hours) do + Enum.filter(acc, fn recurrence -> + case recurrence do + %DateTime{hour: hour} -> Enum.member?(hours, hour) + _ -> false + end + end) + end + + defp apply_modifier({:by_minute, :expand}, %{by_minute: minutes}, acc) when has_some(minutes) do + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce( + minutes, + acc, + fn minute, acc -> + date = + case recurrence do + %Date{} = date -> DateTime.new!(date, Time.new!(0, minute, 0)) + %DateTime{} = date -> %{date | minute: minute} + end + + [date | acc] + end + ) + end) + end + + defp apply_modifier({:by_minute, :limit}, %{by_minute: minutes}, acc) when has_some(minutes) do + Enum.filter(acc, fn recurrence -> + case recurrence do + %DateTime{minute: minute} -> Enum.member?(minutes, minute) + _ -> false + end + end) + end + + defp apply_modifier({:by_second, :expand}, %{by_second: seconds}, acc) when has_some(seconds) do + Enum.reduce(acc, [], fn recurrence, acc -> + Enum.reduce( + seconds, + acc, + fn second, acc -> + date = + case recurrence do + %Date{} = date -> DateTime.new!(date, Time.new!(0, 0, second)) + %DateTime{} = date -> %{date | second: second} + end + + [date | acc] + end + ) + end) + end + + defp apply_modifier({:by_second, :limit}, %{by_second: seconds}, acc) when has_some(seconds) do + Enum.filter(acc, fn recurrence -> + case recurrence do + %DateTime{second: second} -> Enum.member?(seconds, second) + _ -> false + end + end) + end + + defp apply_modifier({:by_set_position, :limit}, %{by_set_position: positions}, recurrences) + when has_some(positions) do + Enum.reduce(positions, [], fn index, acc -> + index = if index > 0, do: index - 1, else: index + + case Enum.at(recurrences, index) do + nil -> acc + recurrence -> [recurrence | acc] + end + end) + end + + defp apply_modifier(_, _rule, acc), do: acc + + defp exclude(recurrences, %{earliest_date: earliest, exclude_dates: exclude_dates}) do + in_set = + if has_some(exclude_dates) do + Enum.filter(recurrences, fn recurrence -> + not in_dates?(exclude_dates, recurrence) + end) + else + recurrences + end + + index = + Enum.find_index(in_set, fn recurrence -> + not before?(recurrence, earliest) + end) + + case index do + nil -> [] + 0 -> in_set + index -> Enum.slice(in_set, index..-1//1) + end + end + + defp in_dates?(all_dates, recurrence) do + Enum.reduce(all_dates, false, fn date, acc -> acc or equal?(date, recurrence) end) + end + + defp include_all_other(recurrences, %{other_recurrences: []} = state) do + {recurrences, state} + end + + defp include_all_other(recurrences, %{other_recurrences: other_recurrences} = state) do + { + Enum.sort(recurrences ++ other_recurrences, &compare_recurrences/2), + %{state | other_recurrences: []} + } + end + + defp include_other(recurrences, %{limit: :reached} = state) do + {recurrences, remaining_other} = merge_other(recurrences, state.other_recurrences) + include_all_other(recurrences, %{state | other_recurrences: remaining_other}) + end + + defp include_other(recurrences, %{other_recurrences: [_ | _] = other_recurrences} = state) do + {recurrences, remaining_other} = merge_other(recurrences, other_recurrences) + {recurrences, %{state | other_recurrences: remaining_other}} + end + + defp include_other(recurrences, state) do + {recurrences, state} + end + + @spec merge_other( + recurrences :: [ICal.Recurrence.recurrence_date()], + other :: [ICal.Recurrence.recurrence_date()] + ) :: + {merged_recurrences :: [ICal.Recurrence.recurrence_date()], + remaining_other :: [ICal.Recurrence.recurrence_date()]} + defp merge_other(recurrences, []) do + {recurrences, []} + end + + defp merge_other(recurrences, other_recurrences) do + Enum.reduce(recurrences, {[], other_recurrences}, fn + recurrence, {acc, []} -> + {acc ++ [recurrence], []} + + recurrence, {acc, other_recurrences} -> + # other_recurrences is sorted, so only compare until a failure + index = + Enum.reduce_while(other_recurrences, -1, fn other_recurrence, index -> + if after?(recurrence, other_recurrence) do + {:cont, index + 1} + else + {:halt, index} + end + end) + + if index > -1 do + {inclusions, other_recurrences} = Enum.split(other_recurrences, index + 1) + {acc ++ inclusions ++ [recurrence], other_recurrences} + else + {acc ++ [recurrence], other_recurrences} + end + end) + end + + defp add_rule_limits(state, %{count: count}, end_date) when is_integer(count) do + %{state | limit: count, end_date: end_date} + end + + defp add_rule_limits(state, %{until: until}, end_date) do + # when until is a date time, set the until into the same timezone as the start date + # it can not just be shifted, as the literal values should remain as they are + until = + case until do + %DateTime{} -> + DateTime.new!( + DateTime.to_date(until), + DateTime.to_time(until), + state.earliest_date.time_zone + ) + + _ -> + until + end + + if end_date != nil and (until == nil or after?(until, end_date)) do + %{state | limit: end_date, end_date: end_date} + else + %{state | limit: until, end_date: end_date} + end + end + + defp beginning_of_week(%DateTime{} = date, start) do + DateTime.new!( + Date.beginning_of_week(DateTime.to_date(date), start), + DateTime.to_time(date), + date.time_zone + ) + end + + defp beginning_of_week(%Date{} = date, start), do: Date.beginning_of_week(date, start) + + defp weekday_order do + %{monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 7} + end + + defp weekday(%Date{} = date) do + index_date = Date.day_of_week(date) + days = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday] + Enum.at(days, index_date - 1) + end + + defp weekday(%DateTime{} = dt), do: weekday(DateTime.to_date(dt)) + + defp generate_all_weekdays_in_month([last | _] = acc) do + next = shift_date(last, week: 1) + + if next.month == last.month do + generate_all_weekdays_in_month([next | acc]) + else + acc + end + end + + # when no more recurrences are generated for too long, then stop even if it could in theory + # go further. + defp update_limit([], state) do + {[], %{state | fruitless_searches: state.fruitless_searches + 1}} + end + + defp update_limit(recurrences, %{limit: limit} = state) when is_integer(limit) do + updated_limit = limit - Enum.count(recurrences) + + if updated_limit < 1 do + recurrences + |> Enum.slice(0, limit) + |> include_other(%{state | limit: :reached}) + else + new_state = %{ + state + | limit: updated_limit, + fruitless_searches: @fruitless_search_start_count + } + + update_limit_by_date(recurrences, new_state.end_date, new_state) + end + end + + defp update_limit(recurrences, %{limit: limit_date} = state) do + new_state = %{state | fruitless_searches: @fruitless_search_start_count} + + update_limit_by_date(recurrences, limit_date, new_state) + end + + defp update_limit_by_date(recurrences, nil, state) do + include_other(recurrences, state) + end + + defp update_limit_by_date(recurrences, limit_date, state) do + index = + Enum.find_index(recurrences, fn recurrence -> after?(recurrence, limit_date) end) + + if index != nil do + recurrences + |> Enum.slice(0, index) + |> include_other(%{state | limit: :reached}) + else + include_other(recurrences, state) + end + end + + def range(first, last, %Date{}) do + Date.range(first, last) |> Enum.to_list() + end + + def range(first, last, %DateTime{} = dt) do + time = DateTime.to_time(dt) + Date.range(first, last) |> Enum.map(fn date -> DateTime.new!(date, time, dt.time_zone) end) + end + + defp shift_interval(date, {:date, interval}), do: shift_date(date, interval) + + defp shift_interval(date, {:time, interval}) do + case date do + %Date{} -> DateTime.new!(date, Time.new!(0, 0, 0), "Etc/UTC") |> DateTime.shift(interval) + %DateTime{} -> DateTime.shift(date, interval) + end + end + + defp shift_date(%DateTime{} = date, interval) do + shifted = DateTime.shift(date, interval) + %{shifted | hour: date.hour, minute: date.minute, second: date.second} + end + + defp shift_date(%Date{} = date, interval), do: Date.shift(date, interval) + + def week_number_bookends(date, week) do + # shift the week + if week > 0 do + # positive week number, start from first w of the year + end_date = + Date.new!(date.year, 1, 1) + |> Date.end_of_week() + |> ensure_end_of_first_week() + |> Date.shift(week: week - 1) + + start_date = Date.beginning_of_week(end_date) + + add_time_if_exists(date, start_date, end_date) + else + # negative week number, start from the last week of the year + # and since it is already on the last week, move one less week than requested + # e.g. the -1 week is 0 weeks from the last week of the year + start_date = + Date.new!(date.year + 1, 1, 1) + |> Date.end_of_week() + |> Date.shift(day: 1) + |> Date.shift(week: week) + + end_date = start_date |> Date.end_of_week() + + add_time_if_exists(date, start_date, end_date) + end + end + + defp add_time_if_exists(%DateTime{} = date, start_date, end_date) do + { + DateTime.new!(start_date, DateTime.to_time(date), date.time_zone), + DateTime.new!(end_date, DateTime.to_time(date), date.time_zone) + } + end + + defp add_time_if_exists(_, start_date, end_date), do: {start_date, end_date} + + defp days_in_month(%Date{} = date), do: Date.days_in_month(date) + defp days_in_month(date), do: Date.days_in_month(DateTime.to_date(date)) + + # the first week is considered the one with at least 4 days + # so if the end of the first week is 3 or less, then bump it by a week + defp ensure_end_of_first_week(%{day: day} = date) when day < 4, do: Date.shift(date, week: 1) + defp ensure_end_of_first_week(day), do: day + + defp inclusively_between?(earliest, middle, latest) do + not after?(earliest, middle) and not after?(middle, latest) + end + + defp equal?(%Date{} = d, %DateTime{} = dt), do: equal?(d, DateTime.to_date(dt)) + defp equal?(%DateTime{} = dt, %Date{} = d), do: equal?(DateTime.to_date(dt), d) + defp equal?(l, r), do: l == r + + defp before?(%Date{} = d, %DateTime{} = dt), do: before?(d, DateTime.to_date(dt)) + defp before?(%DateTime{} = dt, %Date{} = d), do: before?(DateTime.to_date(dt), d) + defp before?(%Date{} = l, r), do: Date.before?(l, r) + defp before?(%DateTime{} = l, r), do: DateTime.before?(l, r) + + defp after?(%Date{} = d, %DateTime{} = dt), do: after?(d, DateTime.to_date(dt)) + defp after?(%DateTime{} = dt, %Date{} = d), do: after?(DateTime.to_date(dt), d) + defp after?(%Date{} = l, r), do: Date.after?(l, r) + defp after?(%DateTime{} = l, r), do: DateTime.after?(l, r) +end diff --git a/lib/ical/recurrence/state.ex b/lib/ical/recurrence/state.ex new file mode 100644 index 0000000..9e35844 --- /dev/null +++ b/lib/ical/recurrence/state.ex @@ -0,0 +1,44 @@ +defmodule ICal.Recurrence.State do + @moduledoc false + + defstruct [ + :limit, + :earliest_date, + :start_date, + :end_date, + :interval, + :modifiers, + :rule, + exclude_dates: nil, + other_recurrences: nil, + fruitless_searches: 0, + error: :none + ] + + @type recurrence_date :: Date.t() | DateTime.t() + @type error_reason :: :none | :search_exhaustion | :no_defined_limit + @type modifier_scope :: + :by_month + | :by_week_number + | :by_year_day + | :by_month_day + | :by_day + | :by_hour + | :by_minute + | :by_second + | :by_set_position + @type modifier_mode :: :limit | :expand | :expand_week | :expand_month | :expand_year + + @type t :: %__MODULE__{ + earliest_date: recurrence_date(), + end_date: recurrence_date() | nil, + error: error_reason(), + exclude_dates: [recurrence_date()], + fruitless_searches: non_neg_integer(), + interval: {:date | :time, Duration.duration()}, + limit: :reached | non_neg_integer() | recurrence_date(), + modifiers: [{modifier_scope, modifier_mode}], + rule: ICal.Recurrence.t(), + start_date: recurrence_date() + } +end diff --git a/lib/ical/serialize/recurrence.ex b/lib/ical/serialize/recurrence.ex index c541f6a..2d53026 100644 --- a/lib/ical/serialize/recurrence.ex +++ b/lib/ical/serialize/recurrence.ex @@ -18,6 +18,7 @@ defmodule ICal.Serialize.Recurrence do # skip empty entries defp to_rrule_entry({_, nil}, acc), do: acc + defp to_rrule_entry({:week_start_day, :default}, acc), do: acc # everything else! defp to_rrule_entry({key, _} = rrule, acc) do @@ -65,7 +66,7 @@ defmodule ICal.Serialize.Recurrence do defp key_to_string(:by_month), do: "BYMONTH" defp key_to_string(:by_set_position), do: "BYSETPOS" defp key_to_string(:by_week_number), do: "BYWEEKNO" - defp key_to_string(:weekday), do: "WKST" + defp key_to_string(:week_start_day), do: "WKST" # :frequency is handled manually in `to_ics` # defp key_to_string(:frequency), do: "FREQ" defp key_to_string(:interval), do: "INTERVAL" diff --git a/mix.exs b/mix.exs index 5f7e0cc..29c1cb8 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule ICal.Mixfile do use Mix.Project @source_url "https://github.com/expothecary/ical" - @version "1.1.2" + @version "2.0.0" def project do [ @@ -76,7 +76,9 @@ defmodule ICal.Mixfile do source_ref: "v#{@version}", formatters: ["html"], groups_for_modules: [ - "Calendar Entries": [ICal.Alarm, ICal.Event, ICal.Journal, ICal.Timezone, ICal.Todo], + Components: [ICal.Alarm, ICal.Event, ICal.Journal, ICal.Timezone, ICal.Todo], + Recurrences: [ICal.Recurrence], + Alarms: [~r/ICal.Alarm.*/], Properties: [ ICal.Attachment, ICal.Attendee, @@ -84,9 +86,7 @@ defmodule ICal.Mixfile do ICal.Duration, ICal.RequestStatus, ICal.Timezone.Properties - ], - Alarms: [~r/ICal.Alarm.*/], - Utilities: [ICal.Recurrence] + ] ] ] end diff --git a/mix.lock b/mix.lock index 9bb72c8..8144219 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, + "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, diff --git a/test/ical/recurrence_test.exs b/test/ical/recurrence_test.exs index b74c331..1efcb8a 100644 --- a/test/ical/recurrence_test.exs +++ b/test/ical/recurrence_test.exs @@ -4,307 +4,1610 @@ defmodule ICal.RecurrenceTest do alias ICal.Test.Fixtures alias ICal.Test.Helper - test "ICalender.to_ics/1 with rrule" do - ics = - Fixtures.recurrence_event() - |> ICal.to_ics() - |> to_string() - - # Extract RRULE line for comparison (parameter order doesn't matter per RFC 5545) - [rrule_line] = Regex.run(~r/RRULE:(.+)/, ics, capture: :all_but_first) - - rrule_params = - rrule_line - |> String.split(";") - |> MapSet.new() - - expected_params = - MapSet.new([ - "BYDAY=WE1FR-2SA", - "BYHOUR=3", - "BYMINUTE=2", - "BYMONTH=10", - "BYMONTHDAY=6", - "BYSECOND=1", - "BYSETPOS=20", - "BYWEEKNO=-1", - "BYYEARDAY=7,8,9", - "COUNT=3", - "FREQ=DAILY", - "INTERVAL=1", - "UNTIL=20191124T084500Z", - "WKST=MONDAY" - ]) - - assert rrule_params == expected_params + doctest ICal.Recurrence + + describe "RRULE: serialization" do + test "Serializes correctly" do + ics = + Fixtures.recurrence_event() + |> ICal.to_ics() + |> to_string() + + # Extract RRULE line for comparison (parameter order doesn't matter per RFC 5545) + [rrule_line] = Regex.run(~r/RRULE:(.+)/, ics, capture: :all_but_first) + + rrule_params = + rrule_line + |> String.split(";") + |> MapSet.new() + + expected_params = + MapSet.new([ + "BYDAY=WE1FR-2SA", + "BYHOUR=3", + "BYMINUTE=2", + "BYMONTH=10", + "BYMONTHDAY=6", + "BYSECOND=1", + "BYSETPOS=20", + "BYWEEKNO=-1", + "BYYEARDAY=7,8,9", + "COUNT=3", + "FREQ=DAILY", + "INTERVAL=1", + "UNTIL=20191124T084500Z", + "WKST=MONDAY" + ]) + + assert rrule_params == expected_params + end + + test "weekday abbreviations handled corrrectly" do + rrule = %{"FREQ" => "DAILY", "BYDAY" => "-1SU,SU,1MO,-1TU,+2WE,TH,FR,SA,GA,GARBAGE,,0,-1"} + + recurrence = %ICal.Recurrence{ + frequency: :daily, + by_day: [ + {0, :thursday}, + {0, :friday}, + {0, :saturday}, + {0, :sunday}, + {1, :monday}, + {2, :wednesday}, + {-1, :tuesday}, + {-1, :sunday} + ] + } + + assert recurrence === ICal.Deserialize.Recurrence.from_params(rrule) + + serialized = ICal.Serialize.Recurrence.property(recurrence) |> to_string() + + assert String.starts_with?(serialized, "RRULE:FREQ=DAILY") + assert String.contains?(serialized, ";INTERVAL=1") + assert String.contains?(serialized, ";BYDAY=THFRSASU1MO2WE-1TU-1SU") + assert String.ends_with?(serialized, "\n") + end end - test "event with no recurrences" do - assert [] == - Fixtures.one_event() - |> ICal.Recurrence.stream() - |> Enum.to_list() + describe "RRULE: deserialization" do + test "ignores bad WKST values" do + rrule = %{"FREQ" => "DAILY", "WKST" => "NO"} + + assert %ICal.Recurrence{frequency: :daily, week_start_day: :default} === + ICal.Deserialize.Recurrence.from_params(rrule) + end + + test "clamps time values" do + rrule = %{ + "FREQ" => "DAILY", + "BYSECOND" => "-1,-,0,1,10,50,59,60,70", + "BYMINUTE" => "-1,-,0,1,10,50,59,60,70", + "BYHOUR" => "-1,-,0,1,6,12,23,24" + } + + assert %ICal.Recurrence{ + frequency: :daily, + by_second: [0, 1, 10, 50, 59], + by_minute: [0, 1, 10, 50, 59], + by_hour: [0, 1, 6, 12, 23] + } === ICal.Deserialize.Recurrence.from_params(rrule) + end + + test "clamps day/week/month values" do + rrule = %{ + "FREQ" => "DAILY", + "BYWEEKNO" => "-54,-53,-1,0,a,1,25,2,53,54", + "BYMONTHDAY" => "-32,-31,a,-1,1,31,32", + "BYMONTH" => "0,1,12,a,13", + "BYYEARDAY" => "-367,-366,-1,0,a,,1,366,367,garbage", + "BYSETPOS" => "-367,-366,-1,0,a,,1,366,367" + } + + assert %ICal.Recurrence{ + frequency: :daily, + by_week_number: [-53, -1, 1, 2, 25, 53], + by_month_day: [-31, -1, 1, 31], + by_month: [1, 12], + by_year_day: [-366, -1, 1, 366], + by_set_position: [-366, -1, 1, 366] + } === ICal.Deserialize.Recurrence.from_params(rrule) + end + + test "ignores garbage in count and interval" do + rrule = %{ + "FREQ" => "DAILY", + "COUNT" => "GARBAGE", + "INTERVAL" => "" + } + + assert %ICal.Recurrence{ + frequency: :daily, + count: nil, + interval: 1 + } === ICal.Deserialize.Recurrence.from_params(rrule) + end + + test "parses values of frequency corrrectly" do + rrule = %{"FREQ" => "DAILY"} + + assert %ICal.Recurrence{frequency: :daily} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "WEEKLY"} + + assert %ICal.Recurrence{frequency: :weekly} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "MONTHLY"} + + assert %ICal.Recurrence{frequency: :monthly} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "YEARLY"} + + assert %ICal.Recurrence{frequency: :yearly} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "HOURLY"} + + assert %ICal.Recurrence{frequency: :hourly} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "MINUTELY"} + + assert %ICal.Recurrence{frequency: :minutely} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "SECONDLY"} + + assert %ICal.Recurrence{frequency: :secondly} === + ICal.Deserialize.Recurrence.from_params(rrule) + + rrule = %{"FREQ" => "GARBAGE"} + assert nil === ICal.Deserialize.Recurrence.from_params(rrule) + end end - test "daily reccuring event with until" do - events = - Helper.test_data("recurrance_daily_until") - |> ICal.from_ics() - |> Map.get(:events) - |> Enum.map(fn event -> - recurrences = + describe "Recurrence stream" do + test "correctly handles event with no recurrences" do + assert [] == + Fixtures.one_event() + |> ICal.Recurrence.stream() + |> Enum.to_list() + end + + test "correctly handles event with no recurrence rule, but recurrence dates" do + event = Fixtures.one_event(:with_rdates) + + assert event.rdates == + event + |> ICal.Recurrence.stream() + |> Enum.to_list() + end + + test "correctly handles event with a recurrence rule and recurrence dates" do + [event] = + Helper.test_data("recurrance_with_count") + |> ICal.from_ics() + |> Map.get(:events) + + rdates = [ + ~U[2015-10-23 07:30:00Z], + ~U[2015-10-23 09:30:00Z], + ~U[2015-12-24 12:30:00Z], + ~U[2015-12-25 12:30:00Z], + ~U[2015-12-26 10:30:00Z], + ~U[2015-12-27 09:30:00Z] + ] + + event = %{event | rdates: rdates} + + generated = [ + ~U[2015-12-24 08:30:00Z], + ~U[2015-12-25 08:30:00Z], + ~U[2015-12-26 08:30:00Z] + ] + + expected = Helper.sort_dates(rdates ++ generated) + + assert expected == + event + |> ICal.Recurrence.stream() + |> Enum.to_list() + end + + test "generates daily reccuring event with until" do + recurrences = + Helper.test_data("recurrance_daily_until") + |> ICal.from_ics() + |> Map.get(:events) + |> Enum.map(fn event -> ICal.Recurrence.stream(event) |> Enum.to_list() + end) + |> List.flatten() - [event | recurrences] - end) - |> List.flatten() - - assert events |> Enum.count() == 8 - - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-25 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-26 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-27 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-28 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-29 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-30 08:30:00Z] - [event] = events - assert event.dtstart == ~U[2015-12-31 08:30:00Z] - end + assert Enum.count(recurrences) == 8 + + assert recurrences == [ + ~U[2015-12-24 08:30:00Z], + ~U[2015-12-25 08:30:00Z], + ~U[2015-12-26 08:30:00Z], + ~U[2015-12-27 08:30:00Z], + ~U[2015-12-28 08:30:00Z], + ~U[2015-12-29 08:30:00Z], + ~U[2015-12-30 08:30:00Z], + ~U[2015-12-31 08:30:00Z] + ] + end - test "daily reccuring event with count" do - events = - Helper.test_data("recurrance_with_count") - |> ICal.from_ics() - |> Map.get(:events) - |> Enum.map(fn event -> - recurrences = + test "generates daily reccuring event with count" do + recurrences = + Helper.test_data("recurrance_with_count") + |> ICal.from_ics() + |> Map.get(:events) + |> Enum.map(fn event -> ICal.Recurrence.stream(event) |> Enum.to_list() + end) + |> List.flatten() - [event | recurrences] - end) - |> List.flatten() + assert Enum.count(recurrences) == 3 - assert events |> Enum.count() == 3 + assert recurrences == [ + ~U[2015-12-24 08:30:00Z], + ~U[2015-12-25 08:30:00Z], + ~U[2015-12-26 08:30:00Z] + ] + end - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | _events] = events - assert event.dtstart == ~U[2015-12-25 08:30:00Z] - end - - test "monthly reccuring event with until" do - events = - Helper.test_data("recurrance_with_until_monthly") - |> ICal.from_ics() - |> Map.get(:events) - |> Enum.map(fn event -> - recurrences = + test "generates monthly reccuring event with until" do + recurrences = + Helper.test_data("recurrance_with_until_monthly") + |> ICal.from_ics() + |> Map.get(:events) + |> Enum.map(fn event -> ICal.Recurrence.stream(event) |> Enum.to_list() + end) + |> List.flatten() - [event | recurrences] - end) - |> List.flatten() - - assert events |> Enum.count() == 7 - - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-02-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-03-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-04-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-05-24 08:30:00Z] - [event] = events - assert event.dtstart == ~U[2016-06-24 08:30:00Z] - end + assert Enum.count(recurrences) == 7 - test "weekly reccuring event with until" do - events = - Helper.test_data("recurrance_with_until_weekly") - |> ICal.from_ics() - |> Map.get(:events) - |> Enum.map(fn event -> - recurrences = + assert recurrences == [ + ~U[2015-12-24 08:30:00Z], + ~U[2016-01-24 08:30:00Z], + ~U[2016-02-24 08:30:00Z], + ~U[2016-03-24 08:30:00Z], + ~U[2016-04-24 08:30:00Z], + ~U[2016-05-24 08:30:00Z], + ~U[2016-06-24 08:30:00Z] + ] + end + + test "generates weekly reccuring event with until" do + recurrences = + Helper.test_data("recurrance_with_until_weekly") + |> ICal.from_ics() + |> Map.get(:events) + |> Enum.map(fn event -> ICal.Recurrence.stream(event) |> Enum.to_list() + end) + |> List.flatten() - [event | recurrences] - end) - |> List.flatten() - - assert events |> Enum.count() == 6 - - [event | events] = events - assert event.dtstart == ~U[2015-12-24 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2015-12-31 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-07 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-14 08:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2016-01-21 08:30:00Z] - [event] = events - assert event.dtstart == ~U[2016-01-28 08:30:00Z] - end + assert Enum.count(recurrences) == 6 - test "exdates not included in reccuring event with until and byday, ignoring invalid byday value" do - events = - Helper.test_data("recurrence_until_byday") - |> ICal.from_ics() - |> Map.get(:events) - |> Enum.map(fn event -> - recurrences = + assert recurrences == [ + ~U[2015-12-24 08:30:00Z], + ~U[2015-12-31 08:30:00Z], + ~U[2016-01-07 08:30:00Z], + ~U[2016-01-14 08:30:00Z], + ~U[2016-01-21 08:30:00Z], + ~U[2016-01-28 08:30:00Z] + ] + end + + test "ensures exdates not included in reccuring event with until and byday, ignoring invalid byday value" do + recurrences = + Helper.test_data("recurrence_until_byday") + |> ICal.from_ics() + |> Map.get(:events) + |> Enum.map(fn event -> ICal.Recurrence.stream(event) |> Enum.to_list() + end) + |> List.flatten() + + assert Enum.count(recurrences) == 5 - [event | recurrences] - end) - |> List.flatten() - - assert events |> Enum.count() == 5 - - [event | events] = events - assert event.dtstart == ~U[2020-09-03 14:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2020-09-30 14:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2020-10-01 14:30:00Z] - [event | events] = events - assert event.dtstart == ~U[2020-10-14 14:30:00Z] - [event] = events - assert event.dtstart == ~U[2020-10-15 14:30:00Z] + assert recurrences == [ + ~U[2020-09-03 14:30:00Z], + ~U[2020-09-30 14:30:00Z], + ~U[2020-10-01 14:30:00Z], + ~U[2020-10-14 14:30:00Z], + ~U[2020-10-15 14:30:00Z] + ] + end end - test "Recurrence deserialization ignores bad WKST values" do - rrule = %{"FREQ" => "DAILY", "WKST" => "NO"} + describe "Recurrence generation with yearly frequence," do + test "simple" do + count = 5 + rule = %ICal.Recurrence{frequency: :yearly, count: count} + dtstart = ~U[2026-04-15 13:00:00Z] - assert %ICal.Recurrence{frequency: :daily, weekday: nil} === - ICal.Deserialize.Recurrence.from_params(rrule) - end + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) + + assert Enum.count(recurrences) == count + end + + test "by month" do + count = 5 + rule = %ICal.Recurrence{frequency: :yearly, count: count, by_month: [1, 4, 6]} + dtstart = ~U[2026-04-15 13:00:00Z] + + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) + + assert Enum.count(recurrences) == count + [recurrence | _] = recurrences + assert %{month: 4} = recurrence + end + + test "every day in january for 3 years using BYMONTH and BYDAY" do + dtstart = DateTime.new!(~D[1998-01-01], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA" + ) + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every day in january for 3 years with yearly freq" + ) + + assert Enum.count(recurrences) == 93 + + assert Enum.at(recurrences, 0) == + DateTime.new!(~D[1998-01-01], ~T[09:00:00], "America/New_York") + + assert Enum.at(recurrences, -1) == + DateTime.new!(~D[2000-01-31], ~T[09:00:00], "America/New_York") + end + + test "positive set position" do + count = 5 + + rule = %ICal.Recurrence{ + frequency: :yearly, + count: count, + by_month: [1, 4, 6], + by_set_position: 1 + } + + dtstart = ~U[2026-04-15 13:00:00Z] + + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) + + assert Enum.count(recurrences) == count + end + + test "negative set position" do + count = 5 + + rule = %ICal.Recurrence{ + frequency: :yearly, + count: count, + by_month: [1, 4, 6], + by_set_position: -1 + } + + dtstart = ~U[2026-04-15 13:00:00Z] + + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) + + assert Enum.count(recurrences) == count + [recurrence | _] = recurrences + assert %{month: 4} = recurrence + end + + test "by week number" do + count = 22 + rule = %ICal.Recurrence{frequency: :yearly, count: count, by_week_number: [3, 17]} + dtstart = ~U[2026-04-15 13:00:00Z] + + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) + + assert Enum.count(recurrences) == count + + assert [ + ~U[2026-04-20 13:00:00Z], + ~U[2026-04-21 13:00:00Z], + ~U[2026-04-22 13:00:00Z], + ~U[2026-04-23 13:00:00Z], + ~U[2026-04-24 13:00:00Z], + ~U[2026-04-25 13:00:00Z], + ~U[2026-04-26 13:00:00Z], + ~U[2027-01-18 13:00:00Z], + ~U[2027-01-19 13:00:00Z], + ~U[2027-01-20 13:00:00Z], + ~U[2027-01-21 13:00:00Z], + ~U[2027-01-22 13:00:00Z], + ~U[2027-01-23 13:00:00Z], + ~U[2027-01-24 13:00:00Z], + ~U[2027-04-26 13:00:00Z], + ~U[2027-04-27 13:00:00Z], + ~U[2027-04-28 13:00:00Z], + ~U[2027-04-29 13:00:00Z], + ~U[2027-04-30 13:00:00Z], + ~U[2027-05-01 13:00:00Z], + ~U[2027-05-02 13:00:00Z], + ~U[2028-01-17 13:00:00Z] + ] == recurrences + end + + test "by week number applied to by month" do + count = 5 + + rule = %ICal.Recurrence{ + frequency: :yearly, + count: count, + by_month: [1, 4, 6], + by_week_number: [2, 17] + } + + dtstart = ~U[2026-04-15 13:00:00Z] - test "Recurrence deserialization clamps time values" do - rrule = %{ - "FREQ" => "DAILY", - "BYSECOND" => "-1,-,0,1,10,50,59,60,70", - "BYMINUTE" => "-1,-,0,1,10,50,59,60,70", - "BYHOUR" => "-1,-,0,1,6,12,23,24" - } - - assert %ICal.Recurrence{ - frequency: :daily, - by_second: [0, 1, 10, 50, 59], - by_minute: [0, 1, 10, 50, 59], - by_hour: [0, 1, 6, 12, 23] - } === ICal.Deserialize.Recurrence.from_params(rrule) + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) + + assert Enum.count(recurrences) == count + + assert [ + ~U[2027-01-15 13:00:00Z], + ~U[2028-01-15 13:00:00Z], + ~U[2033-01-15 13:00:00Z], + ~U[2034-01-15 13:00:00Z], + ~U[2038-01-15 13:00:00Z] + ] == recurrences + end + + test "by year day" do + count = 5 + rule = %ICal.Recurrence{frequency: :yearly, count: count, by_year_day: [15, 50]} + dtstart = ~U[2026-04-15 13:00:00Z] + + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) + + assert Enum.count(recurrences) == count + + assert [ + ~U[2027-01-15 13:00:00Z], + ~U[2027-02-19 13:00:00Z], + ~U[2028-01-15 13:00:00Z], + ~U[2028-02-19 13:00:00Z], + ~U[2029-01-15 13:00:00Z] + ] == recurrences + end + + test "by year day applied to by month" do + count = 5 + + rule = %ICal.Recurrence{ + frequency: :yearly, + count: count, + by_month: [1, 4, 6], + by_year_day: [15, 50] + } + + dtstart = ~U[2026-04-15 13:00:00Z] + + {:ok, recurrences} = ICal.Recurrence.Generate.all(rule, start_date: dtstart) + + assert Enum.count(recurrences) == count + + assert [ + ~U[2027-01-15 13:00:00Z], + ~U[2028-01-15 13:00:00Z], + ~U[2029-01-15 13:00:00Z], + ~U[2030-01-15 13:00:00Z], + ~U[2031-01-15 13:00:00Z] + ] == recurrences + end + + test "in June and July for 10 occurrences" do + count = 10 + dtstart = DateTime.new!(~D[1997-06-10], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=6,7") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "in June and July for 10 occurrences" + ) + + expected = [ + DateTime.new!(~D[1997-06-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-06-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-07-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-06-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-07-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-06-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-07-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2001-06-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2001-07-10], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every other year on January, February, and March for 10 occurences" do + count = 10 + dtstart = DateTime.new!(~D[1997-03-10], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every other year on January, February, and March for 10 occurences" + ) + + expected = [ + DateTime.new!(~D[1997-03-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-01-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-02-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-03-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2001-01-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2001-02-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2001-03-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-01-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-02-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-03-10], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every third year on the 1st, 100th, and 200th day for 10 occurences" do + count = 10 + dtstart = DateTime.new!(~D[1997-01-01], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every third year on the 1st, 100th, and 200th day for 10 occurences" + ) + + expected = [ + DateTime.new!(~D[1997-01-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-04-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-01-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-04-09], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-07-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-01-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-04-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2003-07-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2006-01-01], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every 20th Monday of the year" do + count = 3 + dtstart = DateTime.new!(~D[1997-05-19], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;BYDAY=20MO") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "every 20th Monday of the year" + ) + + expected = [ + DateTime.new!(~D[1997-05-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-05-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-05-17], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every 2nd-to-last Monday of the year" do + count = 3 + dtstart = DateTime.new!(~D[1997-12-22], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;BYDAY=-2MO") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "every 2nd-to-last Monday of the year" + ) + + expected = [ + DateTime.new!(~D[1997-12-22], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-12-21], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-12-20], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "Monday of week number 20 (where the default start of the week is Monday" do + count = 3 + dtstart = DateTime.new!(~D[1997-05-12], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "Monday of week number 20 (where the default start of the week is Monday" + ) + + expected = [ + DateTime.new!(~D[1997-05-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-05-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-05-17], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every Thursday in March" do + count = 7 + dtstart = DateTime.new!(~D[1997-03-13], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=TH") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "every Thursday in March" + ) + + expected = [ + DateTime.new!(~D[1997-03-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-03-20], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-03-27], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-26], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every Thursday, but only during June, July, and August" do + count = 14 + dtstart = DateTime.new!(~D[1997-06-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "every Thursday, but only during June, July, and August" + ) + + expected = [ + DateTime.new!(~D[1997-06-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-06-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-06-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-06-26], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-17], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-24], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-07-31], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-14], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-21], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-28], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-06-04], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every 4 years, the first Tuesday after a Monday in November" do + count = 3 + dtstart = DateTime.new!(~D[1996-11-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8" + ) + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "every 4 years, the first Tuesday after a Monday in November" + ) + + expected = [ + DateTime.new!(~D[1996-11-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-11-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2004-11-02], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end end - test "Recurrence deserialization clamps day/week/month values" do - rrule = %{ - "FREQ" => "DAILY", - "BYWEEKNO" => "-54,-53,-1,0,a,1,25,2,53,54", - "BYMONTHDAY" => "-32,-31,a,-1,1,31,32", - "BYMONTH" => "0,1,12,a,13", - "BYYEARDAY" => "-367,-366,-1,0,a,,1,366,367,garbage", - "BYSETPOS" => "-367,-366,-1,0,a,,1,366,367" - } - - assert %ICal.Recurrence{ - frequency: :daily, - by_week_number: [-53, -1, 1, 25, 2, 53], - by_month_day: [-31, -1, 1, 31], - by_month: [1, 12], - by_year_day: [-366, -1, 1, 366], - by_set_position: [-366, -1, 1, 366] - } === ICal.Deserialize.Recurrence.from_params(rrule) + describe "Recurrence generation with daily frequence," do + test "every day in january for 3 years using BYMONTH" do + dtstart = DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1") + + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every day in january for 3 years" + ) + end + + test "every january 10th and 31st for 3 years" do + dtstart = DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1;BYMONTHDAY=10,31" + ) + + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every 10th and 31st in january for 3 years" + ) + end + + test "every Tuesday and Thursday in january for 3 years" do + dtstart = DateTime.new!(~D[2026-01-31], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=20280131T140000Z;BYMONTH=1;BYDAY=TH,TU") + + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every 10th and 31st in january for 3 years" + ) + end + + test "daily until December 24, 1997" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;UNTIL=19971224T000000Z") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "daily until December 24, 1997" + ) + + assert Enum.at(recurrences, 0) == + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + # DST hits, and it is one hour earlier! + assert Enum.at(recurrences, -1) == + DateTime.new!(~D[1997-12-23], ~T[09:00:00], "America/New_York") + + assert Enum.count(recurrences) == 113 + end + + test "every other day forever is rejected by all/2" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2") + + assert {:error, :no_defined_limit, []} == + ICal.Recurrence.Generate.all(rule, start_date: dtstart) + end + + test "recurrenct termination is correctly noted" do + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2") + refute ICal.Recurrence.terminates?(rule) + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2;COUNT=10") + assert ICal.Recurrence.terminates?(rule) + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2;UNTIL=19971224T000000Z") + assert ICal.Recurrence.terminates?(rule) + end + + test "every other day forever works with a stream" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=2") + count = 10 + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-06], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-08], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-14], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-16], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-20], ~T[09:00:00], "America/New_York") + ] + + recurrences = + ICal.Recurrence.stream(rule, start_date: dtstart) + |> Enum.take(count) + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every 10 days, five times" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=DAILY;INTERVAL=10;COUNT=5") + count = 10 + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-22], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-12], ~T[09:00:00], "America/New_York") + ] + + recurrences = + ICal.Recurrence.stream(rule, start_date: dtstart) + |> Enum.take(count) + + assert Enum.count(recurrences) == 5 + assert recurrences == expected + end end - test "Recurrence deserialization ignores garbage in count and interval" do - rrule = %{ - "FREQ" => "DAILY", - "COUNT" => "GARBAGE", - "INTERVAL" => "" - } - - assert %ICal.Recurrence{ - frequency: :daily, - count: nil, - interval: 1 - } === ICal.Deserialize.Recurrence.from_params(rrule) + describe "Recurrence generation with weekly frequency," do + test "weekly for 10 weeks" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;COUNT=10") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "weekly for 10 weeks" + ) + + assert Enum.count(recurrences) == 10 + # ==> (1997 9:00 AM EDT) September 2,9,16,23,30;October 7,14,21 + # (1997 9:00 AM EST) October 28;November 4 + end + + test "weekly until a date" do + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "weekly for 10 weeks" + ) + + assert Enum.count(recurrences) == 17 + assert Enum.at(recurrences, 0) == dtstart + + assert Enum.at(recurrences, -1) == + DateTime.new!(~D[1997-12-23], ~T[09:00:00], "America/New_York") + end + + test "every other week, forever" do + count = 5 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + rule = ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "every other week, forever" + ) + + assert Enum.count(recurrences) == count + assert Enum.at(recurrences, 0) == dtstart + + assert Enum.at(recurrences, -1) == + DateTime.new!(~D[1997-10-28], ~T[09:00:00], "America/New_York") + end + + test "five weeks of tuesdays and thursdays" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every other week, forever" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-09], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-16], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-23], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-25], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-02], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every other week on Monday, Wednesday, and Friday until December 24, 1997" do + count = 25 + dtstart = DateTime.new!(~D[1997-09-01], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR" + ) + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every other week, Mo/We/Fr until Dec 24" + ) + + expected = [ + DateTime.new!(~D[1997-09-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-17], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-17], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-27], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-31], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-14], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-24], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-26], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-28], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-08], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-22], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every other week on Tuesday and Thursday, for 8 occurrences" do + count = 8 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every other week, Mo/We/Fr until Dec 24" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-16], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-14], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-16], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "WKST variance -> days generated with MO WKST" do + count = 4 + dtstart = DateTime.new!(~D[1997-08-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "WKST variance -> days generated with MO WKST" + ) + + expected = [ + DateTime.new!(~D[1997-08-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-24], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "WKST variance -> days generated with SU WKST" do + count = 4 + dtstart = DateTime.new!(~D[1997-08-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "example where the days generated makes a difference because of WKST" + ) + + expected = [ + DateTime.new!(~D[1997-08-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-17], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-08-31], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end end - test "Recurrence de/serializes weekday abbreviations corrrectly" do - rrule = %{"FREQ" => "DAILY", "BYDAY" => "-1SU,SU,1MO,-1TU,+2WE,TH,FR,SA,GA,GARBAGE,,0,-1"} + describe "Recurrence generation with monthly frequency," do + test "on the first Friday for 10 occurrences" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=1FR") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "monthly on the first Friday for 10 occurrences" + ) + + expected = [ + DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-06], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-06], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-04-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-05-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-06-05], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "on the first Friday until December 24, 1997" do + count = 4 + dtstart = DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "monthly on the first Friday until December 24, 1997" + ) + + expected = [ + DateTime.new!(~D[1997-09-05], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-05], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every other month on the first and last Sunday of the month for 10 occurences" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-07], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every other month on the first and last Sunday of the month for 10 occurences" + ) - recurrence = %ICal.Recurrence{ - frequency: :daily, - by_day: [ - {-1, :sunday}, - {nil, :sunday}, - {1, :monday}, - {-1, :tuesday}, - {2, :wednesday}, - {nil, :thursday}, - {nil, :friday}, - {nil, :saturday} + expected = [ + DateTime.new!(~D[1997-09-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-28], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-25], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-05-03], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-05-31], ~T[09:00:00], "America/New_York") ] - } - assert recurrence === ICal.Deserialize.Recurrence.from_params(rrule) + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "on the second-to-last Monday of the month for 6 months" do + count = 6 + dtstart = DateTime.new!(~D[1997-09-22], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=-2MO") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "monthly on the second-to-last Monday of the month for 6 months" + ) + + expected = [ + DateTime.new!(~D[1997-09-22], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-20], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-17], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-22], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-19], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-16], ~T[09:00:00], "America/New_York") + ] - serialized = ICal.Serialize.Recurrence.property(recurrence) |> to_string() + assert Enum.count(recurrences) == count + assert recurrences == expected + end - assert String.starts_with?(serialized, "RRULE:FREQ=DAILY") - assert String.contains?(serialized, ";INTERVAL=1") - assert String.contains?(serialized, ";BYDAY=-1SUSU1MO-1TU2WETHFRSA") - assert String.ends_with?(serialized, "\n") + test "on the third-to-the-last day of the month" do + count = 6 + dtstart = DateTime.new!(~D[1997-09-28], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;BYMONTHDAY=-3") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "on the third-to-the-last day of the month" + ) + + expected = [ + DateTime.new!(~D[1997-09-28], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-28], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-26], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "2nd and 15th of the month for 10 occurrences" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.to_list() end, + "2nd and 15th of the month for 10 occurrences" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-15], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "first and last day of the month for 10 occurrencess" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-30], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.to_list() end, + "first and last day of the month for 10 occurrencess" + ) + + expected = [ + DateTime.new!(~D[1997-09-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-31], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-31], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-01], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-31], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-01], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every 18 months on the 10th thru 15th of the month for 10 occurrencess" do + count = 10 + dtstart = DateTime.new!(~D[1997-09-10], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15" + ) + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.to_list() end, + "every 18 months on the 10th thru 15th of the month for 10 occurrencess" + ) + + expected = [ + DateTime.new!(~D[1997-09-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-14], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-03-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-03-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-03-12], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-03-13], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every Tuesday, every other month" do + count = 13 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "every Tuesday, every other month" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-09], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-16], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-23], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-18], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-25], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-06], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-20], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-27], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every Friday the 13th" do + count = 5 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13") + + recurrences = + Helper.time( + fn -> + ICal.Recurrence.stream(rule, start_date: dtstart, exclude_recurrences: [dtstart]) + |> Enum.take(count) + end, + "every Friday the 13th" + ) + + expected = [ + DateTime.new!(~D[1998-02-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-11-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1999-08-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2000-10-13], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "first Saturday that follows the first Sunday of the month" do + count = 7 + dtstart = DateTime.new!(~D[1997-09-13], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "first Saturday that follows the first Sunday of the month" + ) + + expected = [ + DateTime.new!(~D[1997-09-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-11], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-08], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-13], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-10], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-07], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "third instance into the month of one of Tuesday, Wednesday, or Thursday for the next 3 months" do + count = 3 + dtstart = DateTime.new!(~D[1997-09-04], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.to_list() end, + "third instance into the month of one of Tuesday, Wednesday, or Thursday for the next 3 months" + ) + + expected = [ + DateTime.new!(~D[1997-09-04], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-07], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-06], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "second-to-last weekday of the month" do + count = 7 + dtstart = DateTime.new!(~D[1997-09-29], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "second-to-last weekday of the month" + ) + + expected = [ + DateTime.new!(~D[1997-09-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-10-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-11-27], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-12-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-01-29], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-02-26], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1998-03-30], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "an invalid date (February 30) is ignored" do + count = 5 + dtstart = DateTime.new!(~D[2007-01-15], ~T[09:00:00], "America/New_York") + + rule = ICal.Recurrence.from_ics("RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5") + + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "second-to-last weekday of the month" + ) + + expected = [ + DateTime.new!(~D[2007-01-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2007-01-30], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2007-02-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2007-03-15], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[2007-03-30], ~T[09:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end end - test "Recurrence deserialization parses values of frequency corrrectly" do - rrule = %{"FREQ" => "DAILY"} - assert %ICal.Recurrence{frequency: :daily} === ICal.Deserialize.Recurrence.from_params(rrule) + describe "Recurrence generation with time components," do + test "every 3 hours from 9:00 AM to 5:00 PM on a specific day" do + count = 3 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T170000Z") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every 3 hours from 9:00 AM to 5:00 PM on a specific day" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[12:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[15:00:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every 15 minutes for 6 occurrences" do + count = 6 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MINUTELY;INTERVAL=15;COUNT=6") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every 15 minutes for 6 occurrences" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:15:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:30:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:45:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[10:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[10:15:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + assert recurrences == expected + end + + test "every hour and a half for 4 occurrences" do + count = 4 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics("RRULE:FREQ=MINUTELY;INTERVAL=90;COUNT=4") + + {:ok, recurrences} = + Helper.time( + fn -> ICal.Recurrence.Generate.all(rule, start_date: dtstart) end, + "every hour and a half for 4 occurrences" + ) + + expected = [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[10:30:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[12:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[13:30:00], "America/New_York") + ] + + assert Enum.count(recurrences) == count + + assert recurrences == expected + end + + test "every 20 minutes from 9:00 AM to 4:40 PM every day" do + count = 40 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") + + rule = + ICal.Recurrence.from_ics( + "RRULE:FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40" + ) - rrule = %{"FREQ" => "WEEKLY"} - assert %ICal.Recurrence{frequency: :weekly} === ICal.Deserialize.Recurrence.from_params(rrule) + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "every 20 minutes from 9:00 AM to 4:40 PM every day" + ) - rrule = %{"FREQ" => "MONTHLY"} + assert Enum.count(recurrences) == count - assert %ICal.Recurrence{frequency: :monthly} === - ICal.Deserialize.Recurrence.from_params(rrule) + assert [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:20:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:40:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[10:00:00], "America/New_York") + ] == Enum.slice(recurrences, 0, 4) - rrule = %{"FREQ" => "YEARLY"} - assert %ICal.Recurrence{frequency: :yearly} === ICal.Deserialize.Recurrence.from_params(rrule) + assert [ + DateTime.new!(~D[1997-09-02], ~T[16:40:00], "America/New_York"), + DateTime.new!(~D[1997-09-03], ~T[09:00:00], "America/New_York") + ] == Enum.slice(recurrences, 23, 2) - rrule = %{"FREQ" => "HOURLY"} - assert %ICal.Recurrence{frequency: :hourly} === ICal.Deserialize.Recurrence.from_params(rrule) + assert DateTime.new!(~D[1997-09-03], ~T[14:00:00], "America/New_York") == + Enum.at(recurrences, -1) + end - rrule = %{"FREQ" => "MINUTELY"} + test "every 25 seconds from 09:00" do + count = 4 + dtstart = DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York") - assert %ICal.Recurrence{frequency: :minutely} === - ICal.Deserialize.Recurrence.from_params(rrule) + rule = ICal.Recurrence.from_ics("RRULE:FREQ=SECONDLY;INTERVAL=25") - rrule = %{"FREQ" => "SECONDLY"} + recurrences = + Helper.time( + fn -> ICal.Recurrence.stream(rule, start_date: dtstart) |> Enum.take(count) end, + "every 25 seconds from 09:00" + ) - assert %ICal.Recurrence{frequency: :secondly} === - ICal.Deserialize.Recurrence.from_params(rrule) + assert Enum.count(recurrences) == count - rrule = %{"FREQ" => "GARBAGE"} - assert nil === ICal.Deserialize.Recurrence.from_params(rrule) + assert [ + DateTime.new!(~D[1997-09-02], ~T[09:00:00], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:00:25], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:00:50], "America/New_York"), + DateTime.new!(~D[1997-09-02], ~T[09:01:15], "America/New_York") + ] == recurrences + end end end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index be20c1e..d361634 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -114,6 +114,18 @@ defmodule ICal.Test.Fixtures do } end + def one_event(:with_rdates) do + %{ + one_event(:deserialize) + | rdates: [ + ~D[1997-01-01], + ~D[1997-01-20], + ~D[1997-02-17], + ~D[1997-04-21] + ] + } + end + def one_event(:one_alarm) do %{ one_event(:deserialize) @@ -481,7 +493,7 @@ defmodule ICal.Test.Fixtures do by_month: [10], by_set_position: [20], by_week_number: [-1], - weekday: :monday, + week_start_day: :monday, frequency: :daily, interval: 1 } @@ -798,7 +810,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[1973-04-29 07:00:00Z], - weekday: nil + week_start_day: :default } }, %ICal.Timezone.Properties{ @@ -831,7 +843,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[1986-04-27 07:00:00Z], - weekday: nil + week_start_day: :default } }, %ICal.Timezone.Properties{ @@ -855,7 +867,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[2006-04-02 07:00:00Z], - weekday: nil + week_start_day: :default } }, %ICal.Timezone.Properties{ @@ -879,7 +891,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], @@ -906,7 +918,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[2006-10-29 06:00:00Z], - weekday: nil + week_start_day: :default } }, %ICal.Timezone.Properties{ @@ -930,7 +942,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], @@ -995,7 +1007,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], @@ -1022,7 +1034,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], @@ -1056,7 +1068,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[1998-04-04 07:00:00Z], - weekday: nil + week_start_day: :default } } ], @@ -1083,7 +1095,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], @@ -1117,7 +1129,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: ~U[1998-04-04 07:00:00Z], - weekday: nil + week_start_day: :default } }, %ICal.Timezone.Properties{ @@ -1157,7 +1169,7 @@ defmodule ICal.Test.Fixtures do frequency: :yearly, interval: 1, until: nil, - weekday: nil + week_start_day: :default } } ], diff --git a/test/test_helper.exs b/test/test_helper.exs index 110fcbf..3c74e55 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,4 +1,6 @@ defmodule ICal.Test.Helper do + require Logger + def test_data_path(name) do Path.join([File.cwd!(), "/test/data"], name <> ".ics") end @@ -19,6 +21,26 @@ defmodule ICal.Test.Helper do "-//Elixir ICal//v#{version}//#{custom_vendor}//EN" end + @doc "Times a function" + @spec time(fun, label :: String.t()) :: term + def time(function, label \\ "") do + {time, value} = :timer.tc(function) + + if Application.get_env(:ical, :show_test_timings, false) do + Logger.info("TIME #{label} => #{time} microseconds / #{time / 1000} ms") + end + + value + end + + def sort_dates(dates) do + Enum.sort(dates, &compare_dates/2) + end + + defp compare_dates(%DateTime{} = l, r), do: DateTime.compare(l, r) == :lt + defp compare_dates(%NaiveDateTime{} = l, r), do: NaiveDateTime.compare(l, r) == :lt + defp compare_dates(%Date{} = l, r), do: Date.compare(l, r) == :lt + defmacro __using__(_) do quote do alias ICal.Test.Helper @@ -52,5 +74,4 @@ defmodule ICal.Test.Helper do end end -Calendar.put_time_zone_database(Tz.TimeZoneDatabase) ExUnit.start()