Zum Meeting bitte! (Linux-Magazin, Dezember 2010)

Ein Perl-Dämon liest ICalendar-Dateien mit Meeting-Terminen ein und alarmiert den User kurz vor deren Beginn.

Wer sich hartnäckig weigert, seine Kommunikation in der Arbeitswelt über Microsoft Exchange abzuwickeln und auch sonst keine GUI-schweren Kalenderapplikationen laufen hat, erhält Einladungen zu Meetings per Email in Form von .ics-Dateien. Diese im ICalendar-Format [2] verfassten maschinenlesbaren Textfiles beschreiben, an welchem Tag und zu welcher Uhrzeit das Meeting stattfindet, welches Thema dort erörtert wird und welche Anwesenden sich dort tummeln oder Gehör verschaffen. Sie defininieren auch den Turnus bei sich wiederholenden Meetings, die täglich um dieselbe Uhrzeit, oder wöchentlich an einem bestimmten Wochentag stattfinden.

GUI-schwer oder Perl-leicht?

Kalenderapplikationen aus dem Hause Gnome und KDE, Evolution, iCal auf dem Mac, Outlook auf Windows oder Google Calendar auf dem Web importieren diese .ics-Dateien, stellen Meetings in einer Übersicht farbenfroh dar (Abbildung 1) und lösen mit Dialogfenstern Alarm aus, falls sich ein Meeting seinem Anfang nähert und der User sich schnellstens auf den Weg zum Konferenzraum machen sollte.

Abbildung 1: Der Google-Kalender listet tägliche Standup-Meetings, ein wöchentliches 1:1-Meeting und einen Feiertag am Montag auf.

Umgekehrt erlaubt zum Beispiel der Google-Kalender den Export der dort hinterlegten Kalenderdaten als .ics-Datei. Dies öffnet die Tür zu selbstgestrickten Kalenderprogrammen, wie dem heute vorgestellten Perlskript ical-daemon, das eine Reihe von .ics-Dateien einliest, eine Alarmtabelle mit den bevorstehenden Meetings anlegt und 15 Minuten vor deren Beginn jeweils ein Skript ical-notify ausführt, das den User auf beliebige Art und Weise wachrüttelt. Email ist denkbar, oder eine Nachricht auf einem IM- oder IRC-Netzwerk, oder auch etwas ganz anderes wie zum Beispiel das Abspielen eines bestimmten Musikstücks.

Kalender exportiert

Um die Kalenderdaten vom Google-Server herunterzuladen, klickt der User im Google-Kalender unter Settings->Google Calendar Settings->Calendars den "Export"-Button und erhält ein zip-Archiv mit einer .ics-Datei (Abbildung 2 und 3).

Abbildung 2: Die Funktion "Export" holt die .ics-Datei des Google-Kalenders vom Server.

Abbildung 3: Die exportierte .ics-Datei liegt als .zip-Archiv vor.

Sieht man sich die .ics-Datei in Abbildung 4 genau an, zeigen sich darin zeilenweise Tags, von denen DTSTART den Meetingsbeginn und DESCRIPTION das Thema der Besprechung angeben. Es handelt sich um ein alle zwei Wochen jeweils am Mittwoch stattfindendes Meeting, das die Zeile

    RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=WE

festlegt. Daraus generiert die Kalenderapplikation dann Meeting-Events ab einem Startdatum (zum Beispiel der aktuellen Uhrzeit) bis zu einem Zeitpunkt in der Zukunft und kann dann zu diesen Terminen Aktionen wie zum Beispiel eine Benachrichtigung einleiten.

Abbildung 4: Die von Google Calendar als Kalender-Export produzierte .ics-Datei für ein zweiwöchentlich stattfindendes Meeting.

Feiertage als Kalender

Fällt ein Mittwoch jedoch auf einen Feiertag, entfällt das zweiwöchentliche Meeting mit dem Chef naturgemäß und Alarme sollten möglichst unterbleiben, um die wohlverdiente und deshalb heilige Feiertagsruhe des Arbeitnehmers nicht zu stören. Da Feiertage komplizierten Regeln folgen, bietet der Google-Server sie einfach ebenfalls als .ics-Datei an. Statt Meetings stehen dort über Jahre hinaus jeweils Ganztags-Ereignisse, falls der betreffende Tag auf einen Feiertag fällt. Da ich in den USA arbeite, gelten für mich die "US Holidays", für Deutschland wären unter "Other Calendars->Add->Browse Interesting Calendars" die "German Holidays" zu wählen. Drückt der User dort den "Subscribe"-Button, importiert der Kalender die deutschen Feiertage. Anschließend zeigt sich wie in Abbildung 5 unter "Other Calendars->Settings->German Holidays" im Feld "Calendar Address" ein Button mit der Aufschrift "ICAL", hinter dem sich die .ics-Datei zum kostenlosen Download verbirgt. Den Feiertagskalender (.ical-Datei in Abbildung 6) leitet der User dann an die jeweilige Kalenderapplikation (oder das heutige Skript) weiter, die die Feiertagsereignisse extra behandelt und zum Beispiel alle an diesen Tagen anberaumten Meetings ausblendet.

Abbildung 5: Die .ics-Datei mit den Feiertagen holt ein Klick auf den ICAL-Button vom Google-Server.

Abbildung 6: Alle US-Feiertage in einer .ics-Datei.

Kalender selbst gestrickt

Nach dem Start liest das Skript ical-daemon alle im Verzeichnis ~/.ics-daemon/ics liegenden .ics-Dateien ein und formt daraus mit Hilfe des CPAN-Moduls iCal::Parser eine Datenstruktur, die am aktuellen Tag bevorstehende Kalenderereignisse berechnet und nach der Uhrzeit im Array @TODAYS_EVENTS ordnet.

Zeile 3 in Listing 1 lädt das hier schon oft benutzte CPAN-Modul local::lib hinzu, das die Installation der weiter benötigten CPAN-Module unter dem Home-Verzeichnis des Users erlaubt, der deswegen weder Root-Rechte braucht noch die heilige Ordnung des Paket-Managers durcheinanderwirbelt.

Die Zeilen 22 und 23 legen die Logdatei und die Datei für die hinterlegte Prozess-ID, pid, fest. Zeile 32 initialisiert das Log4perl-Framework, das mittels DEBUG, INFO oder WARN geschickte Meldungen an die Logdatei anhängt. Das Modul App::Daemon und seine exportierte Funktion daemonize() sorgen dafür, dass das Skript die Kommandos ical-daemon start und ical-daemon stop versteht, die den Dämon herauf- und herunterfahren. Datumsberechnungen erledigt elegant das CPAN-Modul DateTime, das zum Beispiel das Zurücksetzen der Zeit in einem DateTime-Objekt $dt zum Anbeginn eines Tages einfach durch $dt->truncate( to => 'day' ) erledigt. DateTime überlädt auch Vergleichsoperatoren wie "<" und ">", sodass $dt1 > $dt2 genau dann wahr ist, falls der Zeitpunkt $dt1 nach dem Zeitpunkt $dt2 liegt. Die 15 Minuten Karenzzeit vor dem Meeting definiert Zeile 12 mit einem Objekt der Klasse DateTime::Duration und legt es in der globalen Variablen $ALERT_BEFORE ab. Zeile 71 zieht die Zeitspanne später vom Meetingszeitpunkt ab und prüft, ob die aktuelle Uhrzeit schon weiter fortgeschritten ist.

Listing 1: ical-daemon

    001 #!/usr/local/bin/perl -w
    002 use strict;
    003 use local::lib;
    004 use iCal::Parser;
    005 use Log::Log4perl qw(:easy);
    006 use App::Daemon qw(daemonize);
    007 use Sysadm::Install qw(mkd slurp tap);
    008 use FindBin qw($Bin);
    009 
    010 our $UPDATE_REQUESTED = 0;
    011 our $ALERT_BEFORE = 
    012  DateTime::Duration->new( minutes => 15 );
    013 our $CURRENT_DAY      = DateTime->today();
    014 our @TODAYS_EVENTS    = ();
    015 
    016 my($home)  = glob "~";
    017 my $admdir = "$home/.ical-daemon";
    018 my $icsdir = "$admdir/ics";
    019 
    020 mkd $admdir unless -d $admdir;
    021 mkd $icsdir unless -d $icsdir;
    022 
    023 $App::Daemon::logfile = "$admdir/log";
    024 $App::Daemon::pidfile = "$admdir/pid";
    025 
    026 if( exists $ARGV[0] and 
    027   $ARGV[0] eq '-q' ) {
    028   my $pid = App::Daemon::pid_file_read();
    029   kill 10, $pid; # Send USR1
    030   exit 0;
    031 }
    032 
    033 Log::Log4perl->easy_init({ 
    034   level => $DEBUG, 
    035   file  => $App::Daemon::logfile 
    036 });
    037 
    038 $SIG{ USR1 } = sub {
    039     DEBUG "Received USR1";
    040     $UPDATE_REQUESTED = 1;
    041 };
    042 
    043 $UPDATE_REQUESTED = 1; # bootstrap
    044 
    045 daemonize();
    046 
    047 while(1) {
    048   my $now = DateTime->now( 
    049     time_zone => 'local' );
    050 
    051   my $today =  
    052     $now->clone->truncate( to => 'day' );
    053 
    054   if( $UPDATE_REQUESTED or
    055       $CURRENT_DAY ne $today ) {
    056 
    057     $UPDATE_REQUESTED = 0;
    058     $CURRENT_DAY      = $today;
    059 
    060     DEBUG "Updating ...";
    061     @TODAYS_EVENTS    = update( $now );
    062     DEBUG "Update done.";
    063   }
    064 
    065   if( scalar @TODAYS_EVENTS ) {
    066     my $entry         = $TODAYS_EVENTS[0];
    067                          
    068     DEBUG "Next event at: $entry->[0]";
    069 
    070     if( $now >
    071         $entry->[0] - $ALERT_BEFORE ) {
    072       INFO "Notification: ",
    073            "$entry->[1] $entry->[0]";
    074       tap "$Bin/ical-notify", $entry->[1],
    075           $entry->[0];
    076       shift @TODAYS_EVENTS;
    077       next;
    078     }
    079   }
    080 
    081   DEBUG "Sleeping";
    082   sleep 60;
    083 }
    084 
    085 ###########################################
    086 sub update {
    087 ###########################################
    088   my($now) = @_;
    089 
    090   my $start = $now->clone->truncate( 
    091       to => 'day' );
    092   my $tomorrow = $now->clone->add( 
    093       days => 1 );
    094 
    095   my $parser=iCal::Parser->new( 
    096       start => $start, 
    097       end   => $tomorrow );
    098 
    099   my $hash;
    100 
    101   for my $file (<$icsdir/*.ics>) {
    102     DEBUG "Parsing $file";
    103     $hash = $parser->parse( $file );
    104   }
    105 
    106   my $year  = $now->year;
    107   my $month = $now->month;
    108   my $day   = $now->day;
    109 
    110   if(! exists $hash->{ events }->{ 
    111           $year }->{ $month }->{ $day } ) {
    112       return ();
    113   }
    114 
    115   my $events = $hash->{ events }->{ 
    116           $year }->{ $month }->{ $day };
    117 
    118   for my $key ( keys %$events ) {
    119       if( event_is_holiday( 
    120               $events->{ $key } ) ) {
    121           WARN "No alerts today (holiday)";
    122           return ();
    123       }
    124   }
    125 
    126   my @events = ();
    127 
    128   for my $key ( keys %$events ) {
    129     next if $now > 
    130       $events->{ $key }->{ DTSTART };
    131         # already over?
    132 
    133     push @events, [
    134      $events->{ $key }->{ DTSTART },
    135      $events->{ $key }->{ DESCRIPTION },
    136     ];
    137   }
    138 
    139   @events = sort { $a->[0] <=> $b->[0] } 
    140               @events;
    141 
    142   return @events;
    143 }
    144 
    145 ###########################################
    146 sub event_is_holiday {
    147 ###########################################
    148   my($event) = @_;
    149 
    150   return undef unless 
    151     exists $event->{ ATTENDEE };
    152 
    153   if( $event->{ ATTENDEE }->[ 0 ]->{ CN } 
    154       eq "US Holidays" ) {
    155     return 1;
    156   }
    157   return 0;
    158 }

Spuk um Mitternacht

In der while-Schleife ab Zeile 47 prüft der Dämon anschließend in regelmäßigen Abständen, ob sich ein Meeting schon bis auf 15 Minuten genähert hat, ruft daraufhin das weiter unten besprochene Skript ical-notify auf und löscht den Event aus dem Array mit den Tagesereignissen.

Um Mitternacht ändert sich das aktuelle Datum, was Zeile 55 durch den Vergleich mit dem in $CURRENT_DAY gespeicherten Tag erfasst. In diesem Fall ruft Zeile 61 die weiter unten definierte Funktion update() auf, die wiederum alle .ics-Dateien ausliest und einen neuen Tages-Array konstruiert.

Stellt Zeile 70 fest, dass die Anzahl der noch bis zum Meetingsbeginn verbleibende Zeitspanne kleiner als die eingestellte Karenzzeit von 15 Minuten ist, aktiviert Zeile 74 über die vom CPAN-Modul Sysadm::Install exportierte Funktion tap() das Skript ical-notify. Das in Zeile 8 hereingezogene Modul FindBin liegt Perl-Distributionen von Haus aus bei und exportiert auf Verlangen eine Variable $Bin, die das Verzeichnis angibt, in dem das gerade laufende Skript liegt. Der Befehl tap() in Zeile 74 nutzt nun $Bin, um ical-notify im gleichen Verzeichnis wie der laufende Dämon zu finden.

Fällt der aktuelle Tag auf einen Feiertag, stellt dies die Funktion update() durch den Aufruf von event_is_holiday() (definiert ab Zeile 146) fest, und update() streicht alle Termine für den Tag und reicht einen leeren Array ans Hauptprogramm zurück. Um festzustellen, ob ein Event aus dem Feiertagskalender stammt, prüft Zeile 153 in event_is_holiday(), ob das Feld "ATTENDEE" im Eintrag "CN" den String "US Holidays" enthält, denn die entsprechenden Zeilen der .ics-Datei mit den Feiertagen sehen so aus:

    ATTENDEE;...;CN=US Holidays;...

Im deutschen Feiertagskalender steht dort entsprechend "CN=German Holidays".

Wer denkt schon an morgen

Der Konstruktoraufruf des CPAN-Moduls iCal::Parser in Zeile 95 nimmt zwei DateTime-Objekte entgegen, die das Zeitfenster des aktuellen Tages, von Mitternacht bis Mitternacht, definieren. Aus eventuell sich wiederholenden Meetings generiert iCal::Parser so nur Events, die auf den aktuellen Tag fallen und spart sich so das Extrapolieren der Ereignisse bis in alle Ewigkeit. Schlägt die Uhr Mitternacht, frischt der Dämon seine Daten sowieso auf, ebenfalls wieder nur für die Zeitspanne eines Tages.

Jede gefundene .ics-Datei, einschließlich der Feiertagssammlung, schnupft die Methode parse() in Zeile 103 hoch und addiert die neu gefundenen Meetingsdaten zum bereits bestehenden iCal::Parser-Objekt. Der letzte Aufruf gibt eine Referenz auf einen Hash zurück, der unter dem Eintrag $hash->{2010}->10}->11} einen Hash mit Events stehen hat, die auf den 11.10.2010 fallen.

Ergibt sich in Zeile 119, dass auch nur ein Event ein Feiertag ist, gibt Zeile 121 eine Warnung aus und update() reicht ein leeres Event-Array ans Hauptprogramm zurück, denn der Feiertag übertrumpft alle anderen Einträge. Ist dies nicht der Fall, extrahiert update() die Werte für DTSTART und DESCRIPTION und schiebt die Startzeit des Meetings (als DateTime-Objekt) und das Thema ans Ende des Arrays @events. Zeile 139 sortiert die Meetings aufsteigend nach deren Startzeit und Zeile 142 reicht den Tagesplan als Array ans Hauptprogramm hoch.

Protokoll schreiben

Damit der User prüfen kann, was der Dämon so treibt, protokolliert dieser alle Vorfälle mit Log4perl in der Datei ~/.ical-daemon/log (Abbildung 7).

Als kleine Optimierung könnte sich der Dämon statt des dauernd wiederkehrenden Minutenschlafs gleich bis 15 Minuten vor dem nächsten Meeting (oder den Anbruch eines neuen Tages) aufs Ohr legen. Doch Log-Nachrichten im Minutentakt kosten nicht viel und geben schnell Auskunft darüber, wann der Dämon lief und wann er gestoppt wurde oder gar abgestürzt ist.

Abbildung 7: In der Logdatei schreibt der Dämon nieder, was gerade vor sich geht.

HUPs, aufgefrischt!

Damit der Dämon nicht ständig die Zeitstempel der .ics-Dateien prüfen muss, um festzustellen, ob sich neue dazugesellt oder alte verabschiedet haben, scheucht der User den Dämon in diesem Fall manuell über ein Unix-Signal auf. Empfängt der Dämon das Signal USR1, setzt der Signal-Handler ab Zeile 37 die globale Variable $UPDATE_REQUESTED. Im nächsten Durchgang der Endlos-while-Schleife ab Zeile 47 stellt der Dämon dies dann fest und re-initialisiert seine internen Datenstrukturen mit den aktuellen .ics-Dateien. Damit der User zum Reinitialisieren des Dämons nicht die PID des Dämon-Prozesses herausfitzeln und eigenhändig kill USR1 pid absetzen muss, erledigt dies ein weiterer Aufruf des Dämons mit ical-daemon -q. Nach dem Senden des Signals bricht das Skript wegen des exit-Befehls in Zeile 30 sofort wieder ab, ohne einen weiteren Dämon zu starten.

Da der mit dem CPAN-Modul App::Daemon realisierte Dämon die PID in einer Datei speichert, ist das Hervorkramen mit App::Daemon::pid_file_read() in Zeile 28 ein Kinderspiel.

Wachrütteln per Email

Nun bietet Google auch allerlei Benachrichtigungen an, vom Popup bis zur Textnachricht auf dem Handy, doch der ical-daemon kann stattdessen beliebige Skripts ausführen. Mir schwebten ursprünglich IM-Nachrichten über Yahoos neue Messenger-Web-API [3] vor, doch dazu reicht in dieser Ausgabe der Platz leider nicht. In einer der nächsten Ausgaben vielleicht, und sobald ich mich durch den dafür notwendigen OAuth-Dschungel gekämpft habe.

Listing 2: ical-notify

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use local::lib;
    04 use Mail::DWIM qw(mail);
    05 
    06 my($agenda, $time) = @ARGV;
    07 
    08 die "usage: $0 time agenda" unless
    09     defined $time;
    10 
    11 mail(
    12   to          => 'm@perlmeister.com',
    13   subject     => "Meeting: $agenda",
    14   text => "Meeting '$agenda' at $time.",
    15   transport   => "smtp",
    16   smtp_server => "localhost",
    17 );

Abbildung 8: Eine Email alarmiert den User über das in 15 Minuten anstehende Meeting.

Statt dessen nutzt das Skript ical-notify das CPAN-Modul Mail::DWIM, das eine Nachricht über den lokal laufenden SMTP-Dämon auf Port 25 absetzt. Aufmerksame Leser erinnern sich an den dazu in [4] konstruierten dynamischen Tunnelmailer, aber ein normaler Sendmail oder Postfix-Prozess tut's auch. Die beim User 15 Minuten vor Meetingsbeginn ankommende Email zeigt Abbildung 8.

Zur Installation sind die verwendeten CPAN-Module zu laden und am besten mittels local::lib zu installieren. Wer statt amerikanischer Feiertage die deutschen bevorzugt, ersetzt den Textstring "US Holidays" in Zeile 154 von Listing 1 durch "German Holidays" und erhält trotz anderslautender hartnäckiger Gerüchte nur einige wenige freie Tage mehr.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2010/12/Perl

[2]

http://en.wikipedia.org/wiki/ICalendar

[3]

Yahoo! Messenger IM API http://developer.yahoo.com/messenger/guide/ch02.html

[4]

"Schöner Schicken" (Perl-Skript tunnelt Mailverkehr auf Zuruf), Michael Schilli, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2010/07/Schoener-schicken

[5]

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.