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.
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.
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. |
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. |
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.
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 }
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".
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.
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. |
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.
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.
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.
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2010/12/Perl
Yahoo! Messenger IM API http://developer.yahoo.com/messenger/guide/ch02.html
"Schöner Schicken" (Perl-Skript tunnelt Mailverkehr auf Zuruf), Michael Schilli, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2010/07/Schoener-schicken
Michael Schilliarbeitet 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. |