From 974b00c7ad5b73257abff7a381d265ed6f15528f Mon Sep 17 00:00:00 2001 From: Michael Bruderer Date: Mon, 24 Oct 2022 16:50:13 +0200 Subject: [PATCH 1/4] Add support to fetch list of calendars --- lib/caldav_client/calendar.ex | 26 +++++++++++++++++++++++++ lib/caldav_client/xml/builder.ex | 22 +++++++++++++++++++++ test/caldav_client/xml/builder_test.exs | 18 +++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/lib/caldav_client/calendar.ex b/lib/caldav_client/calendar.ex index f825001..6cab26a 100644 --- a/lib/caldav_client/calendar.ex +++ b/lib/caldav_client/calendar.ex @@ -11,6 +11,32 @@ defmodule CalDAVClient.Calendar do CalDAVClient.Tesla.ContentLengthMiddleware ] + @doc """ + Fetches the list of calendars (see [RFC 4791, section 4.2](https://tools.ietf.org/html/rfc4791#section-4.2)). + """ + @spec list(CalDAVClient.Client.t()) :: + :ok | {:error, any()} + def list(caldav_client) do + case caldav_client + |> make_tesla_client(@xml_middlewares) + |> Tesla.request( + method: :propfind, + url: "", + body: CalDAVClient.XML.Builder.build_list_calendar_xml() + ) do + {:ok, %Tesla.Env{status: code}} -> + case code do + 201 -> :ok + 207 -> :ok + 405 -> {:error, :already_exists} + _ -> {:error, reason_atom(code)} + end + + {:error, _reason} = error -> + error + end + end + @doc """ Creates a calendar (see [RFC 4791, section 5.3.1.2](https://tools.ietf.org/html/rfc4791#section-5.3.1.2)). diff --git a/lib/caldav_client/xml/builder.ex b/lib/caldav_client/xml/builder.ex index 60fdf1d..e6d3c5c 100644 --- a/lib/caldav_client/xml/builder.ex +++ b/lib/caldav_client/xml/builder.ex @@ -8,6 +8,28 @@ defmodule CalDAVClient.XML.Builder do @default_event_from DateTime.from_naive!(~N[0000-01-01 00:00:00], "Etc/UTC") @default_event_to DateTime.from_naive!(~N[9999-12-31 23:59:59], "Etc/UTC") + @doc """ + Generates XML request body to fetch the list of calendars + (see [RFC 4791, section 4.2](https://tools.ietf.org/html/rfc4791#section-4.2)). + """ + @spec build_list_calendar_xml() :: String.t() + def build_list_calendar_xml() do + {"D:propfind", + [ + "xmlns:D": "DAV:", + "xmlns:CS": "http://calendarserver.org/ns/", + "xmlns:C": "urn:ietf:params:xml:ns:caldav" + ], + [ + {"D:prop", nil, + [ + {"D:resourcetype"}, + {"D:displayname"} + ]} + ]} + |> serialize() + end + @doc """ Generates XML request body to create a calendar (see [RFC 4791, section 5.3.1.2](https://tools.ietf.org/html/rfc4791#section-5.3.1.2)). diff --git a/test/caldav_client/xml/builder_test.exs b/test/caldav_client/xml/builder_test.exs index e59f34f..617f364 100644 --- a/test/caldav_client/xml/builder_test.exs +++ b/test/caldav_client/xml/builder_test.exs @@ -3,6 +3,24 @@ defmodule CalDAVClient.XML.BuilderTest do use ExUnit.Case, async: true doctest CalDAVClient.XML.Builder + test "generates list calendar XML request body" do + # https://tools.ietf.org/html/rfc4791#section-4.2 + + actual = CalDAVClient.XML.Builder.build_list_calendar_xml() + + expected = """ + + + + + + + + """ + + assert_xml_identical(actual, expected) + end + test "generates create calendar XML request body" do # https://tools.ietf.org/html/rfc4791#section-5.3.1.2 From f40427017e30d0e7dca5da5a85212eb2a2fe3ff4 Mon Sep 17 00:00:00 2001 From: Michael Bruderer Date: Mon, 24 Oct 2022 20:41:33 +0200 Subject: [PATCH 2/4] Parse list of calendars and add example --- examples/calendar.ex | 4 ++ lib/caldav_client/calendar.ex | 18 +++-- lib/caldav_client/xml/parser.ex | 14 ++++ test/caldav_client/xml/parser_test.exs | 94 ++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/examples/calendar.ex b/examples/calendar.ex index 649ee37..86856fd 100644 --- a/examples/calendar.ex +++ b/examples/calendar.ex @@ -16,6 +16,10 @@ try do description: "This is an example calendar." ) + {:ok, calendars} = + client + |> CalDAVClient.Calendar.list() + :ok = client |> CalDAVClient.Calendar.update(calendar_url, diff --git a/lib/caldav_client/calendar.ex b/lib/caldav_client/calendar.ex index 6cab26a..a30f87c 100644 --- a/lib/caldav_client/calendar.ex +++ b/lib/caldav_client/calendar.ex @@ -6,6 +6,13 @@ defmodule CalDAVClient.Calendar do import CalDAVClient.HTTP.Error import CalDAVClient.Tesla + @type t :: %__MODULE__{ + url: String.t(), + name: String.t() + } + @enforce_keys [:url, :name] + defstruct @enforce_keys + @xml_middlewares [ CalDAVClient.Tesla.ContentTypeXMLMiddleware, CalDAVClient.Tesla.ContentLengthMiddleware @@ -24,13 +31,12 @@ defmodule CalDAVClient.Calendar do url: "", body: CalDAVClient.XML.Builder.build_list_calendar_xml() ) do + {:ok, %Tesla.Env{status: 207, body: response_xml}} -> + calendars = response_xml |> CalDAVClient.XML.Parser.parse_calendars() + {:ok, calendars} + {:ok, %Tesla.Env{status: code}} -> - case code do - 201 -> :ok - 207 -> :ok - 405 -> {:error, :already_exists} - _ -> {:error, reason_atom(code)} - end + {:error, reason_atom(code)} {:error, _reason} = error -> error diff --git a/lib/caldav_client/xml/parser.ex b/lib/caldav_client/xml/parser.ex index 95e2a48..25709e9 100644 --- a/lib/caldav_client/xml/parser.ex +++ b/lib/caldav_client/xml/parser.ex @@ -10,6 +10,7 @@ defmodule CalDAVClient.XML.Parser do @icalendar_xpath ~x"./*[local-name()='propstat']/*[local-name()='prop']/*[local-name()='calendar-data']/text()"s @etag_xpath ~x"./*[local-name()='propstat']/*[local-name()='prop']/*[local-name()='getetag']/text()"s + @cal_name_xpath ~x"./*[local-name()='propstat']/*[local-name()='prop']/*[local-name()='displayname']/text()"s @doc """ Parses XML response body into a list of events. """ @@ -23,4 +24,17 @@ defmodule CalDAVClient.XML.Parser do ) |> Enum.map(&struct(CalDAVClient.Event, &1)) end + + @doc """ + Parses XML response body into a list of calendars. + """ + @spec parse_calendars(response_xml :: String.t()) :: [CalDAVClient.Calendar.t()] + def parse_calendars(response_xml) do + response_xml + |> xpath(@event_xpath, + url: @url_xpath, + name: @cal_name_xpath + ) + |> Enum.map(&struct(CalDAVClient.Calendar, &1)) + end end diff --git a/test/caldav_client/xml/parser_test.exs b/test/caldav_client/xml/parser_test.exs index 82cbc30..ed51ffd 100644 --- a/test/caldav_client/xml/parser_test.exs +++ b/test/caldav_client/xml/parser_test.exs @@ -191,4 +191,98 @@ defmodule CalDAVClient.XML.ParserTest do assert actual == expected end + + test "parses calendars from XML response" do + xml = """ + + + + /calendars/blubbi/ + + + + + + + HTTP/1.1 200 OK + + + + + + HTTP/1.1 404 Not Found + + + + /calendars/blubbi/journals/ + + + + + + + + Journals + + HTTP/1.1 200 OK + + + + /calendars/blubbi/home/ + + + + + + + + Home + + HTTP/1.1 200 OK + + + + /calendars/blubbi/tasks/ + + + + + + + + Tasks + + HTTP/1.1 200 OK + + + + /calendars/blubbi/work/ + + + + + + + + Work + + HTTP/1.1 200 OK + + + + """ + + actual = xml |> CalDAVClient.XML.Parser.parse_calendars() + + expected = [ + %CalDAVClient.Calendar{name: "", url: "/calendars/blubbi/"}, + %CalDAVClient.Calendar{name: "Journals", url: "/calendars/blubbi/journals/"}, + %CalDAVClient.Calendar{name: "Home", url: "/calendars/blubbi/home/"}, + %CalDAVClient.Calendar{name: "Tasks", url: "/calendars/blubbi/tasks/"}, + %CalDAVClient.Calendar{name: "Work", url: "/calendars/blubbi/work/"} + ] + + assert actual == expected + end end From 76cd5c73e2535cf92ae97151193fe18b9da5ef4b Mon Sep 17 00:00:00 2001 From: Michael Bruderer Date: Tue, 25 Oct 2022 17:59:41 +0200 Subject: [PATCH 3/4] Extract additional information from calendar - Extract calendar type (eg. VEVENT or VTODO) - Extract timezone icalendar information --- lib/caldav_client/calendar.ex | 6 +- lib/caldav_client/xml/builder.ex | 4 +- lib/caldav_client/xml/parser.ex | 7 +- test/caldav_client/xml/builder_test.exs | 2 + test/caldav_client/xml/parser_test.exs | 322 ++++++++++++++++++------ 5 files changed, 257 insertions(+), 84 deletions(-) diff --git a/lib/caldav_client/calendar.ex b/lib/caldav_client/calendar.ex index a30f87c..ae604de 100644 --- a/lib/caldav_client/calendar.ex +++ b/lib/caldav_client/calendar.ex @@ -8,9 +8,11 @@ defmodule CalDAVClient.Calendar do @type t :: %__MODULE__{ url: String.t(), - name: String.t() + name: String.t(), + type: String.t(), + timezone: String.t() } - @enforce_keys [:url, :name] + @enforce_keys [:url, :name, :type, :timezone] defstruct @enforce_keys @xml_middlewares [ diff --git a/lib/caldav_client/xml/builder.ex b/lib/caldav_client/xml/builder.ex index e6d3c5c..f639eb6 100644 --- a/lib/caldav_client/xml/builder.ex +++ b/lib/caldav_client/xml/builder.ex @@ -24,7 +24,9 @@ defmodule CalDAVClient.XML.Builder do {"D:prop", nil, [ {"D:resourcetype"}, - {"D:displayname"} + {"D:displayname"}, + {"C:calendar-timezone"}, + {"C:supported-calendar-component-set"} ]} ]} |> serialize() diff --git a/lib/caldav_client/xml/parser.ex b/lib/caldav_client/xml/parser.ex index 25709e9..6e4c620 100644 --- a/lib/caldav_client/xml/parser.ex +++ b/lib/caldav_client/xml/parser.ex @@ -11,6 +11,9 @@ defmodule CalDAVClient.XML.Parser do @etag_xpath ~x"./*[local-name()='propstat']/*[local-name()='prop']/*[local-name()='getetag']/text()"s @cal_name_xpath ~x"./*[local-name()='propstat']/*[local-name()='prop']/*[local-name()='displayname']/text()"s + @cal_type_xpath ~x"./*[local-name()='propstat']/*[local-name()='prop']/*[local-name()='supported-calendar-component-set']/*[local-name()='comp']/@name"s + @cal_timezone_xpath ~x"./*[local-name()='propstat']/*[local-name()='prop']/*[local-name()='calendar-timezone']/text()"s + @doc """ Parses XML response body into a list of events. """ @@ -33,7 +36,9 @@ defmodule CalDAVClient.XML.Parser do response_xml |> xpath(@event_xpath, url: @url_xpath, - name: @cal_name_xpath + name: @cal_name_xpath, + type: @cal_type_xpath, + timezone: @cal_timezone_xpath ) |> Enum.map(&struct(CalDAVClient.Calendar, &1)) end diff --git a/test/caldav_client/xml/builder_test.exs b/test/caldav_client/xml/builder_test.exs index 617f364..08c14aa 100644 --- a/test/caldav_client/xml/builder_test.exs +++ b/test/caldav_client/xml/builder_test.exs @@ -14,6 +14,8 @@ defmodule CalDAVClient.XML.BuilderTest do + + """ diff --git a/test/caldav_client/xml/parser_test.exs b/test/caldav_client/xml/parser_test.exs index ed51ffd..b244af5 100644 --- a/test/caldav_client/xml/parser_test.exs +++ b/test/caldav_client/xml/parser_test.exs @@ -194,81 +194,209 @@ defmodule CalDAVClient.XML.ParserTest do test "parses calendars from XML response" do xml = """ - - - - /calendars/blubbi/ - - - - - - - HTTP/1.1 200 OK - - - - - - HTTP/1.1 404 Not Found - - - - /calendars/blubbi/journals/ - - - - - - - - Journals - - HTTP/1.1 200 OK - - - - /calendars/blubbi/home/ - - - - - - - - Home - - HTTP/1.1 200 OK - - - - /calendars/blubbi/tasks/ - - - - - - - - Tasks - - HTTP/1.1 200 OK - - - - /calendars/blubbi/work/ - - - - - - - - Work - - HTTP/1.1 200 OK - + + + + /calendars/blublub/ + + + + + + + HTTP/1.1 200 OK + + + + + + + + + HTTP/1.1 404 Not Found + + + + /calendars/blublub/journals/ + + + + + + + + + + + Journals + + HTTP/1.1 200 OK + + + + + + + HTTP/1.1 404 Not Found + + + + /calendars/blublub/home/ + + + + + + + + + + + Home + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Apple Inc.//macOS 12.4//EN + CALSCALE:GREGORIAN + BEGIN:VTIMEZONE + TZID:Europe/Zurich + BEGIN:DAYLIGHT + TZOFFSETFROM:+0100 + RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU + DTSTART:19810329T020000 + TZNAME:CEST + TZOFFSETTO:+0200 + END:DAYLIGHT + BEGIN:STANDARD + TZOFFSETFROM:+0200 + RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU + DTSTART:19961027T030000 + TZNAME:CET + TZOFFSETTO:+0100 + END:STANDARD + END:VTIMEZONE + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + + HTTP/1.1 404 Not Found + + + + /calendars/blublub/tasks/ + + + + + + + + + + + Tasks + + HTTP/1.1 200 OK + + + + + + + HTTP/1.1 404 Not Found + + + + /calendars/blublub/work/ + + + + + + + + + + + Work + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Apple Inc.//macOS 12.4//EN + CALSCALE:GREGORIAN + BEGIN:VTIMEZONE + TZID:Europe/Zurich + BEGIN:DAYLIGHT + TZOFFSETFROM:+0100 + RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU + DTSTART:19810329T020000 + TZNAME:CEST + TZOFFSETTO:+0200 + END:DAYLIGHT + BEGIN:STANDARD + TZOFFSETFROM:+0200 + RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU + DTSTART:19961027T030000 + TZNAME:CET + TZOFFSETTO:+0100 + END:STANDARD + END:VTIMEZONE + END:VCALENDAR + + + HTTP/1.1 200 OK + + + + + + HTTP/1.1 404 Not Found + + + + /calendars/blublub/inbox/ + + + + + + + + HTTP/1.1 200 OK + + + + + + + + + HTTP/1.1 404 Not Found + + + + /calendars/blublub/outbox/ + + + + + + + + HTTP/1.1 200 OK + + + + + + + + + HTTP/1.1 404 Not Found + """ @@ -276,11 +404,45 @@ defmodule CalDAVClient.XML.ParserTest do actual = xml |> CalDAVClient.XML.Parser.parse_calendars() expected = [ - %CalDAVClient.Calendar{name: "", url: "/calendars/blubbi/"}, - %CalDAVClient.Calendar{name: "Journals", url: "/calendars/blubbi/journals/"}, - %CalDAVClient.Calendar{name: "Home", url: "/calendars/blubbi/home/"}, - %CalDAVClient.Calendar{name: "Tasks", url: "/calendars/blubbi/tasks/"}, - %CalDAVClient.Calendar{name: "Work", url: "/calendars/blubbi/work/"} + %CalDAVClient.Calendar{name: "", timezone: "", type: "", url: "/calendars/blublub/"}, + %CalDAVClient.Calendar{ + name: "Journals", + timezone: "", + type: "VJOURNAL", + url: "/calendars/blublub/journals/" + }, + %CalDAVClient.Calendar{ + name: "Home", + timezone: + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Apple Inc.//macOS 12.4//EN\nCALSCALE:GREGORIAN\nBEGIN:VTIMEZONE\nTZID:Europe/Zurich\nBEGIN:DAYLIGHT\nTZOFFSETFROM:+0100\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\nDTSTART:19810329T020000\nTZNAME:CEST\nTZOFFSETTO:+0200\nEND:DAYLIGHT\nBEGIN:STANDARD\nTZOFFSETFROM:+0200\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\nDTSTART:19961027T030000\nTZNAME:CET\nTZOFFSETTO:+0100\nEND:STANDARD\nEND:VTIMEZONE\nEND:VCALENDAR\n", + type: "VEVENT", + url: "/calendars/blublub/home/" + }, + %CalDAVClient.Calendar{ + name: "Tasks", + timezone: "", + type: "VTODO", + url: "/calendars/blublub/tasks/" + }, + %CalDAVClient.Calendar{ + name: "Work", + timezone: + "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Apple Inc.//macOS 12.4//EN\nCALSCALE:GREGORIAN\nBEGIN:VTIMEZONE\nTZID:Europe/Zurich\nBEGIN:DAYLIGHT\nTZOFFSETFROM:+0100\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\nDTSTART:19810329T020000\nTZNAME:CEST\nTZOFFSETTO:+0200\nEND:DAYLIGHT\nBEGIN:STANDARD\nTZOFFSETFROM:+0200\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\nDTSTART:19961027T030000\nTZNAME:CET\nTZOFFSETTO:+0100\nEND:STANDARD\nEND:VTIMEZONE\nEND:VCALENDAR\n", + type: "VEVENT", + url: "/calendars/blublub/work/" + }, + %CalDAVClient.Calendar{ + name: "", + timezone: "", + type: "", + url: "/calendars/blublub/inbox/" + }, + %CalDAVClient.Calendar{ + name: "", + timezone: "", + type: "", + url: "/calendars/blublub/outbox/" + } ] assert actual == expected From 0681745d7f4c67ce6bc5818ca2e4701dc6c3de16 Mon Sep 17 00:00:00 2001 From: Michael Bruderer Date: Fri, 23 Jun 2023 10:49:24 +0200 Subject: [PATCH 4/4] Spec fix as suggested by mdlkxzmcp --- lib/caldav_client/calendar.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/caldav_client/calendar.ex b/lib/caldav_client/calendar.ex index ae604de..2e6452f 100644 --- a/lib/caldav_client/calendar.ex +++ b/lib/caldav_client/calendar.ex @@ -24,7 +24,7 @@ defmodule CalDAVClient.Calendar do Fetches the list of calendars (see [RFC 4791, section 4.2](https://tools.ietf.org/html/rfc4791#section-4.2)). """ @spec list(CalDAVClient.Client.t()) :: - :ok | {:error, any()} + {:ok, [t()]} | {:error, any()} def list(caldav_client) do case caldav_client |> make_tesla_client(@xml_middlewares)