From b330e468041e363174c1065e3d6510b8c8ac053b Mon Sep 17 00:00:00 2001 From: Sam Schams Date: Wed, 5 Mar 2025 17:12:30 +0100 Subject: [PATCH 1/6] Add freebusy support --- calendav.gemspec | 7 +-- lib/calendav/clients/calendars_client.rb | 9 ++++ lib/calendav/components/freebusy.rb | 58 ++++++++++++++++++++++++ lib/calendav/errors.rb | 4 +- lib/calendav/properties/freebusy.rb | 43 ++++++++++++++++++ lib/calendav/requests/free_busy_query.rb | 29 ++++++++++++ 6 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 lib/calendav/components/freebusy.rb create mode 100644 lib/calendav/properties/freebusy.rb create mode 100644 lib/calendav/requests/free_busy_query.rb diff --git a/calendav.gemspec b/calendav.gemspec index 633af0a..b80d53f 100644 --- a/calendav.gemspec +++ b/calendav.gemspec @@ -24,10 +24,11 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_runtime_dependency "http" - spec.add_runtime_dependency "icalendar" - spec.add_runtime_dependency "nokogiri" + spec.add_dependency "http" + spec.add_dependency "icalendar" + spec.add_dependency "nokogiri" + spec.add_development_dependency "debug" spec.add_development_dependency "dotenv" spec.add_development_dependency "google-api-client" spec.add_development_dependency "googleauth" diff --git a/lib/calendav/clients/calendars_client.rb b/lib/calendav/clients/calendars_client.rb index 33d7a18..e422ca3 100644 --- a/lib/calendav/clients/calendars_client.rb +++ b/lib/calendav/clients/calendars_client.rb @@ -1,14 +1,17 @@ # frozen_string_literal: true require "securerandom" +require "icalendar" require_relative "../calendar" +require_relative "../components/freebusy" require_relative "../parsers/sync_xml" require_relative "../requests/calendar_home_set" require_relative "../requests/list_calendars" require_relative "../requests/make_calendar" require_relative "../requests/sync_collection" require_relative "../requests/update_calendar" +require_relative "../requests/free_busy_query" module Calendav module Clients @@ -91,6 +94,12 @@ def update(url, attributes) .text["200 OK"] == "200 OK" end + def freebusy(url, from:, to:) + request = Requests::FreeBusyQuery.call(from: from, to: to) + response = endpoint.report(request.to_xml, url: url, depth: 1) + Components::Freebusy.from_http_response(response) + end + private attr_reader :client, :endpoint, :credentials diff --git a/lib/calendav/components/freebusy.rb b/lib/calendav/components/freebusy.rb new file mode 100644 index 0000000..0c44dd7 --- /dev/null +++ b/lib/calendav/components/freebusy.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative "../properties/freebusy" + +module Calendav + module Components + class Freebusy + attr_reader :dtend, :dtstart, :dtstamp, :freebusy, :ical_name, :name, + :parent, :uid + + def self.from_http_response(response) + ical_freebusy = Icalendar::Freebusy.parse(response).first + + attributes = attributes_from_ical_freebusy(ical_freebusy) + new(attributes) + end + + def initialize(attributes) + @dtend = attributes[:dtend].to_time + @dtstart = attributes[:dtstart].to_time + @dtstamp = attributes[:dtstamp].to_time + @freebusy = attributes[:freebusy].map do |fb| + Properties::Freebusy.from_ical_period(fb) + end + @ical_name = attributes[:ical_name] + @name = attributes[:name] + @uid = attributes[:uid] + end + + def to_h + { + dtend: dtend, + dtstart: dtstart, + dtstamp: dtstamp, + freebusy: freebusy, + ical_name: ical_name, + name: name, + parent: parent, + uid: uid + } + end + + private + + def attributes_from_ical_freebusy(ical_freebusy) + { + dtend: ical_freebusy.dtend, + dtstamp: ical_freebusy.dtstamp, + dtstart: ical_freebusy.dtstart, + freebusy: ical_freebusy.freebusy, + ical_name: ical_freebusy.ical_name, + name: ical_freebusy.name, + uid: ical_freebusy.uid + } + end + end + end +end diff --git a/lib/calendav/errors.rb b/lib/calendav/errors.rb index 5dd1c67..379e31c 100644 --- a/lib/calendav/errors.rb +++ b/lib/calendav/errors.rb @@ -7,7 +7,7 @@ class ParsingXMLError < Error attr_reader :xml, :original def initialize(xml, original) - super original.message + super(original.message) @xml = xml @original = original @@ -18,7 +18,7 @@ class RequestError < Error attr_reader :response def initialize(response) - super response.status.to_s + super(response.status.to_s) @response = response end diff --git a/lib/calendav/properties/freebusy.rb b/lib/calendav/properties/freebusy.rb new file mode 100644 index 0000000..d6a0121 --- /dev/null +++ b/lib/calendav/properties/freebusy.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Calendav + module Properties + class Freebusy + attr_reader :start_time, :end_time + + def self.from_ical_period(period) + new(start_time: period.first.to_time, end_time: period.last.to_time) + end + + def initialize(start_time:, end_time:) + validate_arguments(start_time, end_time) + + @start_time = start_time + @end_time = end_time + end + + def to_h + { + start_time: start_time, + end_time: end_time + } + end + + def to_range + Range.new(start_time, end_time) + end + + def duration + end_time - start_time + end + + private + + def validate_arguments(start_time, end_time) + return if start_time.instance_of?(Time) && end_time.instance_of?(Time) + + raise ArgumentError, "arguments must be instances of Time" + end + end + end +end diff --git a/lib/calendav/requests/free_busy_query.rb b/lib/calendav/requests/free_busy_query.rb new file mode 100644 index 0000000..2ed621a --- /dev/null +++ b/lib/calendav/requests/free_busy_query.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "nokogiri" + +module Calendav + module Requests + class FreeBusyQuery + def self.call(...) + new(...).call + end + + # @param from [Time, String YYYYMMDDTHHMMSSZ (ISO8601 w/o dashes/colons)] + # @param to [Time, String YYYYMMDDTHHMMSSZ (ISO8601 w/o dashes/colons)] + def initialize(from:, to:) + @from = from.instance_of?(Time) ? from.utc.iso8601.delete(":-") : from + @to = to.instance_of?(Time) ? to.utc.iso8601.delete(":-") : to + end + + def call + Nokogiri::XML::Builder.new do |xml| + xml["c"].public_send(:"free-busy-query", + "xmlns:c" => "urn:ietf:params:xml:ns:caldav") do + xml["c"].public_send(:"time-range", start: @from, end: @to) + end + end + end + end + end +end From 9b1157cfba6180bbb629295a06cd2f44f5f5b29c Mon Sep 17 00:00:00 2001 From: Julian Kornberger Date: Wed, 5 Mar 2025 17:16:37 +0100 Subject: [PATCH 2/6] Splits workflows --- .github/workflows/{main.yml => rspec.yml} | 5 +---- .github/workflows/rubocop.yml | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) rename .github/workflows/{main.yml => rspec.yml} (94%) create mode 100644 .github/workflows/rubocop.yml diff --git a/.github/workflows/main.yml b/.github/workflows/rspec.yml similarity index 94% rename from .github/workflows/main.yml rename to .github/workflows/rspec.yml index cca9825..84ceb8f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/rspec.yml @@ -1,4 +1,4 @@ -name: Ruby +name: RSpec on: [push, pull_request] @@ -26,9 +26,6 @@ jobs: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - name: Rubocop - run: bundle exec rubocop - - name: RSpec env: APPLE_USERNAME: ${{ secrets.APPLE_USERNAME }} diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml new file mode 100644 index 0000000..ebfd91f --- /dev/null +++ b/.github/workflows/rubocop.yml @@ -0,0 +1,19 @@ +name: Rubocop + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.1 + bundler-cache: true + + - name: Rubocop + run: bundle exec rubocop From 4d9cdf2f34329650227b9dc8010be0f1c981e4b3 Mon Sep 17 00:00:00 2001 From: Julian Kornberger Date: Wed, 5 Mar 2025 18:05:06 +0100 Subject: [PATCH 3/6] Improve workflows --- .github/workflows/rspec.yml | 15 +++++++++------ spec/data/radicale/config.ini | 10 ---------- spec/data/radicale/users | 1 - 3 files changed, 9 insertions(+), 17 deletions(-) delete mode 100644 spec/data/radicale/config.ini delete mode 100644 spec/data/radicale/users diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml index 84ceb8f..73b2539 100644 --- a/.github/workflows/rspec.yml +++ b/.github/workflows/rspec.yml @@ -6,6 +6,15 @@ jobs: build: runs-on: ubuntu-latest + services: + radicale: + image: xlrl/radicale + ports: + - 8000:8000 + env: + RADICALE_USER: test + RADICALE_PASS: test + strategy: fail-fast: false matrix: @@ -17,9 +26,6 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Run Radicale - run: docker run -v ${{ github.workspace }}/spec/data/radicale:/var/radicale --name radicale --publish 8000:8000 --detach xlrl/radicale - - name: Set up Ruby uses: ruby/setup-ruby@v1 with: @@ -38,6 +44,3 @@ jobs: RADICALE_PASSWORD: test RADICALE_HOST: http://127.0.0.1:8000 run: bundle exec rspec - - - name: Stop Radicale - run: docker stop radicale diff --git a/spec/data/radicale/config.ini b/spec/data/radicale/config.ini deleted file mode 100644 index 7eb564d..0000000 --- a/spec/data/radicale/config.ini +++ /dev/null @@ -1,10 +0,0 @@ -[auth] -type = htpasswd -htpasswd_filename = /var/radicale/users -htpasswd_encryption = bcrypt - -[server] -hosts = 0.0.0.0:8000 - -[storage] -filesystem_folder = /var/radicale/collections diff --git a/spec/data/radicale/users b/spec/data/radicale/users deleted file mode 100644 index 69b9764..0000000 --- a/spec/data/radicale/users +++ /dev/null @@ -1 +0,0 @@ -test:$2y$05$O.Z0365vM0GOv.YN1WlIEOnd0gaGEB/SmoRiSjIHnstXIm6ZURl/y From 409ddd1eccc5ff9782569ac482d3ddcb06e15cc3 Mon Sep 17 00:00:00 2001 From: Sam Schams <> Date: Thu, 6 Mar 2025 11:27:38 +0100 Subject: [PATCH 4/6] DateTime support --- lib/calendav/components/freebusy.rb | 26 +++++++++++++----------- lib/calendav/properties/freebusy.rb | 14 +++++++++---- lib/calendav/requests/free_busy_query.rb | 16 +++++++++++---- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/lib/calendav/components/freebusy.rb b/lib/calendav/components/freebusy.rb index 0c44dd7..eb5317f 100644 --- a/lib/calendav/components/freebusy.rb +++ b/lib/calendav/components/freebusy.rb @@ -40,18 +40,20 @@ def to_h } end - private - - def attributes_from_ical_freebusy(ical_freebusy) - { - dtend: ical_freebusy.dtend, - dtstamp: ical_freebusy.dtstamp, - dtstart: ical_freebusy.dtstart, - freebusy: ical_freebusy.freebusy, - ical_name: ical_freebusy.ical_name, - name: ical_freebusy.name, - uid: ical_freebusy.uid - } + class << self + private + + def attributes_from_ical_freebusy(ical_freebusy) + { + dtend: ical_freebusy.dtend, + dtstamp: ical_freebusy.dtstamp, + dtstart: ical_freebusy.dtstart, + freebusy: ical_freebusy.freebusy, + ical_name: ical_freebusy.ical_name, + name: ical_freebusy.name, + uid: ical_freebusy.uid + } + end end end end diff --git a/lib/calendav/properties/freebusy.rb b/lib/calendav/properties/freebusy.rb index d6a0121..b64fd80 100644 --- a/lib/calendav/properties/freebusy.rb +++ b/lib/calendav/properties/freebusy.rb @@ -12,8 +12,12 @@ def self.from_ical_period(period) def initialize(start_time:, end_time:) validate_arguments(start_time, end_time) - @start_time = start_time - @end_time = end_time + @start_time = if start_time.respond_to?(:to_time) + start_time.to_time + else + start_time + end + @end_time = end_time.respond_to?(:to_time) ? end_time.to_time : end_time end def to_h @@ -34,9 +38,11 @@ def duration private def validate_arguments(start_time, end_time) - return if start_time.instance_of?(Time) && end_time.instance_of?(Time) + return if (start_time.is_a?(Time) || start_time.respond_to?(:to_time)) && + (end_time.is_a?(Time) || end_time.respond_to?(:to_time)) - raise ArgumentError, "arguments must be instances of Time" + raise ArgumentError, + "arguments must be instances of Time or respond to to_time" end end end diff --git a/lib/calendav/requests/free_busy_query.rb b/lib/calendav/requests/free_busy_query.rb index 2ed621a..92088fa 100644 --- a/lib/calendav/requests/free_busy_query.rb +++ b/lib/calendav/requests/free_busy_query.rb @@ -9,11 +9,11 @@ def self.call(...) new(...).call end - # @param from [Time, String YYYYMMDDTHHMMSSZ (ISO8601 w/o dashes/colons)] - # @param to [Time, String YYYYMMDDTHHMMSSZ (ISO8601 w/o dashes/colons)] + # @param from [Time, DateTime, String YYYYMMDDTHHMMSSZ (ISO8601 w/o dashes/colons)] + # @param to [Time, DateTime, String YYYYMMDDTHHMMSSZ (ISO8601 w/o dashes/colons)] def initialize(from:, to:) - @from = from.instance_of?(Time) ? from.utc.iso8601.delete(":-") : from - @to = to.instance_of?(Time) ? to.utc.iso8601.delete(":-") : to + @from = time_to_formatted_string(from) + @to = time_to_formatted_string(to) end def call @@ -24,6 +24,14 @@ def call end end end + + private + + def time_to_formatted_string(time_obj) + return time_obj unless time_obj.respond_to?(:utc) + + time_obj.utc.iso8601.delete(":-") + end end end end From a16450e20049f5d82525840d256c741d51c72a8e Mon Sep 17 00:00:00 2001 From: Sam Schams <> Date: Tue, 11 Mar 2025 11:44:12 +0100 Subject: [PATCH 5/6] add "expand" functionality to list_events --- lib/calendav/clients/events_client.rb | 4 ++-- lib/calendav/requests/list_events.rb | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/calendav/clients/events_client.rb b/lib/calendav/clients/events_client.rb index 8467ff0..d573faf 100644 --- a/lib/calendav/clients/events_client.rb +++ b/lib/calendav/clients/events_client.rb @@ -39,8 +39,8 @@ def find(event_url) ) end - def list(calendar_url, from: nil, to: nil) - request = Requests::ListEvents.call(from: from, to: to) + def list(calendar_url, from: nil, to: nil, expand: false) + request = Requests::ListEvents.call(from: from, to: to, expand: expand) endpoint .report(request.to_xml, url: calendar_url, depth: 1) diff --git a/lib/calendav/requests/list_events.rb b/lib/calendav/requests/list_events.rb index 4946abc..e0c5b15 100644 --- a/lib/calendav/requests/list_events.rb +++ b/lib/calendav/requests/list_events.rb @@ -11,9 +11,10 @@ def self.call(...) new(...).call end - def initialize(from:, to:) + def initialize(from:, to:, expand: false) @from = from @to = to + @expand = expand end def call @@ -21,7 +22,9 @@ def call xml["caldav"].public_send("calendar-query", NAMESPACES) do xml["dav"].prop do xml["dav"].getetag - xml["caldav"].public_send(:"calendar-data") + xml["caldav"].public_send(:"calendar-data") do + xml["caldav"].expand(start: from, end: to) if expand? + end end xml["caldav"].filter do xml["caldav"].public_send(:"comp-filter", name: "VCALENDAR") do @@ -55,6 +58,10 @@ def to def range? to || from end + + def expand? + @expand + end end end end From 690fab857ca256e1542406aae776f6491a7518c4 Mon Sep 17 00:00:00 2001 From: Sam Schams <> Date: Wed, 19 Mar 2025 15:39:57 +0100 Subject: [PATCH 6/6] change calendars.list xpath --- lib/calendav/clients/calendars_client.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/calendav/clients/calendars_client.rb b/lib/calendav/clients/calendars_client.rb index e422ca3..6eba64c 100644 --- a/lib/calendav/clients/calendars_client.rb +++ b/lib/calendav/clients/calendars_client.rb @@ -62,7 +62,8 @@ def find(url, attributes: DEFAULT_ATTRIBUTES, sync: false) def list(url = home_url, depth: 1, attributes: DEFAULT_ATTRIBUTES) request = Requests::ListCalendars.call(attributes) - calendar_xpath = ".//dav:resourcetype/caldav:calendar" + calendar_xpath = + "//d:response[d:propstat/d:prop/cal:supported-calendar-component-set]" endpoint .propfind(request.to_xml, url: url, depth: depth)