From 0cd9fd7fcd0cf70df2887eeb24b92bce0f276d02 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Thu, 24 Aug 2017 18:59:53 +0200 Subject: [PATCH] Fixed issue #1361 - Calendar: ICAL sources are not updated. Implemented issue #1362 - Calendar: ICAL ignores recurring appointments/events. --- Gemfile | 1 + Gemfile.lock | 5 ++ app/models/calendar.rb | 68 +++++++++++----- test/fixtures/calendar1.ics | 30 +++++++ test/fixtures/calendar2.ics | 51 ++++++++++++ test/fixtures/calendar3.ics | 111 ++++++++++++++++++++++++++ test/unit/calendar_test.rb | 154 +++++++++++++++++++++++++++++++++++- 7 files changed, 398 insertions(+), 22 deletions(-) create mode 100644 test/fixtures/calendar1.ics create mode 100644 test/fixtures/calendar2.ics create mode 100644 test/fixtures/calendar3.ics diff --git a/Gemfile b/Gemfile index 449f5ac68..4d43388a0 100644 --- a/Gemfile +++ b/Gemfile @@ -74,6 +74,7 @@ gem 'argon2' gem 'writeexcel' gem 'icalendar' +gem 'icalendar-recurrence' gem 'browser' # integrations diff --git a/Gemfile.lock b/Gemfile.lock index 19b5096f5..96e3ada43 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -185,6 +185,10 @@ GEM httpclient (2.8.3) i18n (0.8.6) icalendar (2.4.1) + icalendar-recurrence (1.1.2) + icalendar (~> 2.0) + ice_cube (~> 0.13) + ice_cube (0.16.2) inflection (1.0.0) json (1.8.6) jwt (1.5.6) @@ -481,6 +485,7 @@ DEPENDENCIES guard-symlink htmlentities icalendar + icalendar-recurrence json koala libv8 diff --git a/app/models/calendar.rb b/app/models/calendar.rb index 56fee6a93..1ac32dc09 100644 --- a/app/models/calendar.rb +++ b/app/models/calendar.rb @@ -150,14 +150,16 @@ returns return if !ical_url # only sync every 5 days - cache_key = "CalendarIcal::#{id}" - cache = Cache.get(cache_key) - return if !last_log && cache && cache[:ical_url] == ical_url + if id + cache_key = "CalendarIcal::#{id}" + cache = Cache.get(cache_key) + return if !last_log && cache && cache[:ical_url] == ical_url + end begin events = {} - if ical_url && !ical_url.empty? - events = Calendar.parse(ical_url) + if ical_url.present? + events = Calendar.fetch_parse(ical_url) end # sync with public_holidays @@ -189,11 +191,13 @@ returns } } self.last_log = nil - cache = Cache.write( - cache_key, - { public_holidays: public_holidays, ical_url: ical_url }, - { expires_in: 5.days }, - ) + if id + Cache.write( + cache_key, + { public_holidays: public_holidays, ical_url: ical_url }, + { expires_in: 1.day }, + ) + end rescue => e self.last_log = e.inspect end @@ -205,7 +209,7 @@ returns true end - def self.parse(location) + def self.fetch_parse(location) if location =~ /^http/i result = UserAgent.get(location) if !result.success? @@ -220,22 +224,43 @@ returns cal = cals.first events = {} cal.events.each { |event| + if event.rrule + + # loop till days + interval_frame_start = Date.parse("#{Time.zone.now - 1.year}-01-01") + interval_frame_end = Date.parse("#{Time.zone.now + 3.years}-12-31") + occurrences = event.occurrences_between(interval_frame_start, interval_frame_end) + if occurrences.present? + occurrences.each { |occurrence| + result = Calendar.day_and_comment_by_event(event, occurrence.start_time) + next if !result + events[result[0]] = result[1] + } + end + end next if event.dtstart < Time.zone.now - 1.year next if event.dtstart > Time.zone.now + 3.years - day = "#{event.dtstart.year}-#{format('%02d', event.dtstart.month)}-#{format('%02d', event.dtstart.day)}" - comment = event.summary || event.description - comment = Encode.conv( 'utf8', comment.to_s.force_encoding('utf-8') ) - if !comment.valid_encoding? - comment = comment.encode('utf-8', 'binary', invalid: :replace, undef: :replace, replace: '?') - end - - # ignore daylight saving time entries - next if comment =~ /(daylight saving|sommerzeit|summertime)/i - events[day] = comment + result = Calendar.day_and_comment_by_event(event, event.dtstart) + next if !result + events[result[0]] = result[1] } events.sort.to_h end + # get day and comment by event + def self.day_and_comment_by_event(event, start_time) + day = "#{start_time.year}-#{format('%02d', start_time.month)}-#{format('%02d', start_time.day)}" + comment = event.summary || event.description + comment = Encode.conv( 'utf8', comment.to_s.force_encoding('utf-8') ) + if !comment.valid_encoding? + comment = comment.encode('utf-8', 'binary', invalid: :replace, undef: :replace, replace: '?') + end + + # ignore daylight saving time entries + return if comment =~ /(daylight saving|sommerzeit|summertime)/i + [day, comment] + end + private # if changed calendar is default, set all others default to false @@ -254,6 +279,7 @@ returns def min_one_check if !Calendar.find_by(default: true) first = Calendar.order(:created_at, :id).limit(1).first + return true if !first first.default = true first.save end diff --git a/test/fixtures/calendar1.ics b/test/fixtures/calendar1.ics new file mode 100644 index 000000000..ad2c285ca --- /dev/null +++ b/test/fixtures/calendar1.ics @@ -0,0 +1,30 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +VERSION:2.0 +X-WR-CALNAME:test2 +PRODID:-//Apple Inc.//Mac OS X 10.12.6//EN +X-APPLE-CALENDAR-COLOR:#CC73E1 +X-WR-TIMEZONE:Europe/Berlin +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20170824T123259Z +UID:D49093CD-2D9D-4B79-9DDA-79E1528B010E +RRULE:FREQ=YEARLY;INTERVAL=1 +DTEND;VALUE=DATE:20121225 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:Christmas1 +DTSTART;VALUE=DATE:20121224 +DTSTAMP:20170824T123314Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +UID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/test/fixtures/calendar2.ics b/test/fixtures/calendar2.ics new file mode 100644 index 000000000..5bed19d71 --- /dev/null +++ b/test/fixtures/calendar2.ics @@ -0,0 +1,51 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +VERSION:2.0 +X-WR-CALNAME:test2 +PRODID:-//Apple Inc.//Mac OS X 10.12.6//EN +X-APPLE-CALENDAR-COLOR:#CC73E1 +X-WR-TIMEZONE:Europe/Berlin +CALSCALE:GREGORIAN +BEGIN:VEVENT +CREATED:20170824T123259Z +UID:D49093CD-2D9D-4B79-9DDA-79E1528B010E +RRULE:FREQ=YEARLY;INTERVAL=1 +DTEND;VALUE=DATE:20121225 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:Christmas1 +DTSTART;VALUE=DATE:20121224 +DTSTAMP:20170824T123314Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +UID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +BEGIN:VEVENT +CREATED:20170824T123259Z +UID:D49093CD-2D9D-4B79-9DDA-79E1528B010G +RRULE:FREQ=YEARLY;INTERVAL=1 +DTEND;VALUE=DATE:20121225 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:Christmas2 +DTSTART;VALUE=DATE:20121225 +DTSTAMP:20170824T123314Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +UID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/test/fixtures/calendar3.ics b/test/fixtures/calendar3.ics new file mode 100644 index 000000000..98649a964 --- /dev/null +++ b/test/fixtures/calendar3.ics @@ -0,0 +1,111 @@ +BEGIN:VCALENDAR +METHOD:PUBLISH +VERSION:2.0 +X-WR-CALNAME:test2 +PRODID:-//Apple Inc.//Mac OS X 10.12.6//EN +X-APPLE-CALENDAR-COLOR:#CC73E1 +X-WR-TIMEZONE:Europe/Berlin +CALSCALE:GREGORIAN +BEGIN:VTIMEZONE +TZID:Europe/Berlin +BEGIN:DAYLIGHT +TZOFFSETFROM:+0100 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +DTSTART:19810329T020000 +TZNAME:GMT+2 +TZOFFSETTO:+0200 +END:DAYLIGHT +BEGIN:STANDARD +TZOFFSETFROM:+0200 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +DTSTART:19961027T030000 +TZNAME:GMT+1 +TZOFFSETTO:+0100 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20170824T123259Z +UID:D49093CD-2D9D-4B79-9DDA-79E1528B010E +RRULE:FREQ=YEARLY;INTERVAL=1 +DTEND;VALUE=DATE:20121225 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:Christmas1 +DTSTART;VALUE=DATE:20121224 +DTSTAMP:20170824T123314Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +UID:D8B0F2C4-B45E-4D1B-9DC5-80DCDF5F06C1 +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +BEGIN:VEVENT +CREATED:20170824T123409Z +UID:B461611E-DF33-42BA-B40E-DF32FAD3BA92 +RRULE:FREQ=MONTHLY;INTERVAL=1;COUNT=10 +DTEND;TZID=Europe/Berlin:20121228T100000 +TRANSP:OPAQUE +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:day4 +DTSTART;TZID=Europe/Berlin:20121228T090000 +DTSTAMP:20170824T134117Z +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +CREATED:20170824T123419Z +UID:4F1F0444-9F71-4FDD-9D4A-EBA6BF1EF72E +RRULE:FREQ=MONTHLY;INTERVAL=1;COUNT=5 +DTEND;VALUE=DATE:20121227 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:day3 +DTSTART;VALUE=DATE:20161226 +DTSTAMP:20170824T134106Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:118EB330-738F-4956-9FAD-D548462CC26A +UID:118EB330-738F-4956-9FAD-D548462CC26A +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +BEGIN:VEVENT +CREATED:20170824T134043Z +UID:3606DA64-D734-434A-A430-E2AC7ADECACF +DTEND;TZID=Europe/Berlin:20121226T100000 +TRANSP:OPAQUE +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:day2 +DTSTART;TZID=Europe/Berlin:20121226T090000 +DTSTAMP:20170824T134043Z +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +CREATED:20170824T134127Z +UID:7B5AB905-39EA-42B6-AD31-1E63D4C5C2C7 +DTEND;VALUE=DATE:20121229 +TRANSP:TRANSPARENT +X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC +SUMMARY:day5 +DTSTART;VALUE=DATE:20161228 +DTSTAMP:20170824T134134Z +SEQUENCE:0 +BEGIN:VALARM +X-WR-ALARMUID:45BF1D7B-3D2C-48DC-AB5C-BB391AFA7837 +UID:45BF1D7B-3D2C-48DC-AB5C-BB391AFA7837 +TRIGGER:-PT15H +ATTACH;VALUE=URI:Basso +X-APPLE-LOCAL-DEFAULT-ALARM:TRUE +ACTION:AUDIO +X-APPLE-DEFAULT-ALARM:TRUE +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/test/unit/calendar_test.rb b/test/unit/calendar_test.rb index 197d5e1f3..6bc5ef26e 100644 --- a/test/unit/calendar_test.rb +++ b/test/unit/calendar_test.rb @@ -3,7 +3,7 @@ require 'test_helper' class CalendarTest < ActiveSupport::TestCase test 'default test' do - Calendar.delete_all + Calendar.destroy_all calendar1 = Calendar.create_or_update( name: 'US 1', timezone: 'America/Los_Angeles', @@ -91,4 +91,156 @@ class CalendarTest < ActiveSupport::TestCase travel_back end + test 'sync test' do + Calendar.destroy_all + + travel_to Time.zone.parse('2017-08-24T01:04:44Z0') + + calendar1 = Calendar.create_or_update( + name: 'Sync 1', + timezone: 'America/Los_Angeles', + business_hours: { + mon: { '09:00' => '17:00' }, + tue: { '09:00' => '17:00' }, + wed: { '09:00' => '17:00' }, + thu: { '09:00' => '17:00' }, + fri: { '09:00' => '17:00' } + }, + default: true, + ical_url: 'test/fixtures/calendar1.ics', + updated_by_id: 1, + created_by_id: 1, + ) + + assert_equal(true, calendar1.public_holidays['2016-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2016-12-24']['summary']) + assert_nil(calendar1.public_holidays['2016-12-25']) + assert_equal(true, calendar1.public_holidays['2017-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2017-12-24']['summary']) + assert_nil(calendar1.public_holidays['2017-12-25']) + assert_equal(true, calendar1.public_holidays['2018-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2018-12-24']['summary']) + assert_nil(calendar1.public_holidays['2018-12-25']) + assert_equal(true, calendar1.public_holidays['2019-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2019-12-24']['summary']) + assert_nil(calendar1.public_holidays['2019-12-25']) + assert_nil(calendar1.public_holidays['2020-12-24']) + + Calendar.sync + + assert_equal(true, calendar1.public_holidays['2016-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2016-12-24']['summary']) + assert_nil(calendar1.public_holidays['2016-12-25']) + assert_equal(true, calendar1.public_holidays['2017-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2017-12-24']['summary']) + assert_nil(calendar1.public_holidays['2017-12-25']) + assert_equal(true, calendar1.public_holidays['2018-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2018-12-24']['summary']) + assert_nil(calendar1.public_holidays['2018-12-25']) + assert_equal(true, calendar1.public_holidays['2019-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2019-12-24']['summary']) + assert_nil(calendar1.public_holidays['2019-12-25']) + assert_nil(calendar1.public_holidays['2020-12-24']) + + cache_key = "CalendarIcal::#{calendar1.id}" + cache = Cache.get(cache_key) + + calendar1.update_columns(ical_url: 'test/fixtures/calendar2.ics') + cache_key = "CalendarIcal::#{calendar1.id}" + cache = Cache.get(cache_key) + cache[:ical_url] = 'test/fixtures/calendar2.ics' + Cache.write( + cache_key, + cache, + { expires_in: 1.day }, + ) + + Calendar.sync + + calendar1.reload + assert_equal(true, calendar1.public_holidays['2016-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2016-12-24']['summary']) + assert_nil(calendar1.public_holidays['2016-12-25']) + assert_equal(true, calendar1.public_holidays['2017-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2017-12-24']['summary']) + assert_nil(calendar1.public_holidays['2017-12-25']) + assert_equal(true, calendar1.public_holidays['2018-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2018-12-24']['summary']) + assert_nil(calendar1.public_holidays['2018-12-25']) + assert_equal(true, calendar1.public_holidays['2019-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2019-12-24']['summary']) + assert_nil(calendar1.public_holidays['2019-12-25']) + assert_nil(calendar1.public_holidays['2020-12-24']) + + travel 2.days + + Calendar.sync + + calendar1.reload + assert_equal(true, calendar1.public_holidays['2016-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2016-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2016-12-25']['active']) + assert_equal('Christmas2', calendar1.public_holidays['2016-12-25']['summary']) + assert_equal(true, calendar1.public_holidays['2017-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2017-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2017-12-25']['active']) + assert_equal('Christmas2', calendar1.public_holidays['2017-12-25']['summary']) + assert_equal(true, calendar1.public_holidays['2018-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2018-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2018-12-25']['active']) + assert_equal('Christmas2', calendar1.public_holidays['2018-12-25']['summary']) + assert_equal(true, calendar1.public_holidays['2019-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2019-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2019-12-25']['active']) + assert_equal('Christmas2', calendar1.public_holidays['2019-12-25']['summary']) + assert_nil(calendar1.public_holidays['2020-12-24']) + assert_nil(calendar1.public_holidays['2020-12-25']) + + Calendar.destroy_all + + calendar1 = Calendar.create_or_update( + name: 'Sync 2', + timezone: 'America/Los_Angeles', + business_hours: { + mon: { '09:00' => '17:00' }, + tue: { '09:00' => '17:00' }, + wed: { '09:00' => '17:00' }, + thu: { '09:00' => '17:00' }, + fri: { '09:00' => '17:00' } + }, + default: true, + ical_url: 'test/fixtures/calendar3.ics', + updated_by_id: 1, + created_by_id: 1, + ) + + assert_equal(true, calendar1.public_holidays['2016-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2016-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2016-12-26']['active']) + assert_equal('day3', calendar1.public_holidays['2016-12-26']['summary']) + assert_equal(true, calendar1.public_holidays['2016-12-28']['active']) + assert_equal('day5', calendar1.public_holidays['2016-12-28']['summary']) + assert_equal(true, calendar1.public_holidays['2017-01-26']['active']) + assert_equal('day3', calendar1.public_holidays['2017-01-26']['summary']) + assert_equal(true, calendar1.public_holidays['2017-02-26']['active']) + assert_equal('day3', calendar1.public_holidays['2017-02-26']['summary']) + assert_equal(true, calendar1.public_holidays['2017-03-26']['active']) + assert_equal('day3', calendar1.public_holidays['2017-03-26']['summary']) + assert_equal(true, calendar1.public_holidays['2017-04-26']['active']) + assert_equal('day3', calendar1.public_holidays['2017-04-26']['summary']) + assert_nil(calendar1.public_holidays['2017-05-26']) + assert_equal(true, calendar1.public_holidays['2017-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2017-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2018-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2018-12-24']['summary']) + assert_equal(true, calendar1.public_holidays['2019-12-24']['active']) + assert_equal('Christmas1', calendar1.public_holidays['2019-12-24']['summary']) + assert_nil(calendar1.public_holidays['2020-12-24']) + + Calendar.destroy_all + + travel_back + + end + end