Tägliches Gehirnfutter (Linux-Magazin, September 2007)

Niemand paukt gern lange Listen von Vokabeln oder vim-Kommandos. Das heutige Skript versorgt Lernwillige deswegen häppchenweise mit Wissen. Mit einer Email pro Tag.

Seit mehreren Jahren verbessere ich mein Englisch, und zwar um ein Wort pro Tag. Hätten Sie zum Beispiel gewusst, was cynosure heisst? Oder exonym? Oder schtick? Diese Wörter waren mir lange Zeit unbekannt, aber seit jeden Tag eine Email von ``A Word A Day'' ([1]) ins Haus flattert, die ein neues Wort vorstellt, lerne ich sie spielend. Abbildung 1 zeigt, wie der Service nicht nur das Wort mittels (hoffentlich) einfacherer Vokabeln umschreibt, sondern zitiert auch Beispiele aus veröffentlichten Schriften, um die ordnungsgemäße Verwendung zu zeigen.

Abbildung 1: Die Erklärung des Wortes "Exonym" auf "A Word A Day"

Jeden Tag ein Tipp

Dieses Verfahren ist ausgesprochen effektiv, und so liegt der Ansatz nahe, ihn auf andere Bereiche auszudehnen. Es gibt tägliche Tipps für den Editor vim, und es lassen sich leicht neue Einsatzmöglichkeiten finden: Coole Perl-Tipps? Die Java-Falle des Tages? Fehlt nur noch ein Skript, um die Tipps abzulegen und dann per Cronjob frühmorgens an eine Mailingliste mit nach Tipps lechzenden Subscribern abzuschicken.

Das Skript xaday (``x-a-day'') nutzt eine SQLite-Datenbank als Tippspeicher, erlaubt aber das Editieren der Tipps in einem normalen Editor. Mit dem Parameter -m und einer Email-Adresse aufgerufen, schickt das Skript jeweils einen Tipp ab, merkt sich das Publikationsdatum in der Datenbank und wird bei einem erneuten Aufruf stets einen noch unveröffentlichten Tipp heranziehen. So arbeitet es die Warteschlange Eintrag für Eintrag ab, bis nichts mehr übrig ist. Will der Tipp-Provider neue Tipps in die Warteschlange einfügen, ruft er xaday -e auf und trägt neue Ideen ein (Abbildung 2).

Abbildung 2: Drei neue vorbereitete vim-Tipps im Editor

Die Tipps stehen durch =head1-Überschriften (bekannt aus Perls POD-Format) getrennt in der editierten Datei. Die Überschrift wird zur Subject-Zeile einer ausgehenden Tipp-Email, der Text zum Body der Email. Speichert der Benutzer die geänderte Datei ab, speichert xaday den Inhalt zurück in die Datenbank. Hierbei ist es sowohl möglich, neue Tipps hinzuzufügen als auch wartende oder bereits ausgesandte Tipps zu korrigieren.

Abbildung 3 zeigt, wie die Tipps in der Tabelle tips der Datenbank landen. Der SQLite-Client zeigt Tabellen nicht so schön in ASCII-Art an wie mysql, aber mit den Formatierungskommandos .width, .mode und .headers wird's ansehnlicher. Näheres zu SQLite findet sich in [3], einem sehr empfehlenswerten Buch, das nicht nur die dateibasierte Datenbank mit ihren erstaunlichen Features beschreibt sondern auch Grundlagen der relationalen Theorie anschaulich vermittelt.

Abbildung 3: Die vorbereiteten Tipps in der Datenbank

Für die einfache Applikation reicht eine Tabelle, die in den Spalten head, text, und published die Überschrift, den Text und das Publikationsdatum der konservierten Tipps beinhaltet. Die Datei xaday.sql in Abbildung 4 zeigt die notwendigen SQL-Kommandos. Der SQLite-Client sqlite3 führt sie mit sqlite3 dbname.dat <xaday.sql aus. Dies legt in der Datei dbname.dat eine Datenbank an, auf die der Perl-Client später mit SQL-Kommandos zugreift.

Abbildung 4: SQL-Kommandos zum Anlegen der einzigen Datenbanktabelle.

Auf Rose gebettet

Statt SQL-Kommandos abzufeuern und die zurückkommenden Daten in Perl zu interpretieren, nutzt xaday wie auch schon früher vorgestellte Skripts die objekt-rationale Schnittstelle DB::Rose vom CPAN. Deren Loader sieht sich beim Programmstart nur kurz die Datenbanktabelle an und die Methode make_classes erzeugt dann automatisch alle notwendigen Mappings im Perl-Code.

Wird xaday mit der Option -e aufgerufen, möchte der Benutzer weitere Tipps einfügen und Zeile 27 erzeugt eine neue temporäre Datei, um sie mit dem Datenbankinhalt zu füllen. Neue Tipps werden dann einfach mit dem Editor mit einer =head1-Überschrift angehängt. Die Environment-Variable EDITOR zeigt hierzu den bevorzugten Editor an; vim, emacs oder sogar pico sind gängige Einstellungen.

Nach dem Editoraufruf prüft das Skript, ob sich die editierte Datei verändert hat oder ob sich der Benutzer dazu entschieden hat, die Änderungen abzubrechen. Im letzten Fall bricht das Programm mit einer Meldung ab, hat sich die Datei jedoch verändert, müssen die Änderungen zurück in die Datenbank wandern.

Damit das Programm erkennt, welche Beiträge aus der Datenbank stammen und welche der Benutzer neu hinzugefügt hat, hängt xaday an jede Überschrift aus der Datenbank deren Identifikationsnummer in geschweiften Klammern an. Steht in der zu editierenden Datei zum Beispiel "=head1 Überschrift {13}", dann stammt der Eintrag aus der Zeile der Datenbanktabelle mit der id-Nummer 13. Möchte der User die Reihenfolge der Beiträge ändern, kann er die Nummern anpassen, den der Emailer geht streng nach aufsteigender id vor. Neuen Einträgen ohne id-Nummer verpasst das Programm eine neue und fügt sie in die Datenbank ein. Der AUTOINCREMENT-Mechanismus in der SQL-Definition sorgt dafür, dass neue Einträge stetig aufsteigene id-Nummern erhalten.

Stapel abarbeiten

Geht es ans Aussenden der Email, muss das Skript den Tipp mit der niedrigsten id-Nummer finden, dessen published-Eintrag noch kein Datum, sondern NULL enthält. Zeile 57 feuert mit get_tips unter der Haube einen SQL-Query ab, der nach allen Records sucht, deren published-Eintrag NULL ist und sortiert die Ergebnisliste aufsteigend nach der id. Das auf 1 gesetzte Limit sorgt dafür, dass nur das erste Ergebnis aus der Datenbank zurückkommt.

Dem gefundenen Eintrag verpasst die Methode published mit dem Parameter DateTime->today() das aktuelle Datum und ein nachfolgendes update() speichert die Änderung in der Datenbank. Sind alle Tipps aufgebraucht, bricht das Skript in Zeile 69 mit einem Fehler ab, was der ausführende Cronjob dem Nutzer per Email mitteilt.

Auffrischen

Um aus der Textdatei die einzelnen Tipp-Einträge voneinander zu trennen setzt die Funktion text2db() einen regulären Ausdruck mit positivem Look-Ahead ein. Dieses mit (?= eingeleitete Konstrukt 'schluckt' den gefundenen Ausdruck nicht, sondern spitzelt nur ein wenig voraus. Ist ein mit =head1 eingeleiteter Absatz gefunden, reicht ihn text2db() an die ab Zeile 92 definierte Funktion rec_parse() weiter, die die Spaltenwerte für head, id, und text zu extrahieren versucht. Im Erfolgsfall, reicht sie alle drei Werte zurück -- im Fehlerfall hingegen undef. text2db() nimmt noch einige kosmetische Korrekturen vor und stutzt einleitenden und abschließenden Whitespace zurecht.

Die Funktion db_update() ab Zeile 109 nimmt entweder einen Dateinamen (als Skalar) oder einen Datenstring (als Referenz) entgegen. Sie ruft text2db() auf, extrahiert alle Teilfelder aller Tipps und findet anschließend heraus, welche Felder aus der ursprünglichen Datenbank aus dem Text gelöscht wurden. Alle anderen ids legt sie im Array @keep_ids ab. Mit der Bedingung "!id" => \@keep_ids löscht die Methode delete_tips() alle Records aus der Datenbank, deren id-Felder mit keinem der Werte im Array @keep_ids identisch sind.

Ist @keep_ids leer, stellt sich DB::Rose stur und weigert sich, alle Einträge zu löschen, deshalb setzt der else-Zweig ab Zeile 129 in diesem Fall einen delete_tips()-Aufruf mit dem Flag all ab. Die Anzahl der gelöschten Reihen zeigt das Skript in beiden Fällen auf der Standardausgabe an.

Zeile 134 iteriert über alle im Text definierten Tipps. Führen sie eine id-Nummer, enthältt der Skalar $info diese und der korrespondierende Datenbankeintrag wird nur mit den aktuellen Daten aufgefrischt. Rose lädt hierzu ein neu erzeugtes Objekt der Klasse Tip erst mit load() aus der Datenbank, frischt die einzelnen Felder mit den bereitgestellten Methoden (head(), text(), usw.) auf und ruft anschließend update() auf, um die Daten vom lokalen Speicher wieder zurück in die Datenbank zurückzuschreiben.

Ist $info nicht definiert, legt Zeile einfach einen neuen Record mit den Werten für die Kopfzeile head und den Tippinhalt text an und lässt die Datenbank mit AUTOINCREMENT eine neue id bestimmen. Die Methode save() führt den Datenbankzugriff durch.

Ausgehende Emails sendet das Modul Mail::Mailer vom CPAN. Die Funktion mail() ab Zeile 156 nimmt nur die Empfängeradresse, die Kopfzeile und den Inhalt des Tipps entgegen und verhandelt anschließend selbständig mit dem lokalen sendmail-Programm.

Listing 1: xaday

    001 #!/usr/bin/perl -w
    002 use strict;
    003 use Rose::DB::Object::Loader;
    004 use Getopt::Std;
    005 use File::Temp qw(tempfile);
    006 use Sysadm::Install qw(:all);
    007 use Mail::Mailer;
    008 
    009 my $RECSEP    = qr/^=head1/;
    010 my $HEAD      = "=head1";
    011 my $MAIL_FROM = 'me@_foo.com';
    012 
    013 getopts("d:lepm:f:", \my %opts);
    014 
    015 die "usage: $0 -d dbfile ..." 
    016                            unless $opts{d};
    017 
    018 my $loader = Rose::DB::Object::Loader->new(
    019   db_dsn => "dbi:SQLite:dbname=$opts{d}",
    020   db_options   => {
    021     AutoCommit => 1, RaiseError => 1 },
    022 );
    023 
    024 $loader->make_classes();
    025 
    026 if($opts{e} or $opts{l}) {
    027   my($fh, $tmpf) = tempfile(UNLINK => 1);
    028   my $tips = 
    029          Tip::Manager->get_tips_iterator();
    030   my $data_before = "";
    031 
    032   while(my $tip = $tips->next()) {
    033     $data_before .= 
    034       "$HEAD " .
    035       $tip->head() . " {" .
    036       $tip->id() . "}" . "\n\n" .
    037       $tip->text() .  "\n\n";
    038     }
    039     if($opts{l}) {
    040         print $data_before;
    041         exit 0;
    042     }
    043     blurt($data_before, $tmpf);
    044     system("$ENV{EDITOR} $tmpf");
    045     my $data_after = slurp($tmpf);
    046     die "No change" if 
    047               $data_before eq $data_after;
    048 
    049     db_update(\$data_after);
    050 }
    051 
    052 if($opts{f}) {
    053     db_update($opts{f});
    054 }
    055 
    056 if($opts{m}) {
    057   my $tips = Tip::Manager->get_tips(
    058     query => [ "published" => undef],
    059     sort_by => 'id',
    060     limit   => 1,
    061   );
    062   if(@$tips) {
    063     $tips->[0]->published(
    064                         DateTime->today());
    065     $tips->[0]->update();
    066     mail($opts{m}, $tips->[0]->head(), 
    067          $tips->[0]->text());
    068   } else {
    069       die "Nothing left to publish";
    070   }
    071 }
    072 
    073 ###########################################
    074 sub text2db {
    075 ###########################################
    076   my($text) = @_;
    077   $text = "" unless defined $text;
    078 
    079   my @fields = ();
    080 
    081   while($text =~ 
    082           /^($RECSEP.*?)
    083             (?=$RECSEP|\s*\Z)/smgx) {
    084     my($head, $info, $tip) = rec_parse($1);
    085     $tip =~ s/\s+\Z//;
    086     $tip =~ s/\A\s+//;
    087     push @fields, [$head, $info, $tip];
    088   }
    089   return \@fields;
    090 }
    091 
    092 ###########################################
    093 sub rec_parse {
    094 ###########################################
    095     my($text) = @_;
    096 
    097     if($text =~ /$RECSEP\s+(.*?)
    098                  (?:\s+\{(.*?)\})?
    099                  $
    100                  (.*)
    101                 /smgx) {
    102         return($1, $2, $3);
    103     }
    104 
    105     return undef;
    106 }
    107 
    108 ###########################################
    109 sub db_update {
    110 ###########################################
    111   my($in) = @_;
    112 
    113   my $data;
    114 
    115   if( ref($in) ){
    116     $data = $$in;
    117   } else {
    118     $data = slurp($in);
    119   }
    120 
    121   my $fields = text2db($data);
    122 
    123   my @keep_ids = map { $_->[1] } @$fields;
    124   my $gone;
    125   if(@keep_ids) {
    126     $gone = Tip::Manager->delete_tips(
    127           where => ["!id" => \@keep_ids] );
    128   } else {
    129     $gone = Tip::Manager->delete_tips(
    130                                 all => 1 );
    131   }
    132   print "$gone rows deleted\n" if $gone;
    133 
    134   for(@$fields) {
    135     my($head, $info, $tip) = @$_;
    136 
    137     my $rec;
    138 
    139     if(defined $info) {
    140       $rec = Tip->new(id => $info);
    141       $rec->load();
    142       $rec->head($head);
    143       $rec->text($tip);
    144       $rec->update();
    145     } else {
    146       $rec = Tip->new(
    147         text => $tip,
    148         head => $head,
    149       );
    150       $rec->save();
    151     }
    152   }
    153 }
    154 
    155 ###########################################
    156 sub mail {
    157 ###########################################
    158   my($to, $head, $body) = @_;
    159 
    160   my $mailer = Mail::Mailer->new();
    161 
    162   $mailer->open({
    163    'From' => $MAIL_FROM,
    164    'To'   => $to,
    165    'Subject' => $head,
    166   });
    167   print $mailer $body;
    168   close $mailer;
    169 }

Installation

Alle verwendeten Module stehen auf dem CPAN zum Download bereit, mit einer CPAN-Shell lösen sich Abhängigkeiten schnell auf. Die Variable MAIL_FROM aus Zeile 11 ist an die für die Tipps verwendete Mailing-Liste anzupassen. Damit das Skript jeden Morgen um 7:30 eine Email verschickt, setzt man einen Cronjob nach

    30 07 * * * /path/to/xaday -d /path/to/dbfile.dat -m mlist@somewhere.com

auf. dbfile.dat ist hierbei die verwendete SQLite-Datei und die mit -e angegebene Mailingliste ist die Zieladdresse, auf der Abonnenten auf tägliche Tipps lauern.

Zum Füllen der Datenbank mit Tipps ruft der User

    xaday -d /path/to/dbfile.dat -e

auf, füllt im aufgehenden Editor Tipps ein und sichert die Änderungen. Alternativ zum Editor kann man dem Skript mit der Option -f auch eine Textdatei unterjubeln. Um zu prüfen, ob die Tipps auch richtig in der Datenbank angekommen sind, zeigt die Option -l die bislang eingespeisten Daten an.

Erweiterungen

Wer seine Tipps nur an Werktagen abschicken möchte, passt einfach den Cronjob an oder ändert das Skript so ab, dass es im $opts{m}-Zweig den Wochentag abfragt und sich beendet, falls dies ein Samstag oder Sonntag ist. Das Perl-Konstrukt (localtime(time))[6]) liefert den aktuellen Wochentag als Zahl von 0 bis 6, wobei 0 ein Sonntag ist.

Wer auch an Feiertagen ruhen will, kann dies über eine zusätzliche Tabelle holidays implementieren, die bevorstehende Feiertage als Datum in einer Kolumne date ablegt. Das Skript könnte hierzu eine Kommandozeilenoption -d anbieten, die ein angegebenes Datum in die Tabelle aufnimmt.

Das Wichtigste sind jedoch die Tipps selbst -- die legt man am besten für etwa eine Woche im voraus an, sodass man auch mal ein paar Tage in Urlaub fahren kann, ohne dass der Tippreigen zum Erliegen kommt.

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2006/09/Perl

[2]
``A Word A Day'', http://wordsmith.org/awad/

[3]
Michael Owens, ``The Definitive Guide to SQLite'', Apress 2006

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.