Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
96 commits
Select commit Hold shift + click to select a range
e192e0c
sort recurrence tests into serialization, deserialization, generation
aseigo Apr 15, 2026
0687f57
remove duplicates, sort for easier processing
aseigo Apr 15, 2026
cae8764
use 0 offsets, not nil
aseigo Apr 15, 2026
a387360
normalize recurrece rule values after parsing
aseigo Apr 15, 2026
040e6e6
improve typing accuracy
aseigo Apr 15, 2026
5cc5c7c
small improvements in recurrence normalization
aseigo Apr 15, 2026
a7d715b
`Reccurence.from_ics` convenience to parse recurrence from a string
aseigo Apr 15, 2026
fb2afc4
add a TODO for a future API-breaking release
aseigo Apr 15, 2026
67c0856
a handy little timer function; benchee_lite
aseigo Apr 15, 2026
a6db42a
make the default weekday :default; works nicely with Date fns
aseigo Apr 15, 2026
0b03e4e
beginning of a full-featured recurrence calculator
aseigo Apr 15, 2026
18d93f8
more tests
aseigo Apr 17, 2026
aadb1fa
fix test
Apr 17, 2026
ca1b60f
rename Recurrence.weekday to Recurrence.week_start_day
Apr 17, 2026
f5ef875
move as_valid_datetime to ICal for reuse
Apr 19, 2026
88b899f
recurrences calc week numbers correctly, and correct application orde…
Apr 19, 2026
f683fa0
implement a very crude max searches to prevent non-halting
Apr 19, 2026
7d57814
another recurrence test, correct slicing to until date
Apr 19, 2026
1fcea75
organize apply_by/3 by by_*, and add stubs for all missing impls
Apr 19, 2026
45e7792
spiff up by_* conditionals, add missing ones
Apr 19, 2026
498614f
fixes to by_* conditionals
Apr 19, 2026
db81f42
refactor Recurrence.Generate for clarity and re-use of code behind all/2
aseigo Apr 20, 2026
0902d08
more refactoring: by -> modifier[s], generate_set/6
aseigo Apr 20, 2026
d9a550e
wrap responses in :ok/:error tuples, introduce ICal.Recurrence.State
aseigo Apr 20, 2026
3ddd768
add optional end and exclusionary dates
aseigo Apr 20, 2026
d3b3455
port ICal.Recurrence.stream/2 to use ICal.Recurrence.Generate
aseigo Apr 20, 2026
6086c96
re-type recurrable component, formatting
aseigo Apr 20, 2026
9d96f59
start handling rdates (TODO left in one case)
aseigo Apr 20, 2026
d6b237a
allow processing recurrence rules without a component
aseigo Apr 20, 2026
838b5c6
respect an optional list of other recurrences, update todos
aseigo Apr 20, 2026
2b8e9f8
update docs for ICal.Recurrence.Stream/2
aseigo Apr 20, 2026
52ca803
strike this TODO as it is done
aseigo Apr 20, 2026
f411a76
more docs updates
aseigo Apr 20, 2026
378ceb9
use ICal.Recurrence.apply/2
aseigo Apr 20, 2026
bd47eb8
update features in README
aseigo Apr 20, 2026
44282e5
dstart => dtstart
aseigo Apr 20, 2026
a314220
do not jump the year if the months are equal
aseigo Apr 20, 2026
8c0b147
move some types to Recurrence, start implementing by_day, more tests
aseigo Apr 20, 2026
123364f
fix tests
aseigo Apr 20, 2026
e9cb46b
more tests from RFC5545
aseigo Apr 20, 2026
49191f6
def -> defp
Apr 21, 2026
77c58d2
some TODO markers
Apr 21, 2026
72a7d9e
implement {:by_day, :expand_week}
Apr 21, 2026
637dfec
tidies
Apr 21, 2026
9b61c45
use Logger
Apr 21, 2026
a50ff0d
more tests
Apr 21, 2026
159657b
update the changelog
aseigo Apr 21, 2026
b120839
track the initial starting date in Recurrence.State, exclude by it
aseigo Apr 21, 2026
6f08347
implement positive monthly-byday day offsets
aseigo Apr 21, 2026
e821078
more tests
aseigo Apr 21, 2026
f658439
add a days_in_month that is date type agnostic
Apr 21, 2026
8c986d3
implement netagive monthly-byday day offsets
Apr 21, 2026
46179d3
remove some fluff
aseigo Apr 21, 2026
458d6ef
implement expand/limit for time components
aseigo Apr 21, 2026
23b38f7
move functions of same arity together
aseigo Apr 21, 2026
f24c8fb
fixes for offset weekdays in months
aseigo Apr 21, 2026
a8f882b
update credo
aseigo Apr 21, 2026
dfca6f8
shift UNTIL dates into the tz of the starting date
aseigo Apr 21, 2026
1ec8176
make timing configurable, default to false
aseigo Apr 21, 2026
6a072f2
remove unused fn
aseigo Apr 21, 2026
a3ee974
when shifting dates, do not touch the times, according to spec
aseigo Apr 21, 2026
5b6cfd4
implement pos/neg by day year expansion
aseigo Apr 21, 2026
a7056d7
reduce instead of flat_map, improve function name
aseigo Apr 21, 2026
8c5d590
by_day limits on yearly recurrences when weekno is set
aseigo Apr 21, 2026
d94492e
simplify {:by_week_number, :expand}
aseigo Apr 21, 2026
3bcb291
do not drop timezones when creating a range of datetimes
aseigo Apr 21, 2026
a6cdea3
keep tz in week_number_bookends, remove week_of_year as now unused
aseigo Apr 21, 2026
621b047
fix {:by_day, :expand_month} for recurrences not first of the month
aseigo Apr 21, 2026
e3cb4f7
finish out the yearly and weekly test suites
aseigo Apr 21, 2026
8b0b6a3
catch search exhaustion in generate_set
aseigo Apr 22, 2026
15696bd
catch exception and return nil
aseigo Apr 22, 2026
2ee0f6e
rely on shift_date entirely
aseigo Apr 22, 2026
fe0130d
support negative year day offsets
aseigo Apr 22, 2026
2b0f3fe
preserve timezone shifts when shifting dates
aseigo Apr 22, 2026
bb8a1a7
filter rather than copy all the dates
aseigo Apr 22, 2026
4cad535
replace date_valid? impl with Calendar.ISO functions
aseigo Apr 22, 2026
786c289
set the tz database via config in test env
aseigo Apr 22, 2026
ccb76fc
add Recurrence.terminates? and a document functions
aseigo Apr 22, 2026
28ddad1
use shift_date to preserve timezone correctness
aseigo Apr 22, 2026
4446da5
there may be multiple set positions
aseigo Apr 22, 2026
56a032e
ensure month day expansion stays in the month
aseigo Apr 22, 2026
2698d3c
prefer reduce of flat_map, prepend new dates when applying by_*
aseigo Apr 22, 2026
063e46b
the last of the examples from rfc5545 as tests
aseigo Apr 22, 2026
f39eed0
add a test for secondly freq
aseigo Apr 22, 2026
621933e
do not document Recurrence.State
aseigo Apr 22, 2026
b861337
simplify docs
aseigo Apr 22, 2026
8cdf2d6
use before?/after? funs
aseigo Apr 22, 2026
4cd9c34
tidy
aseigo Apr 22, 2026
eda8881
allow nesting up to 3 levels deep; simply needed for this lib
aseigo Apr 22, 2026
93178b7
clean up credo issues
aseigo Apr 22, 2026
4cbd4ed
if an end date is provided, but not until, use end_date
aseigo Apr 22, 2026
146ed92
tidy, add more change information
aseigo Apr 22, 2026
1dc99f3
streamline stream options, combine stream/2 and stream/3 into stream/2
aseigo Apr 22, 2026
786481d
apparently explicit time units was only added in OTP26.0
aseigo Apr 22, 2026
a75bf2e
improve typing, Generate.all now takes options, include error in State
aseigo Apr 22, 2026
bc21039
formatting :/
aseigo Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
%{
name: "default",
checks: %{
extra: [
{Credo.Check.Refactor.Nesting, [max_nesting: 3]}
],
disabled: [
{Credo.Check.Design.AliasUsage, []}
]
Expand Down
22 changes: 13 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
32 changes: 9 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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).
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 37 additions & 0 deletions lib/ical.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 7 additions & 2 deletions lib/ical/alarm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 1 addition & 30 deletions lib/ical/deserialize.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
59 changes: 26 additions & 33 deletions lib/ical/deserialize/recurrence.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +17,7 @@ defmodule ICal.Deserialize.Recurrence do
%{recurrence | frequency: frequency},
&add_to_recurrence/2
)
|> ICal.Recurrence.normalize()
else
_ -> nil
end
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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(<<?,, string::binary>>, min, max, value, acc) do
acc = clamp_number(value, min, max, acc)
to_clamped_numbers(string, min, max, "", acc)
defp accumulate_numbers(<<?,, string::binary>>, value, acc) do
acc = accumulate_if_number(value, acc)
accumulate_numbers(string, "", acc)
end

defp to_clamped_numbers(<<c, string::binary>>, min, max, value, acc) do
to_clamped_numbers(string, min, max, <<value::binary, c>>, acc)
defp accumulate_numbers(<<c, string::binary>>, value, acc) do
accumulate_numbers(string, <<value::binary, c>>, 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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading