Skip to content

Commit 90408f0

Browse files
authored
Merge pull request #37 from peek-travel/x-tensions
Add a new proprietary extension: X-BYRANGE
2 parents 2844964 + f3fec4a commit 90408f0

File tree

11 files changed

+240
-17
lines changed

11 files changed

+240
-17
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
55
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased][]
8+
### Breaking
9+
- The `BYTIME` option of `RRULE`s in the iCalendar output is now `X-BYTIME` to better follow the standard's extensions policy
10+
11+
### Added
12+
- "time range" option (e.g. `Schedule.add_recurrence_rules(:daily, time_range: %{start_time: ~T[09:00:00], end_time: ~T[11:00:00], interval_seconds: 1_800})`; this serializes to `X-BYRANGE` in iCalendar format, using the extension prefix to signal that it's a proprietary extension)
13+
814
### Changed
915
- Formatted code-base with the new Elixir 1.6 code formatter
1016
- Changed `Schedule.t()` to not be an opaque type, which fixed the few missing typespecs

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ by adding `cocktail` to your list of dependencies in `mix.exs`:
2727
```elixir
2828
def deps do
2929
[
30-
{:cocktail, "~> 0.7"}
30+
{:cocktail, "~> 0.8"}
3131
]
3232
end
3333
```

lib/cocktail.ex

+7
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ defmodule Cocktail do
2222

2323
@type second_number :: 0..59
2424

25+
@type time_range :: %{
26+
start_time: Time.t(),
27+
end_time: Time.t(),
28+
interval_seconds: second_number()
29+
}
30+
2531
@type schedule_option :: {:duration, pos_integer}
2632

2733
@type schedule_options :: [schedule_option]
@@ -36,6 +42,7 @@ defmodule Cocktail do
3642
| {:minutes, [minute_number]}
3743
| {:seconds, [second_number]}
3844
| {:times, [Time.t()]}
45+
| {:time_range, time_range}
3946

4047
@type rule_options :: [rule_option]
4148

lib/cocktail/builder/i_calendar.ex

+22-9
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ defmodule Cocktail.Builder.ICalendar do
66
"""
77

88
alias Cocktail.{Rule, Schedule, Validation}
9-
alias Cocktail.Validation.{Interval, Day, HourOfDay, MinuteOfHour, SecondOfMinute, TimeOfDay}
9+
alias Cocktail.Validation.{Interval, Day, HourOfDay, MinuteOfHour, SecondOfMinute, TimeOfDay, TimeRange}
1010

1111
@time_format_string "{YYYY}{0M}{0D}T{h24}{m}{s}"
1212

@@ -92,7 +92,7 @@ defmodule Cocktail.Builder.ICalendar do
9292
@spec build_rule(Rule.t()) :: String.t()
9393
defp build_rule(%Rule{validations: validations_map, until: until, count: count}) do
9494
parts =
95-
for key <- [:interval, :day, :hour_of_day, :minute_of_hour, :second_of_minute, :time_of_day],
95+
for key <- [:interval, :day, :hour_of_day, :minute_of_hour, :second_of_minute, :time_of_day, :time_range],
9696
validation = validations_map[key],
9797
!is_nil(validation) do
9898
build_validation_part(key, validation)
@@ -110,6 +110,7 @@ defmodule Cocktail.Builder.ICalendar do
110110
defp build_validation_part(:minute_of_hour, %MinuteOfHour{minutes: minutes}), do: minutes |> build_minutes()
111111
defp build_validation_part(:second_of_minute, %SecondOfMinute{seconds: seconds}), do: seconds |> build_seconds()
112112
defp build_validation_part(:time_of_day, %TimeOfDay{times: times}), do: times |> build_times()
113+
defp build_validation_part(:time_range, %TimeRange{} = time_range), do: time_range |> build_time_range()
113114

114115
@spec build_until(Cocktail.time() | nil) :: [String.t()]
115116
defp build_until(nil), do: []
@@ -193,14 +194,26 @@ defmodule Cocktail.Builder.ICalendar do
193194
times_list =
194195
times
195196
|> Enum.sort()
196-
|> Enum.map(fn {hour, min, sec} ->
197-
hours = String.pad_leading("#{hour}", 2, "0")
198-
mins = String.pad_leading("#{min}", 2, "0")
199-
secs = String.pad_leading("#{sec}", 2, "0")
200-
"#{hours}#{mins}#{secs}"
201-
end)
197+
|> Enum.map(&format_erl_time/1)
202198
|> Enum.join(",")
203199

204-
"BYTIME=#{times_list}"
200+
"X-BYTIME=#{times_list}"
205201
end
202+
203+
defp format_erl_time({hour, min, sec}) do
204+
hours = String.pad_leading("#{hour}", 2, "0")
205+
mins = String.pad_leading("#{min}", 2, "0")
206+
secs = String.pad_leading("#{sec}", 2, "0")
207+
208+
"#{hours}#{mins}#{secs}"
209+
end
210+
211+
# "time range" validation
212+
213+
@spec build_time_range(TimeRange.t()) :: String.t()
214+
defp build_time_range(%TimeRange{start_time: start_time, end_time: end_time, interval_seconds: interval}) do
215+
"X-BYRANGE=" <> ([format_time(start_time), format_time(end_time), interval] |> Enum.join(","))
216+
end
217+
218+
defp format_time(time), do: time |> Time.to_erl() |> format_erl_time
206219
end

lib/cocktail/parser/i_calendar.ex

+50-4
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,18 @@ defmodule Cocktail.Parser.ICalendar do
183183
end
184184
end
185185

186-
defp parse_rrule_option("BYTIME=" <> times_string) do
186+
defp parse_rrule_option("X-BYTIME=" <> times_string) do
187187
with {:ok, times} <- parse_times_string(times_string) do
188188
{:ok, {:times, times |> Enum.reverse()}}
189189
end
190190
end
191191

192+
defp parse_rrule_option("X-BYRANGE=" <> range_string) do
193+
with {:ok, time_range} <- parse_range_string(range_string) do
194+
{:ok, {:time_range, time_range}}
195+
end
196+
end
197+
192198
defp parse_rrule_option(_), do: {:error, :unknown_rrulparam}
193199

194200
@spec parse_frequency(String.t()) :: {:ok, Cocktail.frequency()} | {:error, :invalid_frequency}
@@ -364,16 +370,56 @@ defmodule Cocktail.Parser.ICalendar do
364370
|> parse_times([])
365371
end
366372

367-
@spec parse_times([String.t()], [Time.t()]) :: {:ok, [Time.t()]}
373+
@spec parse_times([String.t()], [Time.t()]) :: {:ok, [Time.t()]} | {:error, :invalid_time_format}
368374
defp parse_times([], times), do: {:ok, times}
369375

370376
defp parse_times([time_string | rest], times) do
371-
with {:ok, datetime} <- Timex.parse(time_string, @time_format) do
372-
time = NaiveDateTime.to_time(datetime)
377+
with {:ok, time} <- parse_time(time_string) do
373378
parse_times(rest, [time | times])
374379
end
375380
end
376381

382+
@spec parse_time(String.t()) :: {:ok, Time.t()} | {:error, :invalid_time_format}
383+
defp parse_time(time_string) do
384+
with {:ok, datetime} <- Timex.parse(time_string, @time_format) do
385+
{:ok, NaiveDateTime.to_time(datetime)}
386+
else
387+
_ ->
388+
{:error, :invalid_time_format}
389+
end
390+
end
391+
392+
# time range
393+
394+
@spec parse_range_string(String.t()) :: {:ok, Cocktail.time_range()} | {:error, :invalid_time_range}
395+
defp parse_range_string(""), do: {:error, :invalid_time_range}
396+
397+
defp parse_range_string(range_string) do
398+
range_string
399+
|> String.split(",")
400+
|> parse_range()
401+
end
402+
403+
@spec parse_range([String.t()]) :: {:ok, Cocktail.time_range()}
404+
defp parse_range([start_time_string, end_time_string, interval_seconds_string]) do
405+
with {:ok, start_time} <- parse_time(start_time_string),
406+
{:ok, end_time} <- parse_time(end_time_string),
407+
{interval_seconds, _} <- Integer.parse(interval_seconds_string) do
408+
time_range = %{
409+
start_time: start_time,
410+
end_time: end_time,
411+
interval_seconds: interval_seconds
412+
}
413+
414+
{:ok, time_range}
415+
else
416+
_ ->
417+
{:error, :invalid_time_range}
418+
end
419+
end
420+
421+
defp parse_range(_), do: {:error, :invalid_time_range}
422+
377423
# rdates and exdates
378424

379425
@spec parse_rdate(String.t(), Schedule.t(), non_neg_integer) :: {:ok, Schedule.t()} | {:error, term}

lib/cocktail/rule_state.ex

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ defmodule Cocktail.RuleState do
2424
:base_hour,
2525
:hour_of_day,
2626
:time_of_day,
27+
:time_range,
2728
:base_wday,
2829
:day,
2930
:interval

lib/cocktail/validation.ex

+13-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ defmodule Cocktail.Validation do
88
HourOfDay,
99
MinuteOfHour,
1010
SecondOfMinute,
11-
TimeOfDay
11+
TimeOfDay,
12+
TimeRange
1213
}
1314

1415
@type validation_key ::
@@ -21,6 +22,7 @@ defmodule Cocktail.Validation do
2122
| :minute_of_hour
2223
| :second_of_minute
2324
| :time_of_day
25+
| :time_range
2426
| :interval
2527

2628
@type validations_map :: %{validation_key => t}
@@ -33,6 +35,7 @@ defmodule Cocktail.Validation do
3335
| MinuteOfHour.t()
3436
| SecondOfMinute.t()
3537
| TimeOfDay.t()
38+
| TimeRange.t()
3639

3740
@spec build_validations(Cocktail.rule_options()) :: validations_map
3841
def build_validations(options) do
@@ -125,6 +128,15 @@ defmodule Cocktail.Validation do
125128
|> apply_options(rest)
126129
end
127130

131+
defp apply_options(map, [{:time_range, time_range} | rest]) do
132+
map
133+
|> Map.delete(:base_sec)
134+
|> Map.delete(:base_min)
135+
|> Map.delete(:base_hour)
136+
|> Map.put(:time_range, TimeRange.new(time_range))
137+
|> apply_options(rest)
138+
end
139+
128140
# unhandled option, just discard and continue
129141
defp apply_options(map, [{_, _} | rest]), do: map |> apply_options(rest)
130142
end

lib/cocktail/validation/time_range.ex

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
defmodule Cocktail.Validation.TimeRange do
2+
@moduledoc false
3+
4+
alias Cocktail.Validation.TimeOfDay
5+
6+
@type t :: %__MODULE__{
7+
start_time: Time.t(),
8+
end_time: Time.t(),
9+
interval_seconds: Cocktail.second_number(),
10+
time_of_day: TimeOfDay.t()
11+
}
12+
13+
@enforce_keys [:start_time, :end_time, :interval_seconds]
14+
defstruct start_time: nil,
15+
end_time: nil,
16+
interval_seconds: nil,
17+
time_of_day: nil
18+
19+
@spec new(Cocktail.time_range()) :: t()
20+
def new(attrs) do
21+
time_range = struct!(__MODULE__, attrs)
22+
times = generate_times(time_range)
23+
24+
%{time_range | time_of_day: TimeOfDay.new(times)}
25+
end
26+
27+
@spec generate_times(t()) :: [Time.t()]
28+
defp generate_times(%__MODULE__{} = time_range) do
29+
time_range.start_time
30+
|> Stream.unfold(fn time ->
31+
case Time.compare(time, time_range.end_time) do
32+
:gt ->
33+
nil
34+
35+
_ ->
36+
{time, time_add(time, time_range.interval_seconds)}
37+
end
38+
end)
39+
|> Enum.to_list()
40+
end
41+
42+
@spec next_time(t(), Cocktail.time(), Cocktail.time()) :: Cocktail.Validation.Shift.result()
43+
def next_time(%__MODULE__{time_of_day: time_of_day}, time, start_time),
44+
do: TimeOfDay.next_time(time_of_day, time, start_time)
45+
46+
if Version.compare(System.version(), "1.6.0") == :lt do
47+
# Yanked from Elixir 1.6.1 source code. Remove once we drop support for Elixir < 1.6.
48+
@spec time_add(Calendar.time(), integer, System.time_unit()) :: t
49+
defp time_add(%{calendar: calendar} = time, number, unit \\ :second) when is_integer(number) do
50+
number = System.convert_time_unit(number, unit, :microsecond)
51+
iso_days = {0, to_day_fraction(time)}
52+
total = Calendar.ISO.iso_days_to_unit(iso_days, :microsecond) + number
53+
iso_ppd = 86_400_000_000
54+
parts = Integer.mod(total, iso_ppd)
55+
56+
{hour, minute, second, microsecond} = calendar.time_from_day_fraction({parts, iso_ppd})
57+
58+
%Time{
59+
hour: hour,
60+
minute: minute,
61+
second: second,
62+
microsecond: microsecond,
63+
calendar: calendar
64+
}
65+
end
66+
67+
defp to_day_fraction(%{
68+
hour: hour,
69+
minute: minute,
70+
second: second,
71+
microsecond: {_, _} = microsecond,
72+
calendar: calendar
73+
}) do
74+
calendar.time_to_day_fraction(hour, minute, second, microsecond)
75+
end
76+
else
77+
defp time_add(time, amount, unit \\ :second), do: Time.add(time, amount, unit)
78+
end
79+
end

mix.exs

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule Cocktail.Mixfile do
22
use Mix.Project
33

4-
@version "0.7.0"
4+
@version "0.8.0"
55

66
def project do
77
[
@@ -69,7 +69,6 @@ defmodule Cocktail.Mixfile do
6969
{:excoveralls, "~> 0.7", only: :test},
7070
{:inch_ex, ">= 0.0.0", only: :docs},
7171
{:mix_test_watch, "~> 0.3", only: :dev, runtime: false},
72-
{:poison, ">= 2.0.0"},
7372
{:timex, "~> 3.1"}
7473
]
7574
end

test/cocktail/reversibility_test.exs

+10
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ defmodule Cocktail.ReversibilityTest do
9090
|> assert_reversible()
9191
end
9292

93+
test "time range option" do
94+
~N[2017-09-09 09:00:00]
95+
|> Schedule.new()
96+
|> Schedule.add_recurrence_rule(
97+
:daily,
98+
time_range: %{start_time: ~T[09:00:00], end_time: ~T[11:00:00], interval_seconds: 1_800}
99+
)
100+
|> assert_reversible()
101+
end
102+
93103
test "empty days" do
94104
~N[2017-09-09 09:00:00] |> Schedule.new() |> Schedule.add_recurrence_rule(:daily, days: []) |> assert_reversible()
95105
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
defmodule Cocktail.TimeRangeTest do
2+
use ExUnit.Case
3+
4+
alias Cocktail.{Schedule}
5+
6+
import Cocktail.TestSupport.DateTimeSigil
7+
8+
test "a daily schedule with a time range option" do
9+
schedule =
10+
~N[2017-09-09 09:00:00]
11+
|> Schedule.new()
12+
|> Schedule.add_recurrence_rule(
13+
:daily,
14+
time_range: %{start_time: ~T[09:00:00], end_time: ~T[11:00:00], interval_seconds: 1_800}
15+
)
16+
17+
times = schedule |> Schedule.occurrences() |> Enum.take(6)
18+
19+
assert times == [
20+
~N[2017-09-09 09:00:00],
21+
~N[2017-09-09 09:30:00],
22+
~N[2017-09-09 10:00:00],
23+
~N[2017-09-09 10:30:00],
24+
~N[2017-09-09 11:00:00],
25+
~N[2017-09-10 09:00:00]
26+
]
27+
end
28+
29+
test "a daily schedule with a zoned datetime and a time range option" do
30+
schedule =
31+
~N[2017-09-09 09:00:00]
32+
|> Timex.to_datetime("America/Chicago")
33+
|> Schedule.new()
34+
|> Schedule.add_recurrence_rule(
35+
:daily,
36+
time_range: %{start_time: ~T[09:00:00], end_time: ~T[11:00:00], interval_seconds: 1_800}
37+
)
38+
39+
times = schedule |> Schedule.occurrences() |> Enum.take(6)
40+
41+
assert times == [
42+
~Y[2017-09-09 09:00:00 America/Chicago],
43+
~Y[2017-09-09 09:30:00 America/Chicago],
44+
~Y[2017-09-09 10:00:00 America/Chicago],
45+
~Y[2017-09-09 10:30:00 America/Chicago],
46+
~Y[2017-09-09 11:00:00 America/Chicago],
47+
~Y[2017-09-10 09:00:00 America/Chicago]
48+
]
49+
end
50+
end

0 commit comments

Comments
 (0)