diff --git a/lib/calendav/client.rb b/lib/calendav/client.rb index f12a084..184d0f2 100644 --- a/lib/calendav/client.rb +++ b/lib/calendav/client.rb @@ -4,6 +4,7 @@ require_relative "endpoint" require_relative "clients/calendars_client" require_relative "clients/events_client" +require_relative "clients/todos_client" require_relative "requests/current_user_principal" module Calendav @@ -21,6 +22,10 @@ def events @events = Clients::EventsClient.new(self, endpoint, credentials) end + def todos + @todos = Clients::TodosClient.new(self, endpoint, credentials) + end + def principal_url @principal_url ||= begin request = Requests::CurrentUserPrincipal.call diff --git a/lib/calendav/clients/todos_client.rb b/lib/calendav/clients/todos_client.rb new file mode 100644 index 0000000..aa517ae --- /dev/null +++ b/lib/calendav/clients/todos_client.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative "../errors" +require_relative "../todo" +require_relative "../requests/list_todos" + +module Calendav + module Clients + class TodosClient + def initialize(client, endpoint, credentials) + @client = client + @endpoint = endpoint + @credentials = credentials + end + + def create(calendar_url, todo_identifier, ics) + todo_url = merged_url(calendar_url, todo_identifier) + result = endpoint.put(ics, url: todo_url, content_type: :ics) + + Todo.new( + url: result.headers["Location"] || todo_url, + etag: result.headers["ETag"] + ) + end + + def delete(todo_url, etag: nil) + endpoint.delete(url: todo_url, etag: etag) + rescue PreconditionError + false + end + + def find(todo_url) + response = endpoint.get(url: todo_url) + + Todo.new( + url: todo_url, + calendar_data: response.body.to_s, + etag: response.headers["ETag"] + ) + end + + def list(calendar_url) + request = Requests::ListTodos.call + + endpoint + .report(request.to_xml, url: calendar_url, depth: 1) + .reject { |node| node.xpath(".//caldav:calendar-data").text.empty? } + .collect { |node| Todo.from_xml(calendar_url, node) } + end + + def update(todo_url, ics, etag: nil) + result = endpoint.put( + ics, url: todo_url, content_type: :ics, etag: etag + ) + + Todo.new( + url: todo_url, + etag: result.headers["ETag"] + ) + rescue PreconditionError + nil + end + + private + + attr_reader :client, :endpoint, :credentials + + def merged_url(calendar_url, todo_identifier) + "#{calendar_url.delete_suffix('/')}/#{todo_identifier}" + end + end + end +end diff --git a/lib/calendav/parsers/todo_xml.rb b/lib/calendav/parsers/todo_xml.rb new file mode 100644 index 0000000..1c2d253 --- /dev/null +++ b/lib/calendav/parsers/todo_xml.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Calendav + module Parsers + class TodoXML + def self.call(...) + new(...).call + end + + def initialize(element) + @element = element + end + + def call + { + calendar_data: value(".//caldav:calendar-data"), + etag: value(".//dav:getetag") + } + end + + private + + attr_reader :element + + def value(xpath) + node = element.xpath(xpath) + return nil if node.children.empty? + + if node.children.any?(&:element?) + node.children.select(&:element?).collect(&:to_xml).join + else + node.children.text + end + end + end + end +end diff --git a/lib/calendav/requests/list_todos.rb b/lib/calendav/requests/list_todos.rb new file mode 100644 index 0000000..fe38a80 --- /dev/null +++ b/lib/calendav/requests/list_todos.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "nokogiri" + +require_relative "../namespaces" + +module Calendav + module Requests + class ListTodos + def self.call(...) + new.call + end + + def call + Nokogiri::XML::Builder.new do |xml| + xml["caldav"].public_send("calendar-query", NAMESPACES) do + xml["dav"].prop do + xml["dav"].getetag + xml["caldav"].public_send(:"calendar-data") + end + xml["caldav"].filter do + xml["caldav"].public_send(:"comp-filter", name: "VCALENDAR") do + xml["caldav"].public_send(:"comp-filter", name: "VTODO") + end + end + end + end + end + + private + + def from + return nil if @from.nil? + + @from.utc.iso8601.delete(":-") + end + + def to + return nil if @to.nil? + + @to.utc.iso8601.delete(":-") + end + + def range? + to || from + end + end + end +end diff --git a/lib/calendav/requests/make_calendar.rb b/lib/calendav/requests/make_calendar.rb index 15a7887..7170ac2 100644 --- a/lib/calendav/requests/make_calendar.rb +++ b/lib/calendav/requests/make_calendar.rb @@ -38,6 +38,7 @@ def call :"supported-calendar-component-set" ) do xml["caldav"].comp name: "VEVENT" + xml["caldav"].comp name: "VTODO" end end end diff --git a/lib/calendav/todo.rb b/lib/calendav/todo.rb new file mode 100644 index 0000000..20e1337 --- /dev/null +++ b/lib/calendav/todo.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require_relative "contextual_url" +require_relative "parsers/todo_xml" + +module Calendav + class Todo + ATTRIBUTES = %i[url calendar_data etag].freeze + + def self.from_xml(host, node) + new( + { + url: ContextualURL.call(host, node.xpath("./dav:href").text) + }.merge( + Parsers::TodoXML.call(node) + ) + ) + end + + def initialize(attributes = {}) + @attributes = attributes + end + + ATTRIBUTES.each do |attribute| + define_method(attribute) { attributes[attribute] } + end + + def to_h + attributes.dup + end + + def summary + inner_todo.summary + end + + def due + inner_todo.due + end + + def status + inner_todo.status + end + + def unloaded? + calendar_data.nil? + end + + private + + attr_reader :attributes + + def inner_calendar + Icalendar::Calendar.parse(calendar_data).first + end + + def inner_todo + @inner_todo = inner_calendar.todos.first + end + end +end diff --git a/spec/acceptance/radicale_spec.rb b/spec/acceptance/radicale_spec.rb index 776a718..a1e638c 100644 --- a/spec/acceptance/radicale_spec.rb +++ b/spec/acceptance/radicale_spec.rb @@ -44,5 +44,6 @@ it_behaves_like "supporting event management" it_behaves_like "supporting event deletion with etags" + it_behaves_like "supporting todo management" end end diff --git a/spec/acceptance/shared.rb b/spec/acceptance/shared.rb index f105801..b529afd 100644 --- a/spec/acceptance/shared.rb +++ b/spec/acceptance/shared.rb @@ -181,3 +181,116 @@ expect(subject.events.delete(event_result.url)).to eq(true) end end + +RSpec.shared_examples "supporting todo management" do + it "supports events" do + expect(calendar.components).to include("VTODO") + end + + it "can create, find, update and delete todos" do + # Create an event + todo_result = subject.todos.create( + calendar.url, "calendav-todo-1.ics", ical_todo("Todo 1") + ) + expect(todo_result.url) + .to include(URI.decode_www_form_component(calendar.url)) + todo = subject.todos.find(todo_result.url) + + # Search for the todo + todos = subject.todos.list(calendar.url) + expect(todos.length).to eq(1) + expect(todos.first.summary).to eq("Todo 1") + expect(todos.first.url).to eq_encoded_url(todo_result.url) + + # Update the event + subject.events.update(todo_result.url, + update_todo_summary(todo, "Todo 2")) + + # Search again + todos = subject.todos.list(calendar.url) + expect(todos.length).to eq(1) + expect(todos.first.summary).to eq("Todo 2") + expect(todos.first.url).to eq_encoded_url(todo_result.url) + + # Create another event + another_result = subject.todos.create( + calendar.url, "calendav-todo-2.ics", ical_todo("Todo 3") + ) + + # Search for all events + todos = subject.todos.list(calendar.url) + expect(todos.length).to eq(2) + + # Delete the events + expect(subject.todos.delete(todo_result.url)).to eq(true) + expect(subject.todos.delete(another_result.url)).to eq(true) + end + + it "respects etag conditions with updates" do + todo_result = subject.todos.create( + calendar.url, "calendav-todo.ics", ical_todo("Todo 1") + ) + todo = subject.todos.find(todo_result.url) + + expect( + subject.todos.update( + todo_result.url, update_todo_summary(todo, "Todo 2"), etag: todo.etag + ) + ).not_to be_nil + + expect(subject.todos.find(todo_result.url).summary).to eq("Todo 2") + + # Wait for server to catch up + sleep 1 + + # Updating with the old etag should fail + expect( + subject.todos.update( + todo_result.url, update_todo_summary(todo, "Todo 1"), etag: todo.etag + ) + ).to be_nil + + expect(subject.todos.find(todo_result.url).summary).to eq("Todo 2") + + expect(subject.todos.delete(todo_result.url)).to eq(true) + end + + it "handles synchronisation requests" do + first_result = subject.todos.create( + calendar.url, "calendav-todo-1.ics", ical_todo("Todo 1") + ) + first = subject.todos.find(first_result.url) + token = subject.calendars.find(calendar.url, sync: true).sync_token + + todos = subject.todos.list(calendar.url) + expect(todos.length).to eq(1) + + second_result = subject.todos.create( + calendar.url, "calendav-event-2.ics", ical_todo("Todo 2") + ) + + subject.todos.update(first_result.url, update_todo_summary(first, "Todo 3")) + first = subject.todos.find(first_result.url) + + collection = subject.calendars.sync(calendar.url, token) + expect(collection.changes.collect(&:url)) + .to match_encoded_urls([first_result.url, second_result.url]) + + expect(collection.deletions).to be_empty + expect(collection.more?).to eq(false) + + subject.todos.update(first_result.url, update_todo_summary(first, "Todo 1")) + subject.todos.delete(second_result.url) + + collection = subject.calendars.sync(calendar.url, collection.sync_token) + urls = collection.changes.collect(&:url) + expect(urls.length).to eq(1) + expect(urls[0]).to eq_encoded_url(first_result.url) + + expect(collection.deletions.length).to eq(1) + expect(collection.deletions.first).to eq_encoded_url(second_result.url) + expect(collection.more?).to eq(false) + + subject.todos.delete(first_result.url) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index df956a2..7d7e1a0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -7,6 +7,7 @@ require_relative "support/encoded_matchers" require_relative "support/event_helpers" +require_relative "support/todo_helpers" require_relative "support/vcr" RSpec.configure do |config| diff --git a/spec/support/todo_helpers.rb b/spec/support/todo_helpers.rb new file mode 100644 index 0000000..131a711 --- /dev/null +++ b/spec/support/todo_helpers.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# require "active_support" +require "icalendar" +require "securerandom" + +module TodoHelpers + def ical_todo(summary) + ics = Icalendar::Calendar.new + + ics.todo do |todo| + todo.uid = SecureRandom.uuid + todo.dtstamp = Time.now.utc + todo.summary = summary + end + + ics.tap(&:publish).to_ical + end + + def update_todo_summary(todo, summary) + ics = Icalendar::Calendar.parse(todo.calendar_data).first + + ics.todos.first.summary = summary + + ics.to_ical + end +end + +RSpec.configure do |config| + config.include TodoHelpers +end