Packages

Parse and manipulate iCalendar (RFC5545) and vCard objects (RFC6350)

Current section

Files

Jump to
vobject lib icalendar decoder.ex
Raw

lib/icalendar/decoder.ex

defmodule ICalendar.Decoder do
def decode(string) do
val =
string
|> String.replace(~r/\r?\n[ \t]/, "")
|> String.split(~r/\r?\n/)
|> Enum.reduce([], &stree/2)
|> parse
{:ok, val}
end
@types %{
"VEVENT" => :event,
"VALARM" => :alarm,
"VCALENDAR" => :calendar,
"VTODO" => :todo,
"VJOURNAL" => :journal,
"VFREEBUSY" => :freebusy,
"VTIMEZONE" => :timezone,
"STANDARD" => :standard,
"DAYLIGHT" => :daylight
}
@properties [
"ACTION",
"ATTACH",
"ATTENDEE",
"CALSCALE",
"CATEGORIES",
"CLASS",
"COMMENT",
"COMPLETED",
"CONTACT",
"CREATED",
"DESCRIPTION",
"DTEND",
"DTSTAMP",
"DTSTART",
"DUE",
"DURATION",
"EXDATE",
"EXRULE",
"FREEBUSY",
"GEO",
"LAST-MODIFIED",
"LOCATION",
"METHOD",
"ORGANIZER",
"PERCENT-COMPLETE",
"PRIORITY",
"PRODID",
"RDATE",
"RECURRENCE-ID",
"RELATED-TO",
"REPEAT",
"REQUEST-STATUS",
"RESOURCES",
"RRULE",
"SEQUENCE",
"STATUS",
"SUMMARY",
"TRANSP",
"TRIGGER",
"TZID",
"TZNAME",
"TZOFFSETFROM",
"TZOFFSETTO",
"TZURL",
"UID",
"URL",
"VERSION",
]
@prop_types %{
action: :text,
attach: :text, # uri or binary
attendee: :cal_address,
calscale: :text,
categories: :text,
class: :text,
comment: :text,
completed: :date_time,
contact: :text,
created: :date_time,
description: :text,
dtend: :date_time, # check VALUE for :date
dtstamp: :date_time,
dtstart: :date_time,
due: :date_time,
duration: :duration,
exdate: :date_time,
exrule: :recur, # deprecated
freebusy: :period,
geo: :float,
last_modified: :date_time,
location: :text,
method: :text,
organizer: :cal_address,
percent_complete: :integer,
priority: :integer,
prodid: :text,
rdate: :date_time, # :date or :period
recurrence_id: :date_time,
related_to: :text,
repeat: :integer,
request_status: :text,
resources: :text,
rrule: :recur,
sequence: :integer,
status: :text,
summary: :text,
transp: :text,
trigger: :duration, # can be date_time
tzid: :text,
tzname: :text,
tzoffsetfrom: :utc_offset,
tzoffsetto: :utc_offset,
tzurl: :uri,
uid: :text,
url: :uri,
version: :text,
}
@parameters [
"ALTREP",
"CN",
"CUTYPE",
"DELEGATED-FROM",
"DELEGATED-TO",
"DIR",
"ENCODING",
"FMTTYPE",
"FBTYPE",
"LANGUAGE",
"MEMBER",
"PARTSTAT",
"RANGE",
"RELATED",
"RELTYPE",
"ROLE",
"RSVP",
"SENT-BY",
"TZID",
"VALUE"
]
defp to_key(string) do
# TODO limit to_atom to @properties + @parameters
string
|> String.replace("-", "_")
|> String.downcase
|> String.to_atom
end
# tokenize data into a syntax tree
defp stree("", stack), do: stack
# pop a new collection onto the stack
defp stree("BEGIN:" <> type, stack) do
[[@types[type]] | stack]
end
defp stree("END:" <> type, [item, col | stack]) do
# TODO: make sure end matches begin type
[[Enum.reverse(item) | col] | stack]
end
# this is annoying
defp stree("END:" <> type, [col]) do
# TODO: make sure end matches begin type
Enum.reverse(col)
end
defp stree(line, [col | stack]) do
case String.split(line, ":", parts: 2) do
[key, val] ->
[key, params] = retrieve_params(key)
[[{to_key(String.upcase(key)), val, params} | col] | stack]
end
end
# Transform the stree structure into concrete types
defp parse(key, acc \\ %{})
# end of parsing!
defp parse([], acc), do: acc
# matches the outer-most value
defp parse([key | attrs], _acc) when is_atom(key) do
{key, parse(attrs)}
end
# matches a collection
defp parse([obj | rest], acc) when is_list(obj) do
{key, attrs} = parse(obj)
parse(rest, Map.put(acc, key, attrs))
end
# matches a property
defp parse([{key, val, params} | attrs], acc) do
{type, params} = determine_type(key, params)
val = {parse_vals(val, type, params), params, type}
l = Map.get(acc, key)
if l do
# TODO: have a list of properties that can be multiple
parse(attrs, Map.put(acc, key, [val | List.wrap(l)]))
else
parse(attrs, Map.put(acc, key, val))
end
end
# --------
# VALUE can override the default type
defp determine_type(_key, %{value: type} = params) do
{to_key(type), Map.drop(params, [:value])}
end
defp determine_type(key, params), do: {@prop_types[key] || :unknown, params}
def parse_vals(val, type, params) do
# TODO: ; to tuple, , to array
case String.split(val, ~r/(?<!\\)[;,]/) do
[val] -> parse_val(val, type, params)
vals -> Enum.map(vals, fn val -> parse_val(val, type, params) end)
end
end
def parse_val(val, :binary, %{encoding: "BASE64"}) do
Base.decode64!(val)
end
def parse_val("TRUE", :boolean, _params), do: true
def parse_val("FALSE", :boolean, _params), do: false
def parse_val(val, :cal_address, params) do
# TODO
val
end
def parse_val(val, :date, params) do
{:ok, date} = Timex.parse(val, "{YYYY}{0M}{0D}")
date
end
def parse_val(val, :date_time, params) do
{:ok, date} = to_date(val, params)
date
end
# negative duration
def parse_val("-" <> val, :duration, params) do
val
|> parse_val(:duration, params)
|> Timex.Duration.invert()
end
# strip plus
def parse_val("+" <> val, :duration, params) do
parse_val(val, :duration, params)
end
def parse_val(val, :duration, _params) do
{:ok, val} =
val
|> String.trim_trailing("T") # for some reason 1PDT is valid
|> Timex.Parse.Duration.Parsers.ISO8601Parser.parse
val
end
def parse_val(val, :float, _params) do
{f, ""} = Float.parse(val)
f
end
def parse_val(val, :integer, _params) do
{f, ""} = Integer.parse(val)
f
end
def parse_val(val, :period, _params) do
[from, to] = String.split(val, "/", parts: 2, trim: true)
from = parse_val(from, :date_time, %{})
# to can either be a duration or a date_time
to = if String.starts_with?(to, "P") do
{:ok, val} = Timex.Parse.Duration.Parsers.ISO8601Parser.parse(to)
val |> Map.drop([:__struct__]) |> Map.to_list
else
parse_val(to, :date_time, %{})
end
Timex.Interval.new(from: from, until: to)
end
def parse_val(val, :recur, _params) do
{:ok, rrule} = ICalendar.RRULE.deserialize(val)
rrule
end
def parse_val(val, :text, _params), do: unescape(val)
def parse_val(val, :time, params) do
{:ok, time} = to_time(val, params)
time
end
def parse_val(val, :uri, _params), do: val
def parse_val(val, :utc_offset, _params) do
# TODO (not sure what the best way to store this is)
val
end
# this could be x-vals
def parse_val(val, :unknown, _), do: val
@doc ~S"""
This function extracts parameter data from a key in an iCalendar string.
iex> ICalendar.Decoder.retrieve_params(
...> "DTSTART;TZID=America/Chicago")
["DTSTART", %{tzid: "America/Chicago"}]
It should be able to handle multiple parameters per key:
iex> ICalendar.Decoder.retrieve_params(
...> "KEY;LOREM=ipsum;DOLOR=sit")
["KEY", %{lorem: "ipsum", dolor: "sit"}]
"""
def retrieve_params(key) do
[key | params] = String.split(key, ";", trim: true)
params =
params
|> Enum.reduce(%{}, fn(param, acc) ->
[key, val] = String.split(param, "=", parts: 2, trim: true)
# trim only leading and trailing double quote
Map.merge(acc, %{to_key(key) => String.trim(val, ~s("))})
end)
[key, params]
end
@doc ~S"""
This function is designed to parse iCal datetime strings into erlang dates.
It should be able to handle dates from the past:
iex> {:ok, date} = ICalendar.Decoder.to_date("19930407T153022Z")
...> Timex.to_erl(date)
{{1993, 4, 7}, {15, 30, 22}}
As well as the future:
iex> {:ok, date} = ICalendar.Decoder.to_date("39930407T153022Z")
...> Timex.to_erl(date)
{{3993, 4, 7}, {15, 30, 22}}
And should return error for incorrect dates:
iex> ICalendar.Decoder.to_date("1993/04/07")
{:error, "Expected `2 digit month` at line 1, column 5."}
It should handle timezones from the Olson Database:
iex> {:ok, date} = ICalendar.Decoder.to_date("19980119T020000",
...> %{tzid: "America/Chicago"})
...> [Timex.to_erl(date), date.time_zone]
[{{1998, 1, 19}, {2, 0, 0}}, "America/Chicago"]
"""
def to_date(date_string, %{tzid: timezone}) do
date_string =
case String.last(date_string) do
"Z" -> date_string
_ -> date_string <> "Z"
end
Timex.parse(date_string <> timezone, "{YYYY}{0M}{0D}T{h24}{m}{s}Z{Zname}")
# This is slow
#
# Timex.Parse.DateTime.Parser.parse/3 7 51.783 0.036
# Timex.Parse.DateTime.Parser.parse!/3 7 51.783 0.036 <--
# Timex.Parse.DateTime.Tokenizers.Default.tokenize/1 7 29.750 0.063
# Timex.Parse.DateTime.Parser.do_parse/3 7 21.997 0.148
#
# since it's a rigid format, use binary matching
end
def to_date(date_string, %{}) do
to_date(date_string, %{tzid: "Etc/UTC"})
end
def to_date(date_string) do
to_date(date_string, %{tzid: "Etc/UTC"})
end
def to_time(time_string, %{tzid: timezone}) do
time_string =
case String.last(time_string) do
"Z" -> time_string
_ -> time_string <> "Z"
end
Timex.parse(time_string <> timezone, "{h24}{m}{s}Z{Zname}")
end
def to_time(time_string, %{}) do
to_time(time_string, %{"TZID" => "Etc/UTC"})
end
def to_time(time_string) do
to_time(time_string, %{"TZID" => "Etc/UTC"})
end
@doc ~S"""
This function should strip any sanitization that has been applied to content
within an iCal string.
iex> ICalendar.Decoder.unescape(~s(lorem\\, ipsum))
"lorem, ipsum"
"""
def unescape(string) do
String.replace(string, ~s(\\), "")
end
end