From ae072352594e097489bd4eebfc814886ae6b84fd Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 30 Oct 2014 17:48:30 +0000 Subject: [PATCH 01/55] Refactoring towards CPAN module. Moving most of the code into App::ICalToGCal (maybe want a better name?), so that the ical-to-gcal script is just a thin wrapper that calls that code. This means I can distribute this via CPAN, and also that I can write some proper tests for it. --- ical-to-gcal | 163 ++----------------------- lib/App/ICalToGCal.pm | 269 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 155 deletions(-) create mode 100644 lib/App/ICalToGCal.pm diff --git a/ical-to-gcal b/ical-to-gcal index d2fdb47..757012d 100644 --- a/ical-to-gcal +++ b/ical-to-gcal @@ -10,12 +10,7 @@ # David Precious use strict; -use Net::Google::Calendar; -use Net::Netrc; -use iCal::Parser; -use LWP::Simple; -use Getopt::Long; -use Digest::MD5; +use App::ICalToGCal; my ($calendar, $ical_url); Getopt::Long::GetOptions( @@ -23,6 +18,7 @@ Getopt::Long::GetOptions( "ical_url|ical|i=s" => \$ical_url, ) or die "Failed to parse options"; + if (!$ical_url) { die "An iCal URL must be provided (--ical_url=....)"; } @@ -30,158 +26,15 @@ if (!$calendar) { die "You must specify the calendar name (--calendar=...)"; } -my $ical_data = LWP::Simple::get($ical_url) - or die "Failed to fetch $ical_url"; - -my $ic = iCal::Parser->new; - -my $ical = $ic->parse_strings($ical_data) - or die "Failed to parse iCal data"; - -# Hash the feed URL, so we can use it along with the event ID to uniquely -# identify events - and when removing events that aren't in the feed any more, -# remove only those which came from this feed, if the user is using multiple -# feeds. (10 characters of the hash should be enough to be reliable enough.) -my $feed_url_hash = substr(Digest::MD5->md5_hex($ical_url), 0, 10); - -# We get events keyed by year, month, day - we just want a flat list of events -# to walk through. Do this keyed by the event ID, so that multiple-day events -# are handled appropriately. We'll want this hash anyway to do a pass through -# all events on the Google Calendar, removing any that are no longer in the -# iCal feed. - -my %ical_events; -for my $year (keys %{ $ical->{events} }) { - for my $month (keys %{ $ical->{events}{$year} }) { - for my $day (keys %{ $ical->{events}{$year}{$month} }) { - for my $event_uid (keys %{ $ical->{events}{$year}{$month}{$day} }) { - $ical_events{ $event_uid } - = $ical->{events}{$year}{$month}{$day}{$event_uid}; - } - } - } -} - -# Right, we can now walk through each event in $events -for my $event_uid (keys %ical_events) { - my $event = $ical_events{$event_uid}; - printf "$event_uid (%s at %s)\n", - @$event{ qw (SUMMARY LOCATION) }; -} - - -# Get our login details, and find the Google calendar in question: -my $mach = Net::Netrc->lookup('calendar.google.com') - or die "No login details for calendar.google.com in ~/.netrc"; -my ($user, $pass) = $mach->lpa; - - -my $gcal = Net::Google::Calendar->new; -$gcal->login($user, $pass) - or die "Google Calendar login failed"; - -my ($desired_calendar) = grep { $_->title eq $calendar } $gcal->get_calendars; - -if (!$desired_calendar) { - die "No calendar named $calendar found!"; -} -$gcal->set_calendar($desired_calendar); -# Fetch all events from this calendar, parse out the ical feed's UID and whack -# them in a hash keyed by the UID; if that UID no longer appears in the ical -# feed, it's one to delete. +my $gcal = App::ICalToGCal->select_google_calendar($calendar); -my %gcal_events; +my $ical = App::ICalToGCal->fetch_ical($ical_url); -gcal_event: -for my $event ($gcal->get_events) { - my ($ical_feed_hash, $ical_uid) - = $event->content->body =~ m{\[ical_imported_uid:(.+)/(.+)\]}; - - # If there's no ical uid, we presumably didn't create this, so leave it - # alone - if (!$ical_uid) { - # Special-case, though: previous versions of this script didn't store - # the feed hash, so if we have only the event UID, assume it was this - # feed so the script continues working - if ($ical_uid - = $event->content->body =~ m{\[ical_imported_uid:(.+)\]} - ) { - $ical_feed_hash = $feed_url_hash; - } else { - warn sprintf "Event %s (%s) ignored as it has no " - . "ical_imported_uid property", - $event->id, - $event->title; - next gcal_event; - } - } - - # OK, if the event isn't for this feed, let it be: - if ($ical_feed_hash ne $feed_url_hash) { - next gcal_event; - } - - # OK, if this event didn't appear in the iCal feed, it has been deleted at - # the other end, so we should delete it from our Google calendar: - if (!$ical_events{$ical_uid}) { - printf "Deleting event %s (%s) (no longer found in iCal feed)\n", - $event->id, $event->title; - $gcal->delete_entry($event) - or warn "Failed to delete an event from Google Calendar"; - } - - # Now check for any differences, and update if required - - # Remember that we found this event, so we can refer to it when looking for - # events we need to create - $gcal_events{$ical_uid} = $event; -} - - -# Now, walk through the ical events we found, and create/update Google Calendar -# events -for my $ical_uid (keys %ical_events) { - - my ($method, $gcal_event); - - my $ical_event = $ical_events{$ical_uid}; - my $gcal_event = ical_event_to_gcal_event( - $ical_event, $gcal_events{$ical_uid} +App::ICalToGCal->update_google_calendar( + $gcal, + $ical, + App::ICalToGCal->hash_ical_url($ical_url), ); - my $method = exists $gcal_events{$ical_uid} - ? 'update_entry' : 'add_entry'; - - $gcal->$method($gcal_event) - or warn "Failed to $method for $ical_uid"; -} - -# Given an iCal event hash (from iCal::Parser) (and possibly a -# Net::Google::Calender event object to update), return an appropriate Google -# Calendar event object (either the one given, after updating it, or a newly -# populated one) -# Note: does not actually add/update the event on the calendar, just returns the -# event object. -sub ical_event_to_gcal_event { - my ($ical_event, $gcal_event) = @_; - - if (ref $ical_event ne 'HASH') { - die "Given invalid iCal event"; - } - if (defined $gcal_event && (!blessed($gcal_event) || - !$gcal_event->isa('Net::Google::Calendar::Event'))) - { - die "Given invalid Google Calendar event - what is it?"; - } - $gcal_event ||= Net::Google::Calendar::Event->new; - - my $ical_uid = $ical_event->{UID}; - $gcal_event->title( $ical_event->{SUMMARY} ); - $gcal_event->location( $ical_event->{LOCATION} ); - $gcal_event->when( $ical_event->{DTSTART}, $ical_event->{DTEND} ); - $gcal_event->content("[ical_imported_uid:$feed_url_hash/$ical_uid]"); - - return $gcal_event; -} diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm new file mode 100644 index 0000000..6c36bd8 --- /dev/null +++ b/lib/App/ICalToGCal.pm @@ -0,0 +1,269 @@ +package App::ICalToGCal; + +our $VERSION = 0.01; + +use strict; +use Net::Google::Calendar; +use Net::Netrc; +use iCal::Parser; +use LWP::Simple; +use Getopt::Long; +use Digest::MD5; + +=head1 NAME + +App::ICalToGCal - import iCal feeds into a Google Calendar + +=head1 DESCRIPTION + +A command line script to fetch an iCal calendar feed, and create corresponding +events in a Google Calendar. + +=head1 WHY? + +Why not just add the iCal feed URL to your Google Calendar directly and let +Google deal with it, I hear you ask? + +That would, at first glance, seem the best way - but they tend to update it +horribly slowly (in my experience, about expect about once every 24 hours at +best). + +Calendaring is a time-sensitive thing; I don't want to wait a full day for +updates to take effect (by the time Google re-fetch the feed and update your +calendar, it could be too late!). + +=head1 SYNOPSIS + +Use the included ical-to-gcal script to fetch and parse an iCal feed and +add/update events in your Google Calendar to match it: + + ical-to-gcal --calendar="Google Calendar Name" --ical-url=.... + + +=head1 CLASS METHODS + + +=head2 select_google_calendar + +Given a calendar name, reads our Google account details from C<~/.netrc>, logs +in to Google, selects the calendar with the name given using C, +and returns the L object. + +=cut + +sub select_google_calendar { + my ($class, $calendar_name) = @_; + # Get our login details, and find the Google calendar in question: + my $mach = Net::Netrc->lookup('calendar.google.com') + or die "No login details for calendar.google.com in ~/.netrc"; + my ($user, $pass) = $mach->lpa; + + + my $gcal = Net::Google::Calendar->new; + $gcal->login($user, $pass) + or die "Google Calendar login failed"; + + my ($desired_calendar) = grep { + $_->title eq $calendar_name + } $gcal->get_calendars; + + if (!$desired_calendar) { + die "No calendar named $calendar_name found!"; + } + $gcal->set_calendar($desired_calendar); + return $gcal; +} + +=head2 fetch_ical + +Given an iCal feed URL, fetches it, parses it using L, and returns +the result. + +=cut + +sub fetch_ical { + my ($class, $ical_url) = @_; + + my $ical_data = LWP::Simple::get($ical_url) + or die "Failed to fetch $ical_url"; + + my $ic = iCal::Parser->new; + + my $ical = $ic->parse_strings($ical_data) + or die "Failed to parse iCal data"; + + return $ical; + +} + +=head2 hash_ical_url + +Given the iCal feed URL, return a short hash to identify it; we'll use this in +the imported_ical_id tags in event descriptions to record which feed they came +from, so we can later delete them if the event is no longer present in the iCal +feed (i.e. it was deleted from the source). + +=cut + +sub hash_ical_url { + my ($class, $ical_url) = @_; + # Hash the feed URL, so we can use it along with the event ID to uniquely + # identify events - and when removing events that aren't in the feed any + # more, remove only those which came from this feed, if the user is using + # multiple feeds. (10 characters of the hash should be enough to be + # reliable enough.) + return substr(Digest::MD5->md5_hex($ical_url), 0, 10); } + +=head2 update_google_calendar + +Given a Google calendar object and the parsed iCal calendar data, make the +appropriate updates to the Google Calendar. + +=cut + +sub update_google_calendar { + my ($class, $gcal, $ical, $feed_url_hash) = @_; + + # We get events keyed by year, month, day - we just want a flat list of + # events to walk through. Do this keyed by the event ID, so that + # multiple-day events are handled appropriately. We'll want this hash + # anyway to do a pass through all events on the Google Calendar, removing + # any that are no longer in the iCal feed. + + my %ical_events; + for my $year (keys %{ $ical->{events} }) { + for my $month (keys %{ $ical->{events}{$year} }) { + for my $day (keys %{ $ical->{events}{$year}{$month} }) { + for my $event_uid (keys %{ $ical->{events}{$year}{$month}{$day} }) { + $ical_events{ $event_uid } + = $ical->{events}{$year}{$month}{$day}{$event_uid}; + } + } + } + } + + # Fetch all events from this calendar, parse out the ical feed's UID and + # whack them in a hash keyed by the UID; if that UID no longer appears in + # the ical feed, it's one to delete. + my %gcal_events; + + gcal_event: + for my $event ($gcal->get_events) { + my ($ical_feed_hash, $ical_uid) + = $event->content->body =~ m{\[ical_imported_uid:(.+)/(.+)\]}; + + # If there's no ical uid, we presumably didn't create this, so leave it + # alone + if (!$ical_uid) { + # Special-case, though: previous versions of this script didn't store + # the feed hash, so if we have only the event UID, assume it was this + # feed so the script continues working + if ($ical_uid + = $event->content->body =~ m{\[ical_imported_uid:(.+)\]} + ) { + $ical_feed_hash = $feed_url_hash; + } else { + warn sprintf "Event %s (%s) ignored as it has no " + . "ical_imported_uid property", + $event->id, + $event->title; + next gcal_event; + } + } + + # OK, if the event isn't for this feed, let it be: + if ($ical_feed_hash ne $feed_url_hash) { + next gcal_event; + } + + # OK, if this event didn't appear in the iCal feed, it has been deleted at + # the other end, so we should delete it from our Google calendar: + if (!$ical_events{$ical_uid}) { + printf "Deleting event %s (%s) (no longer found in iCal feed)\n", + $event->id, $event->title; + $gcal->delete_entry($event) + or warn "Failed to delete an event from Google Calendar"; + } + + # Now check for any differences, and update if required + + # Remember that we found this event, so we can refer to it when looking for + # events we need to create + $gcal_events{$ical_uid} = $event; + } + + + # Now, walk through the ical events we found, and create/update Google Calendar + # events + for my $ical_uid (keys %ical_events) { + + my ($method, $gcal_event); + + my $ical_event = $ical_events{$ical_uid}; + my $gcal_event = $class->ical_event_to_gcal_event( + $ical_event, $gcal_events{$ical_uid}, $feed_url_hash + ); + my $method = exists $gcal_events{$ical_uid} + ? 'update_entry' : 'add_entry'; + + $gcal->$method($gcal_event) + or warn "Failed to $method for $ical_uid"; + } + + +} + + +# Given an iCal event hash (from iCal::Parser) (and possibly a +# Net::Google::Calender event object to update), return an appropriate Google +# Calendar event object (either the one given, after updating it, or a newly +# populated one) +# Note: does not actually add/update the event on the calendar, just returns the +# event object. +sub ical_event_to_gcal_event { + my ($class, $ical_event, $gcal_event, $feed_url_hash) = @_; + + if (ref $ical_event ne 'HASH') { + die "Given invalid iCal event"; + } + if (defined $gcal_event && (!blessed($gcal_event) || + !$gcal_event->isa('Net::Google::Calendar::Event'))) + { + die "Given invalid Google Calendar event - what is it?"; + } + + $gcal_event ||= Net::Google::Calendar::Event->new; + + my $ical_uid = $ical_event->{UID}; + $gcal_event->title( $ical_event->{SUMMARY} ); + $gcal_event->location( $ical_event->{LOCATION} ); + $gcal_event->when( $ical_event->{DTSTART}, $ical_event->{DTEND} ); + $gcal_event->content("[ical_imported_uid:$feed_url_hash/$ical_uid]"); + + return $gcal_event; +} + + + +=head1 AUTHOR + +David Precious C<< >> + +=head1 BUGS / FEATURE REQUESTS + +If you've found a bug, or have a feature request or wish to contribute a patch, +this module is developed on GitHub - please feel free to raise issues or pull +requests against the repo at: +L + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2014 David Precious. + +This program is free software; you can redistribute it and/or modify it +under the terms of either: the GNU General Public License as published +by the Free Software Foundation; or the Artistic License. + +See http://dev.perl.org/licenses/ for more information. + From 8d2c915c9f089857c882dc72d94205972b4dcdc5 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 30 Oct 2014 17:52:28 +0000 Subject: [PATCH 02/55] Document configuring via ~/.netrc --- lib/App/ICalToGCal.pm | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index 6c36bd8..1466d5d 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -39,6 +39,22 @@ add/update events in your Google Calendar to match it: ical-to-gcal --calendar="Google Calendar Name" --ical-url=.... +You'll need to have put your login details in C<~/.netrc>... + +=head1 CONFIGURATION + +Google account details to be used to access your Google Calendar are stored in +the standard C<~/.netrc> file - your C<~/.netrc> file in your home directory +should contain: + + machine calendar.google.com + login yourgoogleaccountusername + password hunter2 + +Of course, you'll want to ensure that file is protected (kept readable by you +only, etc). + + =head1 CLASS METHODS From 701fd13134ec77f18b5ea01f0495d31722d34c00 Mon Sep 17 00:00:00 2001 From: David Precious Date: Fri, 31 Oct 2014 17:05:36 +0000 Subject: [PATCH 03/55] This is only used by the script now --- lib/App/ICalToGCal.pm | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index 1466d5d..8a43569 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -7,7 +7,6 @@ use Net::Google::Calendar; use Net::Netrc; use iCal::Parser; use LWP::Simple; -use Getopt::Long; use Digest::MD5; =head1 NAME From d1d85b11de0f6ffaa8c3d16fd4cee04fa5af2371 Mon Sep 17 00:00:00 2001 From: David Precious Date: Fri, 31 Oct 2014 17:05:57 +0000 Subject: [PATCH 04/55] Script should use Getopt::Long --- ical-to-gcal | 1 + 1 file changed, 1 insertion(+) diff --git a/ical-to-gcal b/ical-to-gcal index 757012d..8dd5bf9 100644 --- a/ical-to-gcal +++ b/ical-to-gcal @@ -11,6 +11,7 @@ use strict; use App::ICalToGCal; +use Getopt::Long; my ($calendar, $ical_url); Getopt::Long::GetOptions( From 5bdffd09d28e7591ee8fafc7e24ce271cf3c0a5d Mon Sep 17 00:00:00 2001 From: David Precious Date: Fri, 31 Oct 2014 17:10:14 +0000 Subject: [PATCH 05/55] Use LWP::UserAgent so we can send User-Agent. Be a nice Internet citizen and identify ourselves. --- lib/App/ICalToGCal.pm | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index 8a43569..c9d405c 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -6,7 +6,7 @@ use strict; use Net::Google::Calendar; use Net::Netrc; use iCal::Parser; -use LWP::Simple; +use LWP::UserAgent; use Digest::MD5; =head1 NAME @@ -99,12 +99,17 @@ the result. sub fetch_ical { my ($class, $ical_url) = @_; - my $ical_data = LWP::Simple::get($ical_url) - or die "Failed to fetch $ical_url"; + my $ua = LWP::UserAgent->new; + $ua->agent(__PACKAGE__ . "/" . $VERSION); + + my $response = $ua->get($ical_url); + if (!$response->is_success) { + die "Failed to fetch $ical_url - " . $response->status_line; + } my $ic = iCal::Parser->new; - my $ical = $ic->parse_strings($ical_data) + my $ical = $ic->parse_strings($response->decoded_content) or die "Failed to parse iCal data"; return $ical; From 427952f84c6fe8ba67866279f0e0bc1936d6c422 Mon Sep 17 00:00:00 2001 From: David Precious Date: Fri, 31 Oct 2014 17:11:11 +0000 Subject: [PATCH 06/55] Add a Makefile.PL --- Makefile.PL | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 Makefile.PL diff --git a/Makefile.PL b/Makefile.PL new file mode 100644 index 0000000..c5838af --- /dev/null +++ b/Makefile.PL @@ -0,0 +1,28 @@ +use 5.006; +use strict; +use warnings FATAL => 'all'; +use ExtUtils::MakeMaker; + +WriteMakefile( + NAME => 'App::ICalToGCal', + AUTHOR => q{David Precious }, + VERSION_FROM => 'lib/App/ICalToGCal.pm', + ABSTRACT_FROM => 'lib/App/ICalToGCal.pm', + LICENSE => 'Artistic_2_0', + PL_FILES => {}, + MIN_PERL_VERSION => 5.010, + CONFIGURE_REQUIRES => { + 'ExtUtils::MakeMaker' => 0, + }, + BUILD_REQUIRES => { + 'Test::More' => 0, + }, + PREREQ_PM => { + 'Net::Google::Calendar' => 0, + 'Net::Netrc' => 0, + 'iCal::Parser' => 0, + 'LWP::UserAgent' => 0, + }, + dist => { COMPRESS => 'gzip -9f', SUFFIX => 'gz', }, + clean => { FILES => 'App-ICalToGCal-*' }, +); From 29d10ce4f568100de88528b161547c83fa46f0fa Mon Sep 17 00:00:00 2001 From: David Precious Date: Fri, 31 Oct 2014 17:13:11 +0000 Subject: [PATCH 07/55] Add MANIFEST --- MANIFEST | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 MANIFEST diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..49669cf --- /dev/null +++ b/MANIFEST @@ -0,0 +1,6 @@ +Changes +lib/App/ICalToGCal.pm +Makefile.PL +MANIFEST +README +bin/ical-to-gcal From 1469bfea5aa246f17e79edce39e332bc093516cf Mon Sep 17 00:00:00 2001 From: David Precious Date: Fri, 31 Oct 2014 17:13:43 +0000 Subject: [PATCH 08/55] Move script to bin/ --- ical-to-gcal => bin/ical-to-gcal | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ical-to-gcal => bin/ical-to-gcal (100%) diff --git a/ical-to-gcal b/bin/ical-to-gcal similarity index 100% rename from ical-to-gcal rename to bin/ical-to-gcal From d3af533e4a67df4b72c135e601b9dc596379af19 Mon Sep 17 00:00:00 2001 From: David Precious Date: Fri, 31 Oct 2014 17:13:53 +0000 Subject: [PATCH 09/55] Add script to EXE_FILES --- Makefile.PL | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile.PL b/Makefile.PL index c5838af..591d3a7 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -10,6 +10,7 @@ WriteMakefile( ABSTRACT_FROM => 'lib/App/ICalToGCal.pm', LICENSE => 'Artistic_2_0', PL_FILES => {}, + EXE_FILES => [ 'bin/ical-to-gcal' ], MIN_PERL_VERSION => 5.010, CONFIGURE_REQUIRES => { 'ExtUtils::MakeMaker' => 0, From 5d94fde9f8785d8a8af7ad476e7e2a1f005fc90f Mon Sep 17 00:00:00 2001 From: David Precious Date: Sat, 1 Nov 2014 22:53:43 +0000 Subject: [PATCH 10/55] Make Net::Google::Calendar object mockable. Fetch our Net::Google::Calendar object via a class method that can also be used to replace it, so test scripts can provide a mock object that implements the required features, and we'll just use it. --- lib/App/ICalToGCal.pm | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index c9d405c..32f6bf5 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -74,7 +74,7 @@ sub select_google_calendar { my ($user, $pass) = $mach->lpa; - my $gcal = Net::Google::Calendar->new; + my $gcal = $class->gcal_obj; $gcal->login($user, $pass) or die "Google Calendar login failed"; @@ -263,7 +263,20 @@ sub ical_event_to_gcal_event { return $gcal_event; } +# Allow the Net::Google::Calendar object to be overriden (e.g. for mocked tests) +{ + my $gcal; + sub gcal_obj { + my $class = shift; + if (@_) { + $gcal = shift; + } + + return $gcal ||= Net::Google::Calendar->new; + } +} +1; # That's all, folks! =head1 AUTHOR From 312f04eaf1e24fc4f8f0871084e674bdc0cc0e18 Mon Sep 17 00:00:00 2001 From: David Precious Date: Sat, 1 Nov 2014 22:55:02 +0000 Subject: [PATCH 11/55] Start of tests using mocking A lot more to do, but committing this now. Currently prepares a mocked Net::Google::Calendar object, passes it to the gcal_obj class method, and tests that we then get that mocked object back when we ask for a Net::Google::Calendar object. Next up: tests that, when given known iCal data, the results stored in the mock object are as we'd expect. --- t/00-mock.t | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 t/00-mock.t diff --git a/t/00-mock.t b/t/00-mock.t new file mode 100644 index 0000000..f45bc7e --- /dev/null +++ b/t/00-mock.t @@ -0,0 +1,53 @@ +#!perl -T +use 5.006; +use strict; +use warnings FATAL => 'all'; +use Test::MockObject; +use Test::More; + +plan tests => 2; + +use_ok( 'App::ICalToGCal' ) || print "Bail out!\n"; + +diag( "Testing App::ICalToGCal $App::ICalToGCal::VERSION, Perl $], $^X" ); + +# override the Net::Google::Calendar object so we can parse some example feeds +# and check what resulting objects were created. Implement just enough of the +# interface we expect to use to make things work. +my $mock_gcal = Test::MockObject->new; +$mock_gcal->set_true('login'); +$mock_gcal->set_list('calendars', + map { + my $calendar = Test::MockObject->new; + $calendar->set_always('name', $_); + $calendar; + } ('Boring calendar', 'Calendar we want', 'Other calendar') +); +$mock_gcal->mock( + 'delete_entry', + sub { + my ($self, $entry) = @_; + $self->{entries} = grep { ref $_ ne ref $entry } @{ $self->{entries} }; + } +); +$mock_gcal->mock( + 'add_entry', + sub { + my ($self, $entry) = @_; + push @{ $self->{entries} }, $entry; + } +); +# update_entry is a no-op here; the entry objects are references, and the entry +# object will have been updated; update_entry would be called to sync the +# changes to Google, so we do nothing. +$mock_gcal->set_true('update_entry'); +$mock_gcal->set_true('is_our_mocked_object'); +App::ICalToGCal->gcal_obj($mock_gcal); +is( + App::ICalToGCal->gcal_obj(), + $mock_gcal, + "Got back our mocked Net::Google::Calendar object", +); + + + From 0682b8bf2fe649824af92be557d9d8249d911b2b Mon Sep 17 00:00:00 2001 From: David Precious Date: Sat, 1 Nov 2014 22:56:48 +0000 Subject: [PATCH 12/55] Add Test::MockObject to BUILD_REQUIRES --- Makefile.PL | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile.PL b/Makefile.PL index 591d3a7..85b1025 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -16,6 +16,7 @@ WriteMakefile( 'ExtUtils::MakeMaker' => 0, }, BUILD_REQUIRES => { + 'Test::MockObject' => 0, 'Test::More' => 0, }, PREREQ_PM => { From c3ed64ea2bd9218c276b2757e23217c5e185b088 Mon Sep 17 00:00:00 2001 From: David Precious Date: Sat, 1 Nov 2014 23:46:14 +0000 Subject: [PATCH 13/55] Supply wider "window" to iCal::Parser --- lib/App/ICalToGCal.pm | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index 32f6bf5..3be5182 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -3,6 +3,7 @@ package App::ICalToGCal; our $VERSION = 0.01; use strict; +use DateTime; use Net::Google::Calendar; use Net::Netrc; use iCal::Parser; @@ -107,7 +108,11 @@ sub fetch_ical { die "Failed to fetch $ical_url - " . $response->status_line; } - my $ic = iCal::Parser->new; + my $ic = iCal::Parser->new( + start => DateTime->now->subtract( years => 5 ), + end => DateTime->now->add( years => 5 ), + debug => 1, + ); my $ical = $ic->parse_strings($response->decoded_content) or die "Failed to parse iCal data"; From e651b7f9c9d46a2aec4bea0f2d0b0c091e2028d4 Mon Sep 17 00:00:00 2001 From: David Precious Date: Sat, 1 Nov 2014 23:46:46 +0000 Subject: [PATCH 14/55] use Net::Google::Calendar::Event; --- lib/App/ICalToGCal.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index 3be5182..7c94231 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -5,6 +5,7 @@ our $VERSION = 0.01; use strict; use DateTime; use Net::Google::Calendar; +use Net::Google::Calendar::Event; use Net::Netrc; use iCal::Parser; use LWP::UserAgent; From fe90ed8a88289e1e53c8ec68134ef1c1ecca7534 Mon Sep 17 00:00:00 2001 From: David Precious Date: Sat, 1 Nov 2014 23:47:44 +0000 Subject: [PATCH 15/55] It's Net::Google::Calendar::Entry, not ::Event --- lib/App/ICalToGCal.pm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index 7c94231..bc70493 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -5,7 +5,7 @@ our $VERSION = 0.01; use strict; use DateTime; use Net::Google::Calendar; -use Net::Google::Calendar::Event; +use Net::Google::Calendar::Entry; use Net::Netrc; use iCal::Parser; use LWP::UserAgent; @@ -258,7 +258,7 @@ sub ical_event_to_gcal_event { die "Given invalid Google Calendar event - what is it?"; } - $gcal_event ||= Net::Google::Calendar::Event->new; + $gcal_event ||= Net::Google::Calendar::Entry->new; my $ical_uid = $ical_event->{UID}; $gcal_event->title( $ical_event->{SUMMARY} ); From 19e98e73dc3ed878e848603f0d95279b4682b12a Mon Sep 17 00:00:00 2001 From: David Precious Date: Sun, 2 Nov 2014 00:09:41 +0000 Subject: [PATCH 16/55] For each ical file, check results look sane. Not yet complete - more hacking to do here - but handles looping over a set of test specs, each of which names an iCal data file to parse, and provides a hashref describing what the resulting entries added to the Google calendar ought to look like. Will need a flag in each test spec to indicate if the entries in the mock gcal object should be wiped before starting, so some tests can be testing only that the results from that parse are correct, but others can test that the results look as expected after making changes based on the previous one too (to e.g. test for events that are no longer in the ical feed / have been modified etc being handled correctly). --- t/00-mock.t | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/t/00-mock.t b/t/00-mock.t index f45bc7e..b93f7dc 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -2,11 +2,14 @@ use 5.006; use strict; use warnings FATAL => 'all'; +use Cwd; +use File::Spec; +use Test::Differences; use Test::MockObject; use Test::More; -plan tests => 2; - +#plan tests => 2; +#plan tests => 'no_plan'; use_ok( 'App::ICalToGCal' ) || print "Bail out!\n"; diag( "Testing App::ICalToGCal $App::ICalToGCal::VERSION, Perl $], $^X" ); @@ -37,6 +40,12 @@ $mock_gcal->mock( push @{ $self->{entries} }, $entry; } ); +$mock_gcal->mock( + 'get_events', + sub { + return @{ shift->{entries} || [] }; + }, +); # update_entry is a no-op here; the entry objects are references, and the entry # object will have been updated; update_entry would be called to sync the # changes to Google, so we do nothing. @@ -49,5 +58,77 @@ is( "Got back our mocked Net::Google::Calendar object", ); +# We have some sample iCal data shipped with the dist; if we work out the +# absolute path to them, we should be able to give a file:// URL to fetch_ical +# and expect it to work. +my @tests = ( + { + ical_file => 'test1.ical', + expect_entries => [ + + ] + }, + { + ical_file => 'allday.ical', + expect_entries => [ + ], + }, +); + +for my $test_spec (@tests) { + my $ical_file = File::Spec->catfile( + Cwd::cwd(), 'ical-tests', $test_spec->{ical_file} + ); + my $ical_data = App::ICalToGCal->fetch_ical("file://$ical_file"); + ok(ref $ical_data, "Got a parsed iCal result from $ical_file"); + use Data::Dump; + warn Data::Dump::dump($ical_data); + + ok( + App::ICalToGCal->update_google_calendar( + $mock_gcal, $ical_data, App::ICalToGCal->hash_ical_url($ical_file) + ), + "update_google_calendar appeared to work", + ); + + warn "Events boil down to: " . + Data::Dump::dump(summarise_events([ $mock_gcal->get_events ])); + + eq_or_diff( + [ $mock_gcal->get_events() ], + $test_spec->{expect_entries}, + "Entries for $test_spec->{ical_file} look correct", + ); + +} + + +done_testing(); + + +# Given a set of Net::Google::Calendar::Entry objects, turn them into simple +# concise hashrefs we can give to Test::Differences to compare with +# what the test says we should have got. +sub summarise_events { + my $events = shift; + return [ + map { + my $entry = $_; + my ($start, $end, $all_day) = $entry->when; + +{ + # The simpler stuff: + ( + map { $_ => $entry->$_() } + qw(title location status) + ), + # deflate datetimes: + when => join(' => ', + (map { $_->iso8601 } ($start, $end)) + ), + all_day => $all_day, + } + } @$events + ]; +} From 45859433bf974513ec7cc457a997b56d4164c6ff Mon Sep 17 00:00:00 2001 From: David Precious Date: Sun, 2 Nov 2014 00:20:45 +0000 Subject: [PATCH 17/55] test ical data --- t/ical-data/allday.ical | 12 ++++++++++++ t/ical-data/recurring.ical | 15 +++++++++++++++ t/ical-data/test1.ical | 26 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 t/ical-data/allday.ical create mode 100644 t/ical-data/recurring.ical create mode 100644 t/ical-data/test1.ical diff --git a/t/ical-data/allday.ical b/t/ical-data/allday.ical new file mode 100644 index 0000000..fedf620 --- /dev/null +++ b/t/ical-data/allday.ical @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Testing//Test//EN +BEGIN:VEVENT +DTSTART;VALUE=DATE:20041115 +DTEND;VALUE=DATE:20041116 +SUMMARY:all day +UID:A4E57872-3516-11D9-8A43-000D93C45D90 +SEQUENCE:2 +DTSTAMP:20041113T015226Z +END:VEVENT +END:VCALENDAR diff --git a/t/ical-data/recurring.ical b/t/ical-data/recurring.ical new file mode 100644 index 0000000..e02aa88 --- /dev/null +++ b/t/ical-data/recurring.ical @@ -0,0 +1,15 @@ +BEGIN:VEVENT +UID:5066@domain.com +ORGANIZER;CN="Max Mustermann":MAILTO:m.mustermann@domain.de +ATTENDEE;CN="Jeder";RSVP=FALSE;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:MAILTO:info@topackt.de +RRULE:FREQ=WEEKLY;INTERVAL=1 +SUMMARY;CHARSET=UTF-8:Teamsitzung (Serientermin) +DESCRIPTION;CHARSET=UTF-8: +LOCATION;CHARSET=UTF-8:A Place, +a street 2, +plz Town +CLASS:PUBLIC +CATEGORIES;CHARSET=UTF-8:Intern +DTSTART;TZID=Europe/Berlin:20130902T090000 +DTEND;TZID=Europe/Berlin:20130902T103000 +END:VEVENT diff --git a/t/ical-data/test1.ical b/t/ical-data/test1.ical new file mode 100644 index 0000000..588849f --- /dev/null +++ b/t/ical-data/test1.ical @@ -0,0 +1,26 @@ +BEGIN:VCALENDAR +CALSCALE:GREGORIAN +PRODID:-//Apple Computer\, Inc//iCal 2.0//EN +VERSION:2.0 +BEGIN:VEVENT +LOCATION:San Francisco +DTSTAMP:20150618T151130Z +UID:BDF17182-CA21-4752-8D4F-40A4FE47C90D +SEQUENCE:8 +URL;VALUE=URI:http://developer.apple.com/wwdc/ +DTSTART;VALUE=DATE:20150606 +SUMMARY:Apple WWDC +DTEND;VALUE=DATE:20150612 +DESCRIPTION:Lots of sessions. +END:VEVENT +BEGIN:VEVENT +DURATION:PT1H +LOCATION:Home +DTSTAMP:20150618T151543Z +UID:5F88A0EC-AD21-428E-AAAD-005F1B1AB72E +SEQUENCE:6 +DTSTART;TZID=America/Chicago:20150615T180000 +SUMMARY:Set up File Server +DESCRIPTION:Music server for the kids. +END:VEVENT +END:VCALENDAR From ca374d801fce66a0cbea822ece313eba7119969e Mon Sep 17 00:00:00 2001 From: David Precious Date: Sun, 2 Nov 2014 00:21:30 +0000 Subject: [PATCH 18/55] New recurring test, and look for ical data in new place --- t/00-mock.t | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/t/00-mock.t b/t/00-mock.t index b93f7dc..c9f6e42 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -73,11 +73,18 @@ my @tests = ( expect_entries => [ ], }, + { + # This test is based on Issue 6: + # https://github.com/bigpresh/ical-to-google-calendar/issues/6 + ical_file => 'recurring.ical', + expect_entries => [ + ], + }, ); for my $test_spec (@tests) { my $ical_file = File::Spec->catfile( - Cwd::cwd(), 'ical-tests', $test_spec->{ical_file} + Cwd::cwd(), 't', 'ical-data', $test_spec->{ical_file} ); my $ical_data = App::ICalToGCal->fetch_ical("file://$ical_file"); ok(ref $ical_data, "Got a parsed iCal result from $ical_file"); From f6027aa5a8c0b35caa09f1cab0af14411ad00821 Mon Sep 17 00:00:00 2001 From: David Precious Date: Sun, 2 Nov 2014 00:26:46 +0000 Subject: [PATCH 19/55] Wipe all events between tests by default. Unless the test says otherwise, wipe all events from the mock object before starting each test. That way, each test can easily compare the list of events to what it expects to see, and not see stuff from previous tests there - but tests which want to build upon previous results (e.g. testing that events no longer in the source iCal feed get deleted, updated events get updated, etc) can do so. --- t/00-mock.t | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/t/00-mock.t b/t/00-mock.t index c9f6e42..7124e81 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -50,7 +50,14 @@ $mock_gcal->mock( # object will have been updated; update_entry would be called to sync the # changes to Google, so we do nothing. $mock_gcal->set_true('update_entry'); -$mock_gcal->set_true('is_our_mocked_object'); +$mock_gcal->mock( + 'wipe_entries', + sub { + shift->{entries} = []; + } +); + + App::ICalToGCal->gcal_obj($mock_gcal); is( App::ICalToGCal->gcal_obj(), @@ -86,6 +93,14 @@ for my $test_spec (@tests) { my $ical_file = File::Spec->catfile( Cwd::cwd(), 't', 'ical-data', $test_spec->{ical_file} ); + + # Unless this test expects to be building upon the results of a previous + # test, wipe the contents of the mock gcal object, so we can compare the + # entries with what we expect to see, and not see things we didn't expect: + if (!$test_spec->{keep_previous}) { + $mock_gcal->wipe_entries; + } + my $ical_data = App::ICalToGCal->fetch_ical("file://$ical_file"); ok(ref $ical_data, "Got a parsed iCal result from $ical_file"); use Data::Dump; From bb487dc1b564abf300dee80bde07f0ffcbd7730e Mon Sep 17 00:00:00 2001 From: David Precious Date: Sun, 2 Nov 2014 00:28:58 +0000 Subject: [PATCH 20/55] Expectation for test1.data --- t/00-mock.t | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/t/00-mock.t b/t/00-mock.t index 7124e81..d3bbad6 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -72,7 +72,20 @@ my @tests = ( { ical_file => 'test1.ical', expect_entries => [ - + { + all_day => 0, + location => "Home", + status => undef, + title => "Set up File Server", + when => "2015-06-15T17:00:00 => 2015-06-15T18:00:00", + }, + { + all_day => 0, + location => "San Francisco", + status => undef, + title => "Apple WWDC", + when => "2015-06-08T23:00:00 => 2015-06-09T23:00:00", + }, ] }, { From 19050668e258e4cc83893f2803719ee0ac79c9bf Mon Sep 17 00:00:00 2001 From: David Precious Date: Sun, 2 Nov 2014 00:30:41 +0000 Subject: [PATCH 21/55] Compare against summarised events representation. Also dump some excess debug output. --- t/00-mock.t | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/t/00-mock.t b/t/00-mock.t index d3bbad6..2073181 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -116,8 +116,6 @@ for my $test_spec (@tests) { my $ical_data = App::ICalToGCal->fetch_ical("file://$ical_file"); ok(ref $ical_data, "Got a parsed iCal result from $ical_file"); - use Data::Dump; - warn Data::Dump::dump($ical_data); ok( App::ICalToGCal->update_google_calendar( @@ -126,11 +124,12 @@ for my $test_spec (@tests) { "update_google_calendar appeared to work", ); + use Data::Dump; warn "Events boil down to: " . Data::Dump::dump(summarise_events([ $mock_gcal->get_events ])); eq_or_diff( - [ $mock_gcal->get_events() ], + summarise_events([ $mock_gcal->get_events() ]), $test_spec->{expect_entries}, "Entries for $test_spec->{ical_file} look correct", ); From 665c9ab50c72b4b0de1f6889d175c350dce18239 Mon Sep 17 00:00:00 2001 From: David Precious Date: Sun, 2 Nov 2014 00:33:20 +0000 Subject: [PATCH 22/55] update_google_calendar doesn't return success/fail --- t/00-mock.t | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/t/00-mock.t b/t/00-mock.t index 2073181..8cd87fd 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -117,11 +117,8 @@ for my $test_spec (@tests) { my $ical_data = App::ICalToGCal->fetch_ical("file://$ical_file"); ok(ref $ical_data, "Got a parsed iCal result from $ical_file"); - ok( - App::ICalToGCal->update_google_calendar( - $mock_gcal, $ical_data, App::ICalToGCal->hash_ical_url($ical_file) - ), - "update_google_calendar appeared to work", + App::ICalToGCal->update_google_calendar( + $mock_gcal, $ical_data, App::ICalToGCal->hash_ical_url($ical_file) ); use Data::Dump; From 31d96bf2ff9298e6d3efba386818268928d8d763 Mon Sep 17 00:00:00 2001 From: David Precious Date: Sun, 2 Nov 2014 21:06:59 +0000 Subject: [PATCH 23/55] Recurring event test data --- t/ical-data/recurring2.ical | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 t/ical-data/recurring2.ical diff --git a/t/ical-data/recurring2.ical b/t/ical-data/recurring2.ical new file mode 100644 index 0000000..29bdd75 --- /dev/null +++ b/t/ical-data/recurring2.ical @@ -0,0 +1,14 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Testing//Test//EN +BEGIN:VEVENT +DTSTART;TZID=Europe/London:20151102T000000 +SUMMARY:This event recurs weekly +UID:5A4E636A-35E7-11D9-9E64-000D93C45D90 +SEQUENCE:3 +DTSTAMP:20151114T024734Z +RRULE:FREQ=WEEKLY;INTERVAL=1;UNTIL=20151203T045959Z;BYDAY=TH,FR;WKST=SU +LOCATION:Anywhere and everywhere +DURATION:PT1H +END:VEVENT +END:VCALENDAR From 7c640ebbf779248865a7ae04d9f2842d8aa19e82 Mon Sep 17 00:00:00 2001 From: David Precious Date: Sun, 2 Nov 2014 22:55:50 +0000 Subject: [PATCH 24/55] Switch to Data::ICal --- lib/App/ICalToGCal.pm | 122 +++++++++++++++++++++++++++--------------- 1 file changed, 78 insertions(+), 44 deletions(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index bc70493..d7909d6 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -3,13 +3,16 @@ package App::ICalToGCal; our $VERSION = 0.01; use strict; +use Data::ICal; +use Date::ICal::Duration; use DateTime; +use DateTime::Format::ISO8601; use Net::Google::Calendar; use Net::Google::Calendar::Entry; use Net::Netrc; -use iCal::Parser; use LWP::UserAgent; use Digest::MD5; +use Scalar::Util qw(blessed); =head1 NAME @@ -109,16 +112,10 @@ sub fetch_ical { die "Failed to fetch $ical_url - " . $response->status_line; } - my $ic = iCal::Parser->new( - start => DateTime->now->subtract( years => 5 ), - end => DateTime->now->add( years => 5 ), - debug => 1, - ); - - my $ical = $ic->parse_strings($response->decoded_content) + my $ical = Data::ICal->new( data => $response->decoded_content ) or die "Failed to parse iCal data"; - return $ical; + return $ical->entries; } @@ -148,29 +145,12 @@ appropriate updates to the Google Calendar. =cut sub update_google_calendar { - my ($class, $gcal, $ical, $feed_url_hash) = @_; - - # We get events keyed by year, month, day - we just want a flat list of - # events to walk through. Do this keyed by the event ID, so that - # multiple-day events are handled appropriately. We'll want this hash - # anyway to do a pass through all events on the Google Calendar, removing - # any that are no longer in the iCal feed. - - my %ical_events; - for my $year (keys %{ $ical->{events} }) { - for my $month (keys %{ $ical->{events}{$year} }) { - for my $day (keys %{ $ical->{events}{$year}{$month} }) { - for my $event_uid (keys %{ $ical->{events}{$year}{$month}{$day} }) { - $ical_events{ $event_uid } - = $ical->{events}{$year}{$month}{$day}{$event_uid}; - } - } - } - } + my ($class, $gcal, $ical_entries, $feed_url_hash) = @_; + - # Fetch all events from this calendar, parse out the ical feed's UID and - # whack them in a hash keyed by the UID; if that UID no longer appears in - # the ical feed, it's one to delete. + # Fetch all events from this Google calendar, parse out the ical feed's UID + # and whack them in a hash keyed by the UID; if that UID no longer appears + # in Ithe ical feed, it's one to delete. my %gcal_events; gcal_event: @@ -202,17 +182,19 @@ sub update_google_calendar { next gcal_event; } + # Assemble a hash on event ID of all the events we saw in the iCal feed, + # so we can quickly check + # OK, if this event didn't appear in the iCal feed, it has been deleted at # the other end, so we should delete it from our Google calendar: - if (!$ical_events{$ical_uid}) { + if (!grep { $_->properties->{uid}->value eq $ical_uid } @$ical_entries) + { printf "Deleting event %s (%s) (no longer found in iCal feed)\n", $event->id, $event->title; $gcal->delete_entry($event) or warn "Failed to delete an event from Google Calendar"; } - # Now check for any differences, and update if required - # Remember that we found this event, so we can refer to it when looking for # events we need to create $gcal_events{$ical_uid} = $event; @@ -221,22 +203,21 @@ sub update_google_calendar { # Now, walk through the ical events we found, and create/update Google Calendar # events - for my $ical_uid (keys %ical_events) { + for my $ical_event (@$ical_entries) { my ($method, $gcal_event); + my $ical_uid = get_ical_field($ical_event, 'uid'); - my $ical_event = $ical_events{$ical_uid}; my $gcal_event = $class->ical_event_to_gcal_event( $ical_event, $gcal_events{$ical_uid}, $feed_url_hash ); - my $method = exists $gcal_events{$ical_uid} + my $ical_uid = $ical_event->properties->{uid}[0]->value; + my $method = exists $gcal_events{ $ical_uid } ? 'update_entry' : 'add_entry'; $gcal->$method($gcal_event) or warn "Failed to $method for $ical_uid"; } - - } @@ -249,7 +230,7 @@ sub update_google_calendar { sub ical_event_to_gcal_event { my ($class, $ical_event, $gcal_event, $feed_url_hash) = @_; - if (ref $ical_event ne 'HASH') { + if (!blessed($ical_event) || !$ical_event->isa('Data::ICal::Entry::Event')) { die "Given invalid iCal event"; } if (defined $gcal_event && (!blessed($gcal_event) || @@ -260,15 +241,68 @@ sub ical_event_to_gcal_event { $gcal_event ||= Net::Google::Calendar::Entry->new; - my $ical_uid = $ical_event->{UID}; - $gcal_event->title( $ical_event->{SUMMARY} ); - $gcal_event->location( $ical_event->{LOCATION} ); - $gcal_event->when( $ical_event->{DTSTART}, $ical_event->{DTEND} ); + my $ical_uid = get_ical_field($ical_event, 'uid'); + $gcal_event->title( get_ical_field($ical_event, 'summary' ) ); + $gcal_event->location( get_ical_field($ical_event, 'location' ) ); + $gcal_event->when( + get_ical_field($ical_event, 'dtstart'), + get_ical_field($ical_event, 'dtend'), + ); $gcal_event->content("[ical_imported_uid:$feed_url_hash/$ical_uid]"); return $gcal_event; } +# Wrap the nastyness of Data::ICal::Property stuff away +sub get_ical_field { + my ($ical_event, $field) = @_; + my $value; + + if (!blessed($ical_event) || !$ical_event->isa('Data::ICal::Entry::Event')) { + die "get_ical_field called without iCal event (got [$ical_event])"; + } + + my $properties = $ical_event->properties; + if (my $prop = $properties->{$field}) { + $value = $prop->[0]->value; + } + + # Auto-inflate dtstart/dtend properties into DateTime objects + if ($field =~ /^dt(start|end)$/ && $value) { + my $dt = DateTime::Format::ISO8601->parse_datetime($value) + or die "Failed to parse '$value' into a DateTime object!"; + my $tz = $properties->{$field}[0]{'_parameters'}{TZID}; + warn "Timezone for $field is '$tz'"; + # TODO: if we didn't find a timezone, should we bail, or just leave the + # DT object in the flatong timezone and hope for the best? + if ($tz) { + $dt->set_time_zone($properties->{$field}[0]{'_parameters'}{TZID}); + } + $value = $dt; + } + + # If we wanted dtend and didn't find a value, but did find a duration, then + # we can calculate the value for dtend (as that's what we need to provide to + # Net::Google::Calendar::Entry->when() ) + if ($field eq 'dtend' && !$value && exists $properties->{duration}) { + my $duration = $properties->{duration}[0]->value; + my $dur_obj = Date::ICal::Duration->new( ical => $duration ); + my $elements = $dur_obj->as_elements; + delete $elements->{sign}; + my $dtstart = get_ical_field($ical_event, 'dtstart'); + my $dtend = $dtstart->clone->add( + map { $_ => $elements->{$_} } + grep { $elements->{$_} } keys %$elements + ); + warn "Calculated end $dtend from $dtstart + $duration"; + $value = $dtend; + } + + + warn "returning value '$value' for $field"; + return $value; +} + # Allow the Net::Google::Calendar object to be overriden (e.g. for mocked tests) { my $gcal; From e1d75ac9b9e455496abcb0f17468dbfaea90bb83 Mon Sep 17 00:00:00 2001 From: David Precious Date: Mon, 3 Nov 2014 00:46:00 +0000 Subject: [PATCH 25/55] Updated deps - Data::ICal etc. --- Makefile.PL | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Makefile.PL b/Makefile.PL index 85b1025..00721d1 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -22,8 +22,13 @@ WriteMakefile( PREREQ_PM => { 'Net::Google::Calendar' => 0, 'Net::Netrc' => 0, - 'iCal::Parser' => 0, 'LWP::UserAgent' => 0, + 'Data::ICal' => 0, + 'Date::ICal::Duration' => 0, + 'DateTime' => 0, + 'DateTime::Format::ISO8601' => 0, + 'Digest::MD5' => 0, + 'Scalar::Util' => 0, }, dist => { COMPRESS => 'gzip -9f', SUFFIX => 'gz', }, clean => { FILES => 'App-ICalToGCal-*' }, From b9c3b3552cdf2aa8d120e9c6f743c1cc126e1c1a Mon Sep 17 00:00:00 2001 From: David Precious Date: Mon, 3 Nov 2014 01:03:19 +0000 Subject: [PATCH 26/55] Test that recurrence is recorded correctly. --- t/00-mock.t | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/t/00-mock.t b/t/00-mock.t index 8cd87fd..5e9874c 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -100,6 +100,10 @@ my @tests = ( expect_entries => [ ], }, + { + ical_file => 'recurring2.ical', + expect_entries => [ ], + } ); for my $test_spec (@tests) { @@ -121,10 +125,6 @@ for my $test_spec (@tests) { $mock_gcal, $ical_data, App::ICalToGCal->hash_ical_url($ical_file) ); - use Data::Dump; - warn "Events boil down to: " . - Data::Dump::dump(summarise_events([ $mock_gcal->get_events ])); - eq_or_diff( summarise_events([ $mock_gcal->get_events() ]), $test_spec->{expect_entries}, @@ -158,6 +158,9 @@ sub summarise_events { (map { $_->iso8601 } ($start, $end)) ), all_day => $all_day, + ( rrule => $_->recurrence + ? $_->recurrence->entries->[0]->properties->{rrule}[0]->value + : '' ) } } @$events ]; From b99588ecd0fb6925fd047856f4967e12cf8ade3b Mon Sep 17 00:00:00 2001 From: David Precious Date: Mon, 3 Nov 2014 01:03:52 +0000 Subject: [PATCH 27/55] Handle recurrence rrules if present. --- lib/App/ICalToGCal.pm | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index d7909d6..7e9f741 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -233,6 +233,7 @@ sub ical_event_to_gcal_event { if (!blessed($ical_event) || !$ical_event->isa('Data::ICal::Entry::Event')) { die "Given invalid iCal event"; } + if (defined $gcal_event && (!blessed($gcal_event) || !$gcal_event->isa('Net::Google::Calendar::Event'))) { @@ -248,6 +249,15 @@ sub ical_event_to_gcal_event { get_ical_field($ical_event, 'dtstart'), get_ical_field($ical_event, 'dtend'), ); + + # If there's a recurrence rule, handle it: + if (my $rrule = get_ical_field($ical_event, 'rrule')) { + # Odd that I can just provide the whole Data::ICal::Entry::Event object + # here; it's a shame Net::Google::Calendar doesn't understand how to + # take all details from it. + $gcal_event->recurrence($ical_event); + } + $gcal_event->content("[ical_imported_uid:$feed_url_hash/$ical_uid]"); return $gcal_event; From a6793b0aeafbdcb27184248b44993a3a0d480ad2 Mon Sep 17 00:00:00 2001 From: David Precious Date: Mon, 3 Nov 2014 01:06:15 +0000 Subject: [PATCH 28/55] Add a Changes file. One less thing to do when this nears release. --- Changes | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 Changes diff --git a/Changes b/Changes new file mode 100644 index 0000000..213bf7e --- /dev/null +++ b/Changes @@ -0,0 +1,12 @@ +Revision history for App-GCalToICal + +0.01 2014-11-?? + [ NEAR RE-WRITE ] + - Turned old ical-to-gcal script into a CPAN distribution + - Switched from Parser::iCal to Data::ICal + - Added recurring events support + - Support multi-day events properly + - Refactored to provide testability + - Added tests + + From b5f6a1f338e9a0ec78998b1bf0bdd57a1dd81f2e Mon Sep 17 00:00:00 2001 From: David Precious Date: Mon, 3 Nov 2014 01:08:47 +0000 Subject: [PATCH 29/55] Tweak start time in this test data --- t/ical-data/recurring2.ical | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/ical-data/recurring2.ical b/t/ical-data/recurring2.ical index 29bdd75..eeb6c0b 100644 --- a/t/ical-data/recurring2.ical +++ b/t/ical-data/recurring2.ical @@ -2,7 +2,7 @@ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Testing//Test//EN BEGIN:VEVENT -DTSTART;TZID=Europe/London:20151102T000000 +DTSTART;TZID=Europe/London:20151102T150000 SUMMARY:This event recurs weekly UID:5A4E636A-35E7-11D9-9E64-000D93C45D90 SEQUENCE:3 From 2f87b50665b87ba4ccca2cc8e718ae50e2ff310e Mon Sep 17 00:00:00 2001 From: David Precious Date: Wed, 5 Nov 2014 23:19:38 +0000 Subject: [PATCH 30/55] chmod +x bin/ical-to-gcal --- bin/ical-to-gcal | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 bin/ical-to-gcal diff --git a/bin/ical-to-gcal b/bin/ical-to-gcal old mode 100644 new mode 100755 From 063577fef5cf29b0d4b61b7f5d9d3fc856ea8323 Mon Sep 17 00:00:00 2001 From: David Precious Date: Wed, 5 Nov 2014 23:32:17 +0000 Subject: [PATCH 31/55] Expected result for recurring.ical --- t/00-mock.t | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/t/00-mock.t b/t/00-mock.t index 5e9874c..017f514 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -98,6 +98,22 @@ my @tests = ( # https://github.com/bigpresh/ical-to-google-calendar/issues/6 ical_file => 'recurring.ical', expect_entries => [ + { + all_day => 0, + location => '', + rrule => '', + status => undef, + title => 'Test event', + when => '2015-11-18T02:00:00 => 2015-11-18T03:00:00' + }, + { + all_day => 0, + location => '', + rrule => 'FREQ=WEEKLY;INTERVAL=1;UNTIL=20150207T065959Z;BYDAY=SA', + status => undef, + title => 'Tuesday SwingTime2', + when => '2015-02-04T02:00:00 => 2015-02-04T05:00:00' + }, ], }, { From 9d6f629cc97938c6294bb1164950aa0f86f71f13 Mon Sep 17 00:00:00 2001 From: David Precious Date: Wed, 5 Nov 2014 23:37:28 +0000 Subject: [PATCH 32/55] Remove left-over debuggery --- lib/App/ICalToGCal.pm | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index 7e9f741..731b67b 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -308,8 +308,6 @@ sub get_ical_field { $value = $dtend; } - - warn "returning value '$value' for $field"; return $value; } From cdfb9d9e3d6a9a2904b16033ecc6795d479f46de Mon Sep 17 00:00:00 2001 From: David Precious Date: Wed, 5 Nov 2014 23:37:44 +0000 Subject: [PATCH 33/55] More extraneous debug info removed --- lib/App/ICalToGCal.pm | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index 731b67b..29511d9 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -304,7 +304,6 @@ sub get_ical_field { map { $_ => $elements->{$_} } grep { $elements->{$_} } keys %$elements ); - warn "Calculated end $dtend from $dtstart + $duration"; $value = $dtend; } From 7da010a92b4b96b6c276f3c6c8af47d2d2fce184 Mon Sep 17 00:00:00 2001 From: David Precious Date: Wed, 5 Nov 2014 23:38:27 +0000 Subject: [PATCH 34/55] And more debuggery to go --- lib/App/ICalToGCal.pm | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index 29511d9..239e73e 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -282,7 +282,6 @@ sub get_ical_field { my $dt = DateTime::Format::ISO8601->parse_datetime($value) or die "Failed to parse '$value' into a DateTime object!"; my $tz = $properties->{$field}[0]{'_parameters'}{TZID}; - warn "Timezone for $field is '$tz'"; # TODO: if we didn't find a timezone, should we bail, or just leave the # DT object in the flatong timezone and hope for the best? if ($tz) { From 46e2af17ff34be41632ccab7427edd12d3788832 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 6 Nov 2014 00:26:25 +0000 Subject: [PATCH 35/55] Replace recurring.ical test data --- t/ical-data/recurring.ical | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/t/ical-data/recurring.ical b/t/ical-data/recurring.ical index e02aa88..7524672 100644 --- a/t/ical-data/recurring.ical +++ b/t/ical-data/recurring.ical @@ -1,15 +1,22 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Testing//Test//EN BEGIN:VEVENT -UID:5066@domain.com -ORGANIZER;CN="Max Mustermann":MAILTO:m.mustermann@domain.de -ATTENDEE;CN="Jeder";RSVP=FALSE;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:MAILTO:info@topackt.de -RRULE:FREQ=WEEKLY;INTERVAL=1 -SUMMARY;CHARSET=UTF-8:Teamsitzung (Serientermin) -DESCRIPTION;CHARSET=UTF-8: -LOCATION;CHARSET=UTF-8:A Place, -a street 2, -plz Town -CLASS:PUBLIC -CATEGORIES;CHARSET=UTF-8:Intern -DTSTART;TZID=Europe/Berlin:20130902T090000 -DTEND;TZID=Europe/Berlin:20130902T103000 +DTSTART;TZID=America/New_York:20151117T210000 +STATUS:CONFIRMED +UID:D5EEE785-3516-11D9-8A43-000D93C45D90 +DTSTAMP:20151113T220701Z +RECURRENCE-ID;TZID=America/New_York:20151117T210000 +DURATION:PT1H +SUMMARY:Test event END:VEVENT +BEGIN:VEVENT +DURATION:PT3H +EXDATE;TZID=US/Mountain:20150203T190000 +UID:38EEC36E-2D8C-11D8-AB19-00306583A102-RID +STATUS:CONFIRMED +DTSTART;TZID=US/Mountain:20150203T190000 +SUMMARY:Tuesday SwingTime2 +RRULE:FREQ=WEEKLY;INTERVAL=1;UNTIL=20150207T065959Z;BYDAY=SA +END:VEVENT +END:VCALENDAR From 1d957b57b31e701cf0036b183f8fed2aa0f988ec Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 6 Nov 2014 00:49:37 +0000 Subject: [PATCH 36/55] Bodgy-hack: force Test::Differences to behave. Test::Differences, given a hash of scalars, uses the stupid, nasty, unhelpful flatten style, which is SHIT. See: https://rt.cpan.org/Public/Bug/Display.html?id=95446 I may have tie @DrHyde's beard to a chair tomorrow and refuse to release him unless he fixes that nastyness so this horrific bodge can be reverted. --- t/00-mock.t | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/t/00-mock.t b/t/00-mock.t index 017f514..457e99c 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -141,9 +141,12 @@ for my $test_spec (@tests) { $mock_gcal, $ical_data, App::ICalToGCal->hash_ical_url($ical_file) ); + # The fucktardery adding badgers to each event in the expect_entries is to + # force Test::Differences not to use the unhelpful flatten style: + # https://rt.cpan.org/Public/Bug/Display.html?id=95446 eq_or_diff( summarise_events([ $mock_gcal->get_events() ]), - $test_spec->{expect_entries}, + [ map { +{ %$_, badgers => [] } } @{ $test_spec->{expect_entries} } ], "Entries for $test_spec->{ical_file} look correct", ); @@ -176,7 +179,11 @@ sub summarise_events { all_day => $all_day, ( rrule => $_->recurrence ? $_->recurrence->entries->[0]->properties->{rrule}[0]->value - : '' ) + : '' ), + + # HACK to make Test::Differences not use the unhelpful flatten + # style + badgers => [], } } @$events ]; From 6bb691cab92704df4c5e2205a6b92ac63078ea4a Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 6 Nov 2014 00:54:59 +0000 Subject: [PATCH 37/55] Update expectation for test1.ical --- t/00-mock.t | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/t/00-mock.t b/t/00-mock.t index 457e99c..fa43b47 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -74,17 +74,19 @@ my @tests = ( expect_entries => [ { all_day => 0, - location => "Home", + location => "San Francisco", + rrule => "", status => undef, - title => "Set up File Server", - when => "2015-06-15T17:00:00 => 2015-06-15T18:00:00", + title => "Apple WWDC", + when => "2015-06-08T23:00:00 => 2015-06-09T23:00:00", }, { all_day => 0, - location => "San Francisco", + location => "Home", + rrule => "", status => undef, - title => "Apple WWDC", - when => "2015-06-08T23:00:00 => 2015-06-09T23:00:00", + title => "Set up File Server", + when => "2015-06-15T17:00:00 => 2015-06-15T18:00:00", }, ] }, From 76c9c07daaa4681ca15f84137239c81f64c42761 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 6 Nov 2014 01:12:52 +0000 Subject: [PATCH 38/55] Don't change time when setting timezone. We want to return DateTime objects using the timezone from the iCal feed, but we don't want to call set_time_zone() to set the timezone as I was, because that changes the time represented by the object too. This was is a bit clumsy but works. FIXME: it seems to be dog-slow - probably because it's calculating loads of DST changes etc. May have to revert this and implement my own parsing of the ISO8601 datetimes, and just create DT objects with the right datetime and timezone set? Or maybe Data::ICal has something useful. --- lib/App/ICalToGCal.pm | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index 239e73e..eedb854 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -279,14 +279,22 @@ sub get_ical_field { # Auto-inflate dtstart/dtend properties into DateTime objects if ($field =~ /^dt(start|end)$/ && $value) { - my $dt = DateTime::Format::ISO8601->parse_datetime($value) - or die "Failed to parse '$value' into a DateTime object!"; - my $tz = $properties->{$field}[0]{'_parameters'}{TZID}; + # Get a DateTime object and set the timezone on it, so that + # DateTime::Format::ISO8601 can use that to copy the timezone; if we + # just set it with set_time_zone() afterwards, the local time will + # change! + my $dt_base = DateTime->now; # TODO: if we didn't find a timezone, should we bail, or just leave the # DT object in the flatong timezone and hope for the best? + my $tz = $properties->{$field}[0]{'_parameters'}{TZID}; if ($tz) { - $dt->set_time_zone($properties->{$field}[0]{'_parameters'}{TZID}); + $dt_base->set_time_zone($properties->{$field}[0]{'_parameters'}{TZID}); } + + my $dt_parser = DateTime::Format::ISO8601->new; + $dt_parser->set_base_datetime( object => $dt_base ); + my $dt = $dt_parser->parse_datetime($value) + or die "Failed to parse '$value' into a DateTime object!"; $value = $dt; } From e80d75f5ce7819e08620396b0678be84144d5672 Mon Sep 17 00:00:00 2001 From: David Precious Date: Sun, 9 Nov 2014 00:19:41 +0000 Subject: [PATCH 39/55] no -T on shebang --- t/00-mock.t | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/t/00-mock.t b/t/00-mock.t index fa43b47..f657f9e 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -1,4 +1,4 @@ -#!perl -T +#!perl use 5.006; use strict; use warnings FATAL => 'all'; @@ -78,7 +78,7 @@ my @tests = ( rrule => "", status => undef, title => "Apple WWDC", - when => "2015-06-08T23:00:00 => 2015-06-09T23:00:00", + when => "2015-06-06T00:00:00 => 2015-06-12T00:00:00", }, { all_day => 0, @@ -86,7 +86,7 @@ my @tests = ( rrule => "", status => undef, title => "Set up File Server", - when => "2015-06-15T17:00:00 => 2015-06-15T18:00:00", + when => "2015-06-15T18:00:00 => 2015-06-15T19:00:00", }, ] }, From 268a4e5bd8fa1f845ad4b20acf552e246fdf6755 Mon Sep 17 00:00:00 2001 From: David Precious Date: Sun, 9 Nov 2014 12:55:44 +0000 Subject: [PATCH 40/55] Set timezone in base object at creation. The previous approach, calling set_time_zone(), was *HORRIBLY* slow due to all the daylight savings time calculations DT had to perform. It was leading to results like: ``` ``` ... because of all the objects DateTime was flinging about. This way works almost instantaneously. Tests still fail because of timezone fun - it would appear that the resulting timestamp we're seeing is that of the local timezone - may need to fake that in the tests so that we don't fail if the user running the tests is in a different timezone! --- lib/App/ICalToGCal.pm | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index eedb854..c9d0311 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -264,6 +264,9 @@ sub ical_event_to_gcal_event { } # Wrap the nastyness of Data::ICal::Property stuff away +# Cache the timezone-specific "base" objects we'll parse to +# DateTime::Format::ISO8601->set_base_datetime for performance +my %cached_base_dt; sub get_ical_field { my ($ical_event, $field) = @_; my $value; @@ -283,16 +286,24 @@ sub get_ical_field { # DateTime::Format::ISO8601 can use that to copy the timezone; if we # just set it with set_time_zone() afterwards, the local time will # change! - my $dt_base = DateTime->now; + my $dt_parser = DateTime::Format::ISO8601->new; + # TODO: if we didn't find a timezone, should we bail, or just leave the # DT object in the flatong timezone and hope for the best? my $tz = $properties->{$field}[0]{'_parameters'}{TZID}; if ($tz) { - $dt_base->set_time_zone($properties->{$field}[0]{'_parameters'}{TZID}); + if (!$cached_base_dt{$tz}) { + # The date represented here is unimportant; the timezone is what + # matters. + my $dt_base = DateTime->new( + year => 2015, + month => 1, + day => 1, + time_zone => $tz, + ); + $cached_base_dt{$tz} = $dt_base; + } } - - my $dt_parser = DateTime::Format::ISO8601->new; - $dt_parser->set_base_datetime( object => $dt_base ); my $dt = $dt_parser->parse_datetime($value) or die "Failed to parse '$value' into a DateTime object!"; $value = $dt; From ad380a75ca61d5a74b9702ebb3f6afe8de88825d Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 27 Nov 2014 10:35:19 +0000 Subject: [PATCH 41/55] Revert "Bodgy-hack: force Test::Differences to behave." This reverts commit 1d957b57b31e701cf0036b183f8fed2aa0f988ec. --- t/00-mock.t | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/t/00-mock.t b/t/00-mock.t index f657f9e..f3318d0 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -143,12 +143,9 @@ for my $test_spec (@tests) { $mock_gcal, $ical_data, App::ICalToGCal->hash_ical_url($ical_file) ); - # The fucktardery adding badgers to each event in the expect_entries is to - # force Test::Differences not to use the unhelpful flatten style: - # https://rt.cpan.org/Public/Bug/Display.html?id=95446 eq_or_diff( summarise_events([ $mock_gcal->get_events() ]), - [ map { +{ %$_, badgers => [] } } @{ $test_spec->{expect_entries} } ], + $test_spec->{expect_entries}, "Entries for $test_spec->{ical_file} look correct", ); @@ -181,11 +178,7 @@ sub summarise_events { all_day => $all_day, ( rrule => $_->recurrence ? $_->recurrence->entries->[0]->properties->{rrule}[0]->value - : '' ), - - # HACK to make Test::Differences not use the unhelpful flatten - # style - badgers => [], + : '' ) } } @$events ]; From a62e9b4770bf5b7bc9166ca889779ef5312b22b1 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 27 Nov 2014 10:35:57 +0000 Subject: [PATCH 42/55] Add tests to MANIFEST --- MANIFEST | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MANIFEST b/MANIFEST index 49669cf..d430739 100644 --- a/MANIFEST +++ b/MANIFEST @@ -4,3 +4,7 @@ Makefile.PL MANIFEST README bin/ical-to-gcal +t/00-mock.t +t/ical-data/allday.ical +t/ical-data/recurring2.ical +t/ical-data/test1.ical From 0c90e5f1436222cc0116e0912bc2cdce480dac63 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 21:13:33 +0000 Subject: [PATCH 43/55] New test - parse another iCal without clearing object. This one tests that we can parse multiple iCal feeds onto a single Google Calendar without clobbering what was there. In other words, check that this big refactoring, which came about as a result of Issue #8, actually does fix Issue #8. --- t/00-mock.t | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/t/00-mock.t b/t/00-mock.t index f3318d0..c7dd4db 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -4,7 +4,7 @@ use strict; use warnings FATAL => 'all'; use Cwd; use File::Spec; -use Test::Differences; +use Test::Differences::Color; use Test::MockObject; use Test::More; @@ -118,10 +118,50 @@ my @tests = ( }, ], }, + + # Now, process test1.ical again, but this time, don't clear the mock gcal + # object, so we can check the events from the previous test are still + # present in addition to the events from test1.ical (i.e. test that we can + # process multiple iCal feeds and have the events all end up on the same + # Google Calendar...) { - ical_file => 'recurring2.ical', - expect_entries => [ ], - } + keep_previous => 1, + ical_file => 'test1.ical', + expect_entries => [ + { + all_day => 0, + location => '', + rrule => '', + status => undef, + title => 'Test event', + when => '2015-11-18T02:00:00 => 2015-11-18T03:00:00' + }, + { + all_day => 0, + location => '', + rrule => 'FREQ=WEEKLY;INTERVAL=1;UNTIL=20150207T065959Z;BYDAY=SA', + status => undef, + title => 'Tuesday SwingTime2', + when => '2015-02-04T02:00:00 => 2015-02-04T05:00:00' + }, + { + all_day => 0, + location => "San Francisco", + rrule => "", + status => undef, + title => "Apple WWDC", + when => "2015-06-06T00:00:00 => 2015-06-12T00:00:00", + }, + { + all_day => 0, + location => "Home", + rrule => "", + status => undef, + title => "Set up File Server", + when => "2015-06-15T18:00:00 => 2015-06-15T19:00:00", + }, + ] + }, ); for my $test_spec (@tests) { From 0e0e41fdf255a016234dfbaacb230ebe35dafd5c Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 21:17:30 +0000 Subject: [PATCH 44/55] Add expected output from allday.ical --- t/00-mock.t | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/t/00-mock.t b/t/00-mock.t index c7dd4db..c125ac2 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -93,6 +93,14 @@ my @tests = ( { ical_file => 'allday.ical', expect_entries => [ + { + all_day => 0, + location => '', + rrule => '', + status => undef, + title => 'all day', + when => '2004-11-15T00:00:00 => 2004-11-16T00:00:00' + }, ], }, { From 1ba78a1cff18667a3ce895c495c446330289ade8 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 21:20:50 +0000 Subject: [PATCH 45/55] fix typo in comment --- lib/App/ICalToGCal.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index c9d0311..de5fd83 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -264,7 +264,7 @@ sub ical_event_to_gcal_event { } # Wrap the nastyness of Data::ICal::Property stuff away -# Cache the timezone-specific "base" objects we'll parse to +# Cache the timezone-specific "base" objects we'll pass to # DateTime::Format::ISO8601->set_base_datetime for performance my %cached_base_dt; sub get_ical_field { From a09eb1968cea3058d501f2911faeb15c645edd55 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 21:21:43 +0000 Subject: [PATCH 46/55] Another comment typo --- lib/App/ICalToGCal.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index de5fd83..3cb529a 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -289,7 +289,7 @@ sub get_ical_field { my $dt_parser = DateTime::Format::ISO8601->new; # TODO: if we didn't find a timezone, should we bail, or just leave the - # DT object in the flatong timezone and hope for the best? + # DT object in the floating timezone and hope for the best? my $tz = $properties->{$field}[0]{'_parameters'}{TZID}; if ($tz) { if (!$cached_base_dt{$tz}) { From b686718a27b114d20047df2f752c6f0c53bc3581 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 22:18:10 +0000 Subject: [PATCH 47/55] Test with a non-DST-timezone. When we pass DateTime objects to Net::Google::Calendar::Entry's when() method, it automatically converts them to UTC; we want to test that the result is correct. Using a timezone that doesn't observe DST means the result will always be the same, rather than us failing our tests in the summer/winter :) --- t/00-mock.t | 2 +- t/ical-data/test1.ical | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/t/00-mock.t b/t/00-mock.t index c125ac2..43c9701 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -166,7 +166,7 @@ my @tests = ( rrule => "", status => undef, title => "Set up File Server", - when => "2015-06-15T18:00:00 => 2015-06-15T19:00:00", + when => "2015-06-15T21:00:00 => 2015-06-15T22:00:00", }, ] }, diff --git a/t/ical-data/test1.ical b/t/ical-data/test1.ical index 588849f..f113c68 100644 --- a/t/ical-data/test1.ical +++ b/t/ical-data/test1.ical @@ -19,7 +19,7 @@ LOCATION:Home DTSTAMP:20150618T151543Z UID:5F88A0EC-AD21-428E-AAAD-005F1B1AB72E SEQUENCE:6 -DTSTART;TZID=America/Chicago:20150615T180000 +DTSTART;TZID=Canada/Saskatchewan:20150615T150000 SUMMARY:Set up File Server DESCRIPTION:Music server for the kids. END:VEVENT From 0c73ad614bb35260480cf77f3302a8fcab2d7368 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 22:20:26 +0000 Subject: [PATCH 48/55] Missed updating this datetime from last commit --- t/00-mock.t | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/00-mock.t b/t/00-mock.t index 43c9701..043330c 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -86,7 +86,7 @@ my @tests = ( rrule => "", status => undef, title => "Set up File Server", - when => "2015-06-15T18:00:00 => 2015-06-15T19:00:00", + when => "2015-06-15T21:00:00 => 2015-06-15T22:00:00", }, ] }, From 76612b7d97217edd07d062105c09677c68590931 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 22:26:58 +0000 Subject: [PATCH 49/55] Test titles to make output a bit clearer --- t/00-mock.t | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/t/00-mock.t b/t/00-mock.t index 043330c..edcc7f5 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -70,6 +70,7 @@ is( # and expect it to work. my @tests = ( { + test_title => "Basic tests", ical_file => 'test1.ical', expect_entries => [ { @@ -91,6 +92,7 @@ my @tests = ( ] }, { + test_title => "One all-day event", ical_file => 'allday.ical', expect_entries => [ { @@ -106,6 +108,7 @@ my @tests = ( { # This test is based on Issue 6: # https://github.com/bigpresh/ical-to-google-calendar/issues/6 + test_title => "Recurring events (Issue #6)", ical_file => 'recurring.ical', expect_entries => [ { @@ -135,6 +138,7 @@ my @tests = ( { keep_previous => 1, ical_file => 'test1.ical', + test_title => 'Adding items from another feed to previous', expect_entries => [ { all_day => 0, @@ -173,6 +177,7 @@ my @tests = ( ); for my $test_spec (@tests) { + diag($test_spec->{test_title}) if $test_spec->{test_title}; my $ical_file = File::Spec->catfile( Cwd::cwd(), 't', 'ical-data', $test_spec->{ical_file} ); @@ -185,7 +190,10 @@ for my $test_spec (@tests) { } my $ical_data = App::ICalToGCal->fetch_ical("file://$ical_file"); - ok(ref $ical_data, "Got a parsed iCal result from $ical_file"); + ok( + ref $ical_data, + "Got a parsed iCal result from $test_spec->{ical_file}" + ); App::ICalToGCal->update_google_calendar( $mock_gcal, $ical_data, App::ICalToGCal->hash_ical_url($ical_file) From 0c02c91f987526d8e703fb874fdbd5f7937bdb9d Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 22:36:13 +0000 Subject: [PATCH 50/55] Set the all-day flag if start & end are 00:00:00 This is a bit hackish perhaps; might be better to fetch the raw dtstart/dtend and inspect for absence of a time component. --- lib/App/ICalToGCal.pm | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index 3cb529a..a41991a 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -6,7 +6,7 @@ use strict; use Data::ICal; use Date::ICal::Duration; use DateTime; -use DateTime::Format::ISO8601; +use DateTime::Format::DateParse; use Net::Google::Calendar; use Net::Google::Calendar::Entry; use Net::Netrc; @@ -245,9 +245,18 @@ sub ical_event_to_gcal_event { my $ical_uid = get_ical_field($ical_event, 'uid'); $gcal_event->title( get_ical_field($ical_event, 'summary' ) ); $gcal_event->location( get_ical_field($ical_event, 'location' ) ); + # Set the all-day flag if the start datetime and end datetime are both + # 00:00:00 + my @midnights = grep { + $_->hms eq '00:00:00' + } map { + warn "getting $_"; get_ical_field($ical_event, $_) + } (qw(dtstart dtend)); + my $all_day = @midnights == 2 ? 1 : 0; $gcal_event->when( get_ical_field($ical_event, 'dtstart'), get_ical_field($ical_event, 'dtend'), + $all_day, ); # If there's a recurrence rule, handle it: @@ -264,9 +273,6 @@ sub ical_event_to_gcal_event { } # Wrap the nastyness of Data::ICal::Property stuff away -# Cache the timezone-specific "base" objects we'll pass to -# DateTime::Format::ISO8601->set_base_datetime for performance -my %cached_base_dt; sub get_ical_field { my ($ical_event, $field) = @_; my $value; @@ -282,29 +288,11 @@ sub get_ical_field { # Auto-inflate dtstart/dtend properties into DateTime objects if ($field =~ /^dt(start|end)$/ && $value) { - # Get a DateTime object and set the timezone on it, so that - # DateTime::Format::ISO8601 can use that to copy the timezone; if we - # just set it with set_time_zone() afterwards, the local time will - # change! - my $dt_parser = DateTime::Format::ISO8601->new; # TODO: if we didn't find a timezone, should we bail, or just leave the # DT object in the floating timezone and hope for the best? my $tz = $properties->{$field}[0]{'_parameters'}{TZID}; - if ($tz) { - if (!$cached_base_dt{$tz}) { - # The date represented here is unimportant; the timezone is what - # matters. - my $dt_base = DateTime->new( - year => 2015, - month => 1, - day => 1, - time_zone => $tz, - ); - $cached_base_dt{$tz} = $dt_base; - } - } - my $dt = $dt_parser->parse_datetime($value) + my $dt = DateTime::Format::DateParse->parse_datetime($value, $tz) or die "Failed to parse '$value' into a DateTime object!"; $value = $dt; } From bf47f4aab62d8f3be1458bed41ae272a0da1cf61 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 22:37:46 +0000 Subject: [PATCH 51/55] Expect the all_day flag, now it works :) --- t/00-mock.t | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/t/00-mock.t b/t/00-mock.t index edcc7f5..618a242 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -74,7 +74,7 @@ my @tests = ( ical_file => 'test1.ical', expect_entries => [ { - all_day => 0, + all_day => 1, location => "San Francisco", rrule => "", status => undef, @@ -96,7 +96,7 @@ my @tests = ( ical_file => 'allday.ical', expect_entries => [ { - all_day => 0, + all_day => 1, location => '', rrule => '', status => undef, @@ -157,7 +157,7 @@ my @tests = ( when => '2015-02-04T02:00:00 => 2015-02-04T05:00:00' }, { - all_day => 0, + all_day => 1, location => "San Francisco", rrule => "", status => undef, From 7f0386a75d667d20e8bc4daa30e5793beb26847f Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 22:38:28 +0000 Subject: [PATCH 52/55] Add DateTime::Format::DateParse as a prereq. Swapping DateTime::Format::ISO8601 for DateTime::Format::DateParse. Nod to Pizentios on Freenode/#perl for the recommendation. --- Makefile.PL | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile.PL b/Makefile.PL index 00721d1..32eefc5 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -26,7 +26,7 @@ WriteMakefile( 'Data::ICal' => 0, 'Date::ICal::Duration' => 0, 'DateTime' => 0, - 'DateTime::Format::ISO8601' => 0, + 'DateTime::Format::DateParse' => 0, 'Digest::MD5' => 0, 'Scalar::Util' => 0, }, From ad409261d57de55173b8d161b3649841f2bbc41e Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 23:11:39 +0000 Subject: [PATCH 53/55] Compare event content. Want to be able to test that the [ical_imported_uid] tag is being set properly. FIXME: doesn't actually work - we get back some encoded nastyness instead. --- t/00-mock.t | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/t/00-mock.t b/t/00-mock.t index 618a242..ebb7e1d 100644 --- a/t/00-mock.t +++ b/t/00-mock.t @@ -234,7 +234,9 @@ sub summarise_events { all_day => $all_day, ( rrule => $_->recurrence ? $_->recurrence->entries->[0]->properties->{rrule}[0]->value - : '' ) + : '' + ), + content => $entry->content->body, } } @$events ]; From 0085641149bebd7119b925dae77421ec4ae75ad6 Mon Sep 17 00:00:00 2001 From: David Precious Date: Thu, 4 Dec 2014 23:12:58 +0000 Subject: [PATCH 54/55] Doc improvements --- lib/App/ICalToGCal.pm | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/App/ICalToGCal.pm b/lib/App/ICalToGCal.pm index a41991a..106c13b 100644 --- a/lib/App/ICalToGCal.pm +++ b/lib/App/ICalToGCal.pm @@ -21,7 +21,8 @@ App::ICalToGCal - import iCal feeds into a Google Calendar =head1 DESCRIPTION A command line script to fetch an iCal calendar feed, and create corresponding -events in a Google Calendar. +events in a Google Calendar (with most of the code in a module, in case it's +useful to use from other scripts). =head1 WHY? @@ -34,7 +35,8 @@ best). Calendaring is a time-sensitive thing; I don't want to wait a full day for updates to take effect (by the time Google re-fetch the feed and update your -calendar, it could be too late!). +calendar, it could be too late!) - I'd rather push the changes to my Google +Calendar as soon as I see them from the source iCal feeds. =head1 SYNOPSIS @@ -58,6 +60,8 @@ should contain: Of course, you'll want to ensure that file is protected (kept readable by you only, etc). +TODO: I may make it possible to supply credentials via other methods if there's +demand for it. =head1 CLASS METHODS @@ -96,7 +100,7 @@ sub select_google_calendar { =head2 fetch_ical -Given an iCal feed URL, fetches it, parses it using L, and returns +Given an iCal feed URL, fetches it, parses it using L, and returns the result. =cut @@ -267,7 +271,7 @@ sub ical_event_to_gcal_event { $gcal_event->recurrence($ical_event); } - $gcal_event->content("[ical_imported_uid:$feed_url_hash/$ical_uid]"); + $gcal_event->content("Fuck it: [ical_imported_uid:$feed_url_hash/$ical_uid]"); return $gcal_event; } From f846c1fd8ce359e554bc2e6b31cae7a2003bc3eb Mon Sep 17 00:00:00 2001 From: David Precious Date: Wed, 27 Feb 2019 11:34:51 +0000 Subject: [PATCH 55/55] Add a notice that this won't work and how to fix. I've decided to leave the code here for now, as fixing it might not be too difficult, I just don't have the time/motivation to do it at present as I no longer use it, but someone might want to contribute the required changes, or buy me a few beers to do it, whatever. --- README.pod | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.pod b/README.pod index 684c22c..7ffa93f 100644 --- a/README.pod +++ b/README.pod @@ -1,5 +1,18 @@ =head1 ical-to-google-calendar + +=head1 NOTICE + +At present, this script WILL NOT WORK, as it relies on the CPAN module +L which +(at time of writing) uses an older API version which Google no longer support. + +To get this working, either Net::Google::Calendar needs updating to use their +newer API, or this script needs to be switched to using +L but +I don't think it's a straightforward drop-in replacement, and I haven't had time +to do it (I no longer use this script myself. However, pull requests welcome!) + =head1 DESCRIPTION A simple Perl script to parse an iCal-format (.ics) file, and update a Google