PerlStalker’s SysAdmin Notes

Notes from the life of a systems administrator

Importing iCal Into Org-mode

I’ve been using emacs and org-mode for some time to manage my tasks. Org-mode has a great feature which shows and agenda view which includes upcoming scheduled items and deadlines. One of the things that was missing was the ability to view my calendar (which is in Google Calendar) in the agenda.

There are a couple of ways of dealing the syncing the calendar data. One of the ways I tried was org-caldav. It kind of worked. Sort of. It did import the caledar but it failed spectaculary with repeating tasks set in Google by me or others. Since most of the things on my calendar are repeating events, this was a problem.

Alright, so org-caldav didn’t work for me. I could have looked for something else that did two-way sync but, in the end, it wasn’t that important to me. So, I worked up a way to do pull the iCal feed from Google and convert it into an org-mode file.

The first is a pretty simple script that pulls down the iCal files and pumps it through the translation script. I run this from cron every ten minutes.

fetch-calendars.pl (fetch-calendars.pl) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/usr/bin/env perl
use warnings;
use strict;

my $debug = 0;

my $wget = "/usr/bin/wget";
my $ical2org = "$ENV{HOME}/bin/ical2org.pl";

my $base_dir = "$ENV{HOME}/.calendars";
my $org_dir  = "$ENV{HOME}/org/calendars";

my %calendars = (
    'home' => 'http://www.google.com/calendar/ical/...',
    'work' => 'https://www.google.com/calendar/ical/...',
    );

chdir $base_dir;
# acad and ooo don't work
my @cals = qw(home work);

foreach my $cal (@cals) {
    next unless $calendars{$cal};

    my $cmd = "$wget -q -O $cal.ics.new $calendars{$cal} && mv $cal.ics.new $cal.ics";
    print STDERR "$cmd\n" if $debug;
    system $cmd;

    next unless -r "$cal.ics";

    $cmd = "$ical2org -c $cal < $base_dir/$cal.ics > $org_dir/$cal.org.new";
    print STDERR "$cmd\n" if $debug;
    system $cmd;

    if ( -s "$org_dir/$cal.org.new" ) {
  $cmd = "cp $org_dir/$cal.org.new $org_dir/$cal.org";
  print STDERR "$cmd\n" if $debug;
  system $cmd;
    }
}

The fun part is in ical2org.pl.

ical2org.pl (ical2org.pl) download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
#!/usr/bin/env perl
use warnings;
use strict;

use Data::ICal;
use Data::Dumper;
use DateTime::Format::ICal;

use Getopt::Long;
my $category = 'ical';

# Only sync events newer than this many weeks in the past.
# Set to 0 to sync all past events.
my $syncweeksback = 2;

GetOptions(
    'category|c=s' => \$category
);

my $cal = Data::ICal->new(data => join '', <STDIN>);

#print Dumper $cal;
my %gprops = %{ $cal->properties };

print "#+TITLE: ical entries\n";
print "#+AUTHOR: ".$gprops{'x-wr-calname'}[0]->decoded_value."\n";
print "#+EMAIL: \n";
print "#+DESCRIPTION: Converted using ical2org.pl\n";
print "#+CATEGORY: $category\n";
print "#+STARTUP: overview\n";
print "\n";

print "* COMMENT original iCal properties\n";
#print Dumper \%gprops;
print "Timezone: ", $gprops{'x-wr-timezone'}[0]->value, "\n";

foreach my $prop (values %gprops) {
    foreach my $p (@{ $prop }) {
  print $p->key, ':', $p->value, "\n";
    }
}

foreach my $entry (@{ $cal->entries }) {
    next if not $entry->isa('Data::ICal::Entry::Event');
    #print 'Entry: ', Dumper $entry;

    my %props = %{ $entry->properties };

    # skip entries with no start or end time
    next if (not $props{dtstart}[0] or not $props{dtend}[0]);

    my $dtstart = DateTime::Format::ICal->parse_datetime($props{dtstart}[0]->value);
    my $dtend   = DateTime::Format::ICal->parse_datetime($props{dtend}[0]->value);
    # Perhaps only sync some weeks back
    next if ($syncweeksback != 0
       and $dtend < DateTime->now->subtract(weeks => $syncweeksback)
       and !defined $props{rrule});

    my $duration = $dtend->subtract_datetime($dtstart);

    if (defined $props{rrule}) {
  #print "  REPEATABLE\n";
  # Bad: There may be multiple rrules but I'm ignoring them
  my $set = DateTime::Format::ICal->parse_recurrence(
      recurrence => $props{rrule}[0]->value,
      dtstart    => $dtstart,
      dtend      => DateTime->now->add(weeks => 1),
  );

  my $itr = $set->iterator;
  while (my $dt = $itr->next) {
      $dt->set_time_zone(
      $props{dtstart}[0]->parameters->{'TZID'} ||
      $gprops{'x-wr-timezone'}[0]->value
      );

      my $end = $dt->clone->add_duration($duration);
      next if ( $end < DateTime->now->subtract(weeks => $syncweeksback) );
  
      print "* ".$props{summary}[0]->decoded_value."\n";
      print '  ', org_date_range($dt, $end), "\n";
      #print $dt, "\n";
      print  "  :PROPERTIES:\n";
      printf "  :ID: %s\n", $props{uid}[0]->value;

      if (defined $props{location}) {
      printf "  :LOCATION: %s\n", $props{location}[0]->value;
      }

      if (defined $props{status}) {
      printf "  :STATUS: %s\n", $props{status}[0]->value;
      }

      print "  :END:\n";

      if ($props{description}) {
      print "\n", $props{description}[0]->decoded_value, "\n";
      }
  }
    }
    else {

  print "* ".$props{summary}[0]->decoded_value."\n";

  my $tz = $gprops{'x-wr-timezone'}[0]->value;
  $dtstart->set_time_zone($props{dtstart}[0]->parameters->{'TZID'} || $tz);
  $dtend->set_time_zone($props{dtend}[0]->parameters->{'TZID'} || $tz);

  print '  ', org_date_range($dtstart, $dtend), "\n";

  print  "  :PROPERTIES:\n";
  printf "  :ID: %s\n", $props{uid}[0]->value;

  if (defined $props{location}) {
      printf "  :LOCATION: %s\n", $props{location}[0]->value;
  }

  if (defined $props{status}) {
      printf "  :STATUS: %s\n", $props{status}[0]->value;
  }

  print "  :END:\n";

  if ($props{description}) {
      print "\n", $props{description}[0]->decoded_value, "\n";
  }

    }

#    print Dumper \%props;
}

sub org_date_range {
    my $start = shift;
    my $end = shift;

    my $str = sprintf('<%04d-%02d-%02d %s %02d:%02d>',
     $start->year,
     $start->month,
     $start->day,
     $start->day_abbr,
     $start->hour,
     $start->minute
       );
    $str .= '--';
    $str .= sprintf('<%04d-%02d-%02d %s %02d:%02d>',
     $end->year,
     $end->month,
     $end->day,
     $end->day_abbr,
     $end->hour,
     $end->minute
       );

    return $str;
}

I let Data::ICal parse the feed and DataTime::Format::ICal do the heavy lifting of parsing the date and time information from each entry. (Have I mentioned how cool the CPAN is?)

Most of the code is just reformating the iCal entry into org-mode syntax so that emacs can pull it into the agenda.

There’s one bit of magic I’m not showing here. In my .emacs config, I have this little gem.

1
(add-hook 'org-mode-hook 'auto-revert-mode)

That tells emacs to automatically revert (reload) any org-mode file that changes on disk while the buffer is open. Since I drop the converted files in a directory scanned by org-mode, emacs opens each converted calendar file in a buffer when the agenda view is first run. When the files are updated by the scripts above, emacs sees the changes and reverts the buffers. Anytime I regenerate the agenda view, emacs uses the updated buffers and the view is up-to-date.

Once again, this is a one way sync. I can’t edit the generated org-mode files and see the changes reflected in the Google calendars. If I want to make changes to my calendar, I have to do it through Google’s web interface. This actually works out for the best because Google provides all of the scheduling hooks to make sure others who I’ve invited to meetings can attend. I can’t get that, easily, in emacs.

So, there you go. A relatively pain free way to pull any iCal calendar into emacs.

Update 2016-01-21: I’ve incorporated a suggestion from Anders Johansson that prevents ical2org.pl from syncing old events. I set the default to two weeks in the past. To get the old behavior set $syncweeksback to 0.

Update 2016-02-03: Thanks to gr4nchio for a fix for recurring events.

Comments