Pünktlichkeit ist die Höflichkeit der Könige. Ein Perl-Daemon liest I-Calendar-Dateien mit Meeting-Terminen ein und alarmiert den User auf individuelle Weise kurz vor Beginn der Veranstaltung.
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 dennoch Einladungen zu Meetings per E-Mail in Form von ».ics«-Dateien. Diese im I-Calendar-Format [2] verfassten maschinenlesbaren Textfiles beschreiben, an welchem Tag und zu welcher Uhrzeit das Meeting stattfindet, welches Thema dort erörtert und wer anwesend sein wird. Sie definieren auch den Turnus bei Meetings, die sich regelmäßig wiederholen.
GUI-schwer oder Perl-leicht?
Kalenderapplikationen aus dem Hause Gnome und KDE, Evolution, I-Cal auf dem Mac, Outlook auf Windows oder Google Calendar im Web importieren diese ».ics«-Dateien, stellen Meetings in einer Übersicht farbenfroh dar (Abbildung 1) und lösen mit Dialogfenstern Alarm aus, falls sich der Anfang eines Meetings nähert und der User sich schnellstens auf den Weg zum Konferenzraum machen sollte. Umgekehrt erlaubt zum Beispiel der Google-Kalender den Export der dort hinterlegten Kalenderdaten als ».ics«-Datei.

Abbildung 1: Der Google-Kalender listet tägliche Standup-Meetings, ein wöchentliches 1:1-Meeting und für Montag einen Feiertag auf.
Dies öffnet die Tür zu selbst gestrickten Kalenderprogrammen, wie dem heute vorgestellten Perl-Skript »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. E-Mail ist denkbar, eine Nachricht auf einem IM- oder IRC-Netzwerk oder auch etwas ganz anderes, 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). Wer sich die ».ics«-Datei in Abbildung 3 genau ansieht, erkennt darin zeilenweise Tags, von denen »DTSTART« den Meetingbeginn und »DESCRIPTION« das Thema der Besprechung angeben. Es handelt sich um ein alle zwei Wochen jeweils am Mittwoch stattfindendes Meeting, was 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 genau zu diesen Terminen bestimmte Aktionen wie zum Beispiel eine Benachrichtigung einleiten.

Abbildung 3: Die von Google Calendar als Kalender-Export produzierte ».ics«-Datei für ein zweiwöchentlich stattfindendes Meeting.
Feiertage als Kalender
Fällt das zweiwöchentliche Meeting mit dem Chef jedoch auf einen Feiertag, findet es naturgemäß nicht statt und Alarme sollten möglichst unterbleiben, um die wohlverdiente 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 in ihr über Jahre hinaus jeweils Ganztags-Ereignisse für jeden Feiertag.
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 4 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 wie in Abbildung 5) leitet der User dann an die jeweilige Kalenderapplikation (beispielsweise das Skript) weiter, die die Feiertagsereignisse extra behandelt und zum Beispiel alle an diesen Tagen anberaumten Meetings ausblendet.

Abbildung 5: Alle US-Feiertage finden sich in einer ».ics«-Datei, zu der es auch ein Pedant für deutsche Feiertage gibt.
Kalender selbst gestrickt
Nach dem Start liest das Skript (Listing 1) »ical-daemon« alle in dem 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.
|
Listing 1: |
|---|
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 }
|
Zeile 3 in Listing 1 lädt das hier schon häufig benutzte CPAN-Modul local::lib hinzu, das die Installation der anderen noch benötigten CPAN-Module unter dem Homeverzeichnis des ausführenden Users erlaubt, der deswegen weder Root-Rechte braucht noch die heilige Ordnung des Paketmanagers durcheinanderwirbelt.
Die Zeilen 22 und 23 legen die Dateien für das Log und die Prozess-ID (»pid«) fest. Zeile 32 initialisiert das Log4perl-Framework, das mittels »DEBUG«, »INFO« oder »WARN« verschickte 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 Daemon herauf- und herunterfahren.
Datumsberechnungen erledigt elegant das CPAN-Modul »DateTime«, das zum Beispiel das Zurücksetzen der Zeit in einem »DateTime«-Objekt »$dt« zum Tagesbeginn einfach durch »$dt->truncate( to => ‘day’ )« erledigt. »DateTime« überlädt auch Vergleichsoperatoren wie »<« und »>«, sodass »$dt1 > $dt2« genau dann wahr ist, wenn der Zeitpunkt »$dt1« auf den Zeitpunkt »$dt2« folgt.
|
Listing 2: |
|---|
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 ); |
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 Meetingzeitpunkt ab und prüft, ob die aktuelle Uhrzeit schon weiter fortgeschritten ist.
In der While-Schleife ab Zeile 47 prüft der Daemon anschließend regelmäßig, 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 Termin 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 Zeitspanne bis zum Meetingbeginn kürzer ist als die eingestellte Karenzzeit von 15 Minuten, aktiviert Zeile 74 über die vom CPAN-Modul System::Install exportierte Funktion »tap()« das Skript »ical-notify«. Das in Zeile 8 hereingezogene Modul »FindBin« liegt Perl-Distributionen immer bei und exportiert eine Variable »$Bin«, die das Verzeichnis angibt, in dem das laufende Skript liegt. Der Befehl »tap()« in Zeile 74 nutzt »$Bin«, um »ical-notify« im Verzeichnis des Daemon 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. »update()« streicht dann 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 an morgen
Der Konstruktoraufruf des CPAN-Moduls iCal::Parser in Zeile 95 nimmt zwei »DateTime«-Objekte entgegen, die das Zeitfenster des aktuellen Tages definieren. Aus sich wiederholenden Meetings generiert iCal::Parser so nur Events, die auf den aktuellen Tag fallen, und spart sich damit 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, lädt die Methode »parse()« in Zeile 103 und addiert die neu gefundenen Meetingdaten zum bereits bestehenden iCal::Parser-Objekt. Der letzte Aufruf gibt eine Referenz auf einen Hash zurück, der unter einem Eintrag wie »$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 auf einen Feiertag fällt, 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 Array »@events«. Zeile 139 sortiert die Meetings aufsteigend nach deren Startzeit und Zeile 142 reicht den Tagesplan als Array ans Hauptprogramm hoch.
Protokoll schreiben
Der Daemon protokolliert alle Vorfälle mit Log4perl in der Datei »~/.ical-daemon/log« (Abbildung 6). Als kleine Optimierung könnte er sich statt des dauernd wiederkehrenden Minutenschlafs gleich bis 15 Minuten vor dem nächsten Meeting (oder dem Anbruch eines neuen Tags) aufs Ohr legen. Doch Log-Nachrichten im Minutentakt kosten nicht viel und geben schnell Auskunft darüber, wann der Daemon lief, gestoppt wurde oder gar abgestürzt ist.
Damit der Daemon nicht ständig die Zeitstempel der ».ics«-Dateien prüfen muss, um festzustellen, ob sich neue Einträge dazugesellt oder alte verabschiedet haben, scheucht der User ihn in diesem Fall manuell über ein Unix-Signal auf. Empfängt der Daemon das Signal »USR1«, setzt der Signalhandler ab Zeile 38 die globale Variable »$UPDATE_REQUESTED«. Im nächsten Durchgang der endlosen While-Schleife ab Zeile 47 stellt der Daemon dies dann fest und re-initialisiert seine internen Datenstrukturen mit den aktuellen ».ics«-Dateien.
Damit der User zum Re-Initialisieren des Daemon nicht die PID des Daemon-Prozesses herausfitzeln und eigenhändig »kill -USR1 PID« absetzen muss, erledigt dies ein weiterer Aufruf 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 Daemon zu starten. Da der mit dem CPAN-Modul App::Daemon realisierte Daemon die PID in einer Datei speichert, ist das Hervorkramen mit »App::Daemon::pid_file_read()« in Zeile 28 ein Kinderspiel.
Schon Google bietet allerlei Benachrichtigungen an, vom Popup bis zur ausführlichen Textnachricht auf dem Handy, doch der Ical-Daemon kann stattdessen beliebige Skripts ausführen. Dem Autor schwebten ursprünglich IM-Nachrichten über Yahoos neues Messenger-Web-API [3] vor, doch das wäre für diese Ausgabe zu viel verlangt. In einem der nächsten Perl-Snapshots vielleicht – und sobald ich mich durch den dafür unentbehrlichen Oauth-Protokoll-Dschungel durchgekämpft habe.
Wachrütteln per E-Mail
Stattdessen nutzt das Skript »ical-notify« (Listing 2) das CPAN-Modul Mail::DWIM, das eine Nachricht über den lokal laufenden SMTP-Daemon auf Port 25 absetzt. Aufmerksame Leser erinnern sich an den dazu in [4] konstruierten dynamischen Tunnelmailer, aber ein ganz normaler Sendmail- oder Postfix-Prozess tut’s auch. Die beim User 15 Minuten vor Meetingbeginn ankommende E-Mail zeigt Abbildung 7.
Zur Installation sind die verwendeten CPAN-Module zu laden und am besten mittels »local::lib« zu installieren. Wer statt amerikanischer Feiertage die deutschen bevorzugt, der ersetzt den Textstring »US Holidays« in Zeile 154 von Listing 1 durch die Angabe »German Holidays« und erhält trotz anders lautender hartnäckiger Gerüchte nur einige wenige freie Tage mehr. (jcb)
|
Infos |
|---|
|
[1] Listings zu diesem Artikel: [ftp://www.linux-magazin.de/pub/listings/magazin/2010/12/Perl] [2] I-Calendar: [http://en.wikipedia.org/wiki/I-Calendar] [3] Yahoo! Messenger IM API: [http://developer.yahoo.com/messenger/guide/ch02.html] [4] Michael Schilli, “Schöner schicken”: [https://www.linux-magazin.de/Heft-Abo/Ausgaben/2010/07/Schoener-schicken] |
|
Der Autor |
|---|
|
Michael Schilli arbeitet als Software-Engineer bei Yahoo in Sunnyvale, Kalifornien. Er hat die Bücher “Goto Perl 5” (auf Deutsch) und “Perl Power” (auf Englisch) für Addison-Wesley geschrieben und ist unter [mschilli@perlmeister.com] zu erreichen. |










