Current section
Files
Jump to
Current section
Files
lib/icalendar/rrule.ex
defmodule ICalendar.RRULE do
@moduledoc """
Serialize and deserialize RRULEs
"""
alias ICalendar.Property
alias ICalendar.Decoder
def invert_map(map = %{}) do
map
|> Enum.reduce(%{}, fn({key, value}, acc) ->
Map.put(acc, value, key)
end)
end
@frequencies %{
"SECONDLY" => :secondly,
"MINUTELY" => :minutely,
"HOURLY" => :hourly,
"DAILY" => :daily,
"WEEKLY" => :weekly,
"MONTHLY" => :monthly,
"YEARLY" => :yearly
}
@days %{
"SU" => :sunday,
"MO" => :monday,
"TU" => :tuesday,
"WE" => :wednesday,
"TH" => :thursday,
"FR" => :friday,
"SA" => :saturday
}
@months [:january, :february, :march,
:april, :may, :june, :july,
:august, :september, :october,
:november, :december]
@string_to_atom_keys %{
"FREQ" => :frequency,
"COUNT" => :count,
"UNTIL" => :until,
"INTERVAL" => :interval,
"BYSECOND" => :by_second,
"BYMINUTE" => :by_minute,
"BYHOUR" => :by_hour,
"BYMONTHDAY" => :by_month_day,
"BYYEARDAY" => :by_year_day,
"BYWEEKNO" => :by_week_number,
"BYSETPOS" => :by_set_pos,
"BYDAY" => :by_day,
"BYMONTH" => :by_month,
"WKST" => :week_start,
"X-NAME" => :x_name
}
defstruct frequency: nil,
until: nil,
count: nil,
interval: nil,
by_second: [],
by_minute: [],
by_hour: [],
by_day: [],
by_month: [],
by_month_day: [],
by_year_day: [],
by_week_number: [],
by_set_pos: [],
week_start: nil,
x_name: nil,
errors: []
@doc ~S"""
Produces a list of months in the year
"""
def months, do: @months
@doc ~S"""
Produce inverse of @string_to_atom_keys at compile time
"""
defmacro _atom_to_string_keys do
quote do: invert_map(@string_to_atom_keys)
end
@doc ~S"""
Produce inverse of @frequencies at compile time
"""
defmacro _inverted_frequencies do
quote do: invert_map(@frequencies)
end
@doc ~S"""
Produce inverse of @days at compile time
"""
defmacro _inverted_days do
quote do: invert_map(@days)
end
@doc ~S"""
Produces a list of RRULE iCal String keys and their %ICalendar.RRULE{}
counterparts.
"""
def string_to_atom_keys, do: @string_to_atom_keys
def string_to_atom_keys(:inverted), do: _atom_to_string_keys()
@doc ~S"""
Produces a list of valid RRULE frequencies and their %ICalendar.RRULE{}
counterparts.
"""
def frequencies, do: @frequencies
def frequencies(:inverted), do: _inverted_frequencies()
@doc ~S"""
Produces a list of valid RRULE days and their %ICalendar.RRULE{}
counterparts.
"""
def days, do: @days
def days(:inverted), do: _inverted_days()
@doc ~S"""
This function is used to determine whether an RRULE struct has errors or not
iex> ICalendar.RRULE.valid(%ICalendar.RRULE{errors: []})
true
iex> ICalendar.RRULE.valid(%ICalendar.RRULE{errors: ["error"]})
false
"""
def valid(%ICalendar.RRULE{errors: []}), do: true
def valid(%ICalendar.RRULE{errors: err}) when is_list(err), do: false
@doc ~S"""
This function is used to deserialize an RRULE string into a struct
Sending an RRULE deserializes it:
iex> "FREQ=DAILY;COUNT=10"
...> |> ICalendar.RRULE.deserialize
{:ok,
%ICalendar.RRULE{
:frequency => :daily,
:count => 10
}}
Sending a bad RRULE produces an error:
iex> "COUNT=1;UNTIL=20151224T083000"
...> |> ICalendar.RRULE.deserialize
{:error, ["You can only set UNTIL or COUNT: not both at the same time"]}
"""
def deserialize(rrule) when is_bitstring(rrule) do
rrule
|> String.split(";")
|> Enum.map(fn (prop) ->
[key, value] = String.split(prop, "=", parts: 2, trim: true)
[key, params] = Decoder.retrieve_params(key)
%Property{key: String.upcase(key), value: value, params: params}
end)
|> Enum.map(&validate_param/1)
|> Enum.reduce(%ICalendar.RRULE{}, &parse_attr/2)
|> validate()
|> respond()
end
def respond(rule = %ICalendar.RRULE{}) do
if valid(rule) do
{:ok, rule}
else
{:error, rule.errors}
end
end
def validate(rule = %ICalendar.RRULE{}) do
# If UNTIL and COUNT are both set, then it's an error
if rule.until && rule.count do
errors = [
"You can only set UNTIL or COUNT: not both at the same time"
| rule.errors]
Map.put(rule, :errors, errors)
else
rule
end
end
def parse_attr(%{key: key, value: value}, accumulator) do
key =
case Map.fetch(@string_to_atom_keys, key) do
{:ok, atom} -> atom
{:error, _reason} ->
key
|> String.downcase
|> String.to_atom
end
Map.put(accumulator, key, value)
end
def parse_attr({:error, %Property{}, err_msg}, accumulator) do
{:ok, errors} = Map.fetch(accumulator, :errors)
Map.put(accumulator, :errors, [ err_msg | errors ])
end
@doc """
This function is used to split up values into a list format. An operation is
optionally passed to it to format each result in a certain way.
iex> RRULE.parse_value_as_list("a,b,c")
["a", "b", "c"]
iex> RRULE.parse_value_as_list("1,2,3", &String.to_integer/1)
[1,2,3]
"""
def parse_value_as_list(value), do: parse_value_as_list(value, &(&1))
def parse_value_as_list(value, operation) when is_function(operation) do
value
|> String.split(",")
|> Enum.map(operation)
end
def validate_param(prop = %Property{key: "FREQ", value: value}) do
case Map.fetch(@frequencies, value) do
{:ok, freq} -> %{prop | value: freq}
:error -> {:error, prop, "'#{value}' is not an accepted frequency"}
end
end
def validate_param(prop = %Property{key: "UNTIL", value: value}) do
out = Decoder.to_datetime(value, %{"TZID" => "Etc/UTC"})
case out do
{:ok, date} -> %{prop | value: date}
_ -> {:error, prop, "'#{value}' is not a valid date"}
end
end
def validate_param(prop = %Property{key: "COUNT", value: value}) do
value = String.to_integer(value)
case value >= 1 do
true -> %{prop | value: value}
false -> {:error, prop, "'COUNT' must be >= 1 if it is set"}
end
end
def validate_param(prop = %Property{key: "INTERVAL", value: value}) do
value = String.to_integer(value)
case value >= 1 do
true -> %{prop | value: value}
false -> {:error, prop, "'INTERVAL' must be >= 1 if it is set"}
end
end
def validate_param(prop = %Property{key: "BYSECOND", value: value}) do
value = parse_value_as_list(value, &String.to_integer/1)
validation =
value
|> Enum.map(&(&1 >= 0 && &1 <= 59))
case false in validation do
false -> %{prop | value: value}
true -> {
:error,
prop,
"'BYSECOND' must be between 0 and 59 if it is set"
}
end
end
def validate_param(prop = %Property{key: "BYMINUTE", value: value}) do
value = parse_value_as_list(value, &String.to_integer/1)
validation =
value
|> Enum.map(&(&1 >= 0 && &1 <= 59))
case false in validation do
false -> %{prop | value: value}
true -> {
:error,
prop,
"'BYMINUTE' must be between 0 and 59 if it is set"
}
end
end
def validate_param(prop = %Property{key: "BYHOUR", value: value}) do
value = parse_value_as_list(value, &String.to_integer/1)
validation =
value
|> Enum.map(&(&1 >= 0 && &1 <= 23))
case false in validation do
false -> %{prop | value: value}
true -> {
:error,
prop,
"'BYHOUR' must be between 0 and 23 if it is set"
}
end
end
def validate_param(prop = %Property{key: "BYMONTHDAY", value: value}) do
value = parse_value_as_list(value, &String.to_integer/1)
validation =
value
|> Enum.map(&((&1 >= 1 && &1 <= 31) || (&1 <= 1 && &1 >= -31 && &1 != 0)))
case false in validation do
false -> %{prop | value: value}
true -> {
:error,
prop,
"'BYMONTHDAY' must be between 1 and 31 or -1 and -31 if it is set"
}
end
end
def validate_param(prop = %Property{key: "BYYEARDAY", value: value}) do
value = parse_value_as_list(value, &String.to_integer/1)
validation =
value
|> Enum.map(&(
(&1 >= 1 && &1 <= 366) || (&1 <= 1 && &1 >= -366 && &1 != 0)))
case false in validation do
false -> %{prop | value: value}
true -> {
:error,
prop,
"'BYYEARDAY' must be between 1 and 366 or -1 and -366 if it is set"
}
end
end
def validate_param(prop = %Property{key: "BYWEEKNO", value: value}) do
value = parse_value_as_list(value, &String.to_integer/1)
validation =
value
|> Enum.map(&((&1 >= 1 && &1 <= 53) || (&1 <= 1 && &1 >= -53 && &1 != 0)))
case false in validation do
false -> %{prop | value: value}
true -> {
:error,
prop,
"'BYWEEKNO' must be between 1 and 53 or -1 and -53 if it is set"
}
end
end
def validate_param(prop = %Property{key: "BYSETPOS", value: value}) do
value = parse_value_as_list(value, &String.to_integer/1)
validation =
value
|> Enum.map(&(
(&1 >= 1 && &1 <= 366) || (&1 <= 1 && &1 >= -366 && &1 != 0)))
case false in validation do
false -> %{prop | value: value}
true -> {
:error,
prop,
"'BYSETPOS' must be between 1 and 366 or -1 and -366 if it is set"
}
end
end
def validate_param(prop = %Property{key: "BYDAY", value: value}) do
# Upcase the values
value = parse_value_as_list(value, fn val ->
val
|> String.upcase()
|> Integer.parse()
|> case do
:error -> val
tuple -> tuple
end
end)
# Check to see if they're in the list of days
validation = Enum.map(value, fn
{_num, val} -> val in Map.keys(@days)
val -> val in Map.keys(@days)
end)
# If they all are, then fetch the value for all of them and add them to the
# property.
case false in validation do
false -> %{prop | value: Enum.map(value, fn
{num, val} -> {num, Map.fetch!(@days, val)}
val -> Map.fetch!(@days, val)
end)}
true -> {
:error,
prop,
"'BYDAY' must have a valid day string if set"
}
end
end
def validate_param(prop = %Property{key: "WKST", value: value}) do
value = String.upcase(value)
case Map.fetch(@days, value) do
{:ok, day} -> %{prop | value: day}
_ -> {
:error,
prop,
"'WKST' must have a valid day string if set"
}
end
end
def validate_param(prop = %Property{key: "BYMONTH", value: value}) do
value = parse_value_as_list(value, &String.to_integer/1)
validation =
value
|> Enum.map(&(&1 >= 1 && &1 <= 12))
case false in validation do
false -> %{prop | value: Enum.map(value, &(Enum.at(@months, &1 - 1)))}
true -> {
:error,
prop,
"'BYMONTH' must be between 1 and 12 if it is set"
}
end
end
def validate_param(prop = %Property{key: "X-NAME"}), do: prop
def validate_param(prop = %Property{key: key}) do
{:error, prop, "'#{key}' is not a recognised property"}
end
end