Aus Linux-Magazin 12/2010

Perl erinnert automatisch an Termine

© Renate Franke, Pixelio.de

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.

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 2: Die Funktion »Export« holt die ».ics«-Datei des Google-Kalenders vom Server.

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

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

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.

Abbildung 4: Die ».ics«-Datei mit den Feiertagen holt ein Klick auf »ICAL« vom Google-Server.

Abbildung 4: Die ».ics«-Datei mit den Feiertagen holt ein Klick auf »ICAL« vom Google-Server.

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.

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:
»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 }

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:
»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 );

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.

Abbildung 6: In der Logdatei schreibt der Daemon nieder, was gerade vor sich geht.

Abbildung 6: In der Logdatei schreibt der Daemon nieder, was gerade vor sich geht.

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.

Abbildung 7: Eine E-Mail alarmiert den User für das in 15 Minuten anstehende Meeting.

Abbildung 7: Eine E-Mail alarmiert den User für das in 15 Minuten anstehende Meeting.

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.

LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben