diff --git a/.github/workflows/main.yml b/.github/workflows/rspec.yml similarity index 75% rename from .github/workflows/main.yml rename to .github/workflows/rspec.yml index cca9825..73b2539 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/rspec.yml @@ -1,4 +1,4 @@ -name: Ruby +name: RSpec on: [push, pull_request] @@ -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,18 +26,12 @@ 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: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - name: Rubocop - run: bundle exec rubocop - - name: RSpec env: APPLE_USERNAME: ${{ secrets.APPLE_USERNAME }} @@ -41,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/.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 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..6eba64c 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 @@ -59,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) @@ -91,6 +95,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/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/components/freebusy.rb b/lib/calendav/components/freebusy.rb new file mode 100644 index 0000000..eb5317f --- /dev/null +++ b/lib/calendav/components/freebusy.rb @@ -0,0 +1,60 @@ +# 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 + + 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 +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..b64fd80 --- /dev/null +++ b/lib/calendav/properties/freebusy.rb @@ -0,0 +1,49 @@ +# 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 = 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 + { + 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.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 or respond to to_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..92088fa --- /dev/null +++ b/lib/calendav/requests/free_busy_query.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "nokogiri" + +module Calendav + module Requests + class FreeBusyQuery + def self.call(...) + new(...).call + end + + # @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 = time_to_formatted_string(from) + @to = time_to_formatted_string(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 + + 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 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 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