Skip to content

Commit

Permalink
Merge pull request #8 from jhuckabee/todo-support
Browse files Browse the repository at this point in the history
Add support for tasks
  • Loading branch information
pat authored Jul 22, 2023
2 parents 9a8376c + 2e8ad33 commit 37cecf7
Show file tree
Hide file tree
Showing 10 changed files with 371 additions and 0 deletions.
5 changes: 5 additions & 0 deletions lib/calendav/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
73 changes: 73 additions & 0 deletions lib/calendav/clients/todos_client.rb
Original file line number Diff line number Diff line change
@@ -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
37 changes: 37 additions & 0 deletions lib/calendav/parsers/todo_xml.rb
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions lib/calendav/requests/list_todos.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions lib/calendav/requests/make_calendar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def call
:"supported-calendar-component-set"
) do
xml["caldav"].comp name: "VEVENT"
xml["caldav"].comp name: "VTODO"
end
end
end
Expand Down
60 changes: 60 additions & 0 deletions lib/calendav/todo.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spec/acceptance/radicale_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
113 changes: 113 additions & 0 deletions spec/acceptance/shared.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
Loading

0 comments on commit 37cecf7

Please sign in to comment.