Aus Linux-Magazin 09/2007

Tipps täglich verschicken

Englische Vokabeln oder Vim-Kommandos in mundgerechten Stücken serviert sind ungleich verdaulicher als jedes Supersize-Mahl. Das diesmal im Snapshot vorgestellte Skript macht Lernwillige zu Verkostern eines täglich wechselnden E-Mail-Häppchens.

Obgleich oder weil ich seit zehn Jahren in den USA wohne, versuche ich mein Englisch zu verbessern. Wer außer Muttersprachlern weiß schon, was “Cynosure” heißt? Oder “Exonym”? Oder “Schtick”? Seit mir jeden Tag eine E-Mail von “A Word A Day” [2] ins Haus flattert, die ein neues Wort vorstellt, lerne ich Englisch im Vorbeigehen. Abbildung 1 zeigt, wie der Service das Wort mit (hoffentlich) einfacheren Vokabeln umschreibt. Auch zitiert er Beispiele aus öffentlichen Schriften, um die ordnungsgemäße Verwendung zu zeigen.

Abbildung 1: Jeden Tag eine neue Vokabel lernen: Die Erklärung für das Wort „Exonym“ hält der Dienst „A Word A Day“ bereit.

Abbildung 1: Jeden Tag eine neue Vokabel lernen: Die Erklärung für das Wort „Exonym“ hält der Dienst „A Word A Day“ bereit.

Jeden Tag ein Tipp

Die Effektivität des Verfahrens legt die Idee nahe, es auf andere Bereiche auszudehnen. So gibt es tägliche Tipps für den Editor Vim, warum nicht auch welche für Perl? Oder die Java-Falle des Tages? Fehlen nur ein Skript, das die Tipps ablegt, und ein Cronjob, der sie morgens an eine Liste mit nach Infohäppchen lechzenden Subscribern abschickt.

Das Skript »xaday« (x-a-day, Listing 1) nutzt eine SQLite-Datenbank ([3], [4]) als Tippspeicher, erlaubt aber das Editieren der Tipps in einem normalen Editor. Mit dem Parameter »-m« und einer E-Mail-Adresse aufgerufen, schickt es 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 eine Warteschlange Eintrag für Eintrag ab, bis nichts mehr übrig ist. Will der Tipp-Versender neue Tipps in die Warteschlange einfügen, ruft er »xaday -e« auf und trägt sie ein (Abbildung 2).

Listing 1:
»xaday«

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

Abbildung 2: Der Versender kann neue Tipps auch per Hand in einem normalen Editor verfassen – hier drei vorbereitete Vim-Tipps.

Ähnlich wie bei Perls POD-Format stehen die Tipps durch »=head1«-Überschriften getrennt in der editierten Datei. Die Überschrift wird zur Subject-Zeile, der Text zum Body der ausgehenden Tipp-E-Mail. Speichert der Benutzer die geänderte Datei, schiebt »xaday« den Inhalt zurück in die Datenbank. Hierbei ist es sowohl möglich, neue Tipps hinzuzufügen als auch wartende oder bereits ausgesandte zu korrigieren.

Abbildung 3 demonstriert, wo die Infohäppchen in der Tabelle »tips« der Datenbank landen. Der SQLite-Client zeigt dabei Tabellen nicht so schön in Ascii-Art an wie MySQL, aber mit den Formatierungskommandos ».width«, ».mode« und ».headers« wird\’s ansehnlich. Näheres zu SQLite findet sich in dem empfehlenswerten Buch [5], das nicht nur die dateibasierte Datenbank mit ihren Features preist, sondern auch Grundlagen der relationalen Theorie anschaulich zu vermitteln weiß.

Abbildung 3: Die vorbereiteten Tipps liegen in einer SQLite-Datenbank. Mit ein paar Formatierungsanweisungen zeigt deren Client die Tabelle ganz passabel an.

Abbildung 3: Die vorbereiteten Tipps liegen in einer SQLite-Datenbank. Mit ein paar Formatierungsanweisungen zeigt deren Client die Tabelle ganz passabel an.

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 aufnimmt. Die Datei »xaday.sql« in Abbildung 4 zeigt die dafür notwendigen SQL-Kommandos. Der SQLite-Client »sqlite3« führt sie mit

sqlite3 dbname.dat <xaday.sql

aus. Das Kommando legt in der Datei »dbname.dat« eine Datenbank an, auf die der Perl-Client später mit SQL-Kommandos zugreift.

Abbildung 4: Der SQLite-Client bekommt die Datei »xaday.sql« vorgesetzt. Die SQL-Kommandos legen eine Datenbanktabelle an.

Abbildung 4: Der SQLite-Client bekommt die Datei »xaday.sql« vorgesetzt. Die SQL-Kommandos legen eine Datenbanktabelle an.

Auf Rose gebettet

Statt SQL-Kommandos abzusondern und die zurückkommenden Daten in Perl zu interpretieren, benutzt »xaday« wie einige früher vorgestellte Skripte die objektrationale Schnittstelle DB::Rose vom CPAN. Deren Loader beschnüffelt beim Programmstart die Datenbanktabelle nur kurz, die Methode »make_classes« erzeugt automatisch alle Mappings im Perl-Code.

Ruft der Benutzer »xaday« mit der Option »-e« auf, möchte er weitere Tipps einfügen. Zeile 31 in Listing 1 erzeugt dafür eine temporäre Datei, die der Editor speist. Den eigentlichen Neu-Tipp hängt der Bediener einfach mit einer »=head1«-Überschrift an. Die Environment-Variable »EDITOR« zeigt den bevorzugten Editor, »vim«, »emacs« oder sogar »pico« sind gängige Einstellungen.

Nach dem Editor-Aufruf prüft das Skript, ob sich die editierte Datei verändert oder ob sich der Benutzer dazu entschieden hat, die Änderungen abzubrechen. Im zweiten Fall verabschiedet sich das Programm mit einer Meldung. Hat sich die Datei jedoch verändert, müssen die Änderungen in die Datenbank wandern.

Schnelle Nummer

Damit das Programm erkennt, welche Beiträge aus der Datenbank stammen und welche der Benutzer neu eingetippt hat, hängt »xaday« an jede Überschrift aus der Datenbank deren Identifikationsnummer in geschweiften Klammern an. Steht also in der zu editierenden Datei zum Beispiel »”=head1 Überschrift {13}”«, dann stammt der Eintrag aus der Zeile der Datenbanktabelle mit der ID-Nummer 13.

Wer die Reihenfolge der Beiträge ändern will, passt die Nummern an, denn der E-Mailer geht streng nach aufsteigender ID vor. Neuen Einträgen ohne ID verpasst das Programm eine neue Nummer und fügt sie in die Datenbank ein. Der »AUTOINCREMENT«-Mechanismus in der SQL-Definition sorgt dafür, dass neue Einträge stetig aufsteigende IDs erhalten.

Den Stapel abarbeiten

Geht es ans Aussenden der Mail, muss das Skript den Tipp mit der niedrigsten ID-Nummer finden, dessen »published«-Eintrag noch kein Datum, sondern »NULL« enthält. Zeile 61 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 heutige Datum, ein nachfolgendes »update()« speichert die Änderung in der Datenbank. Sind alle Tipps aufgebraucht, bricht das Skript in Zeile 73 mit einem Fehler ab, was der ausführende Cronjob dem Nutzer promt per E-Mail mitteilt.

Immer den Blick voraus

Um in der Textdatei die einzelnen Tipp-Einträge voneinander zu trennen, setzt die Funktion »text2db()« einen regulären Ausdruck mit positivem Look-Ahead ein. Das 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 97 definierte Funktion »rec_parse()« weiter. Diese versucht die Spaltenwerte für »head«, »id« und »text« zu extrahieren. 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 113 nimmt entweder einen Dateinamen (als Skalar) oder einen Datenstring (als Referenz) entgegen. Sie ruft »text2db()« auf, extrahiert die Teilfelder aller Tipps und findet heraus, welche Felder der Bediener aus der ursprünglichen Datenbank gelöscht hat. Alle anderen IDs legt sie im Array »@keep_ids« ab.

Rose auffrischen

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 das Array leer, stellt DB::Rose auf stur und weigert sich, alle Einträge zu löschen. Dann setzt der »else«-Zweig ab Zeile 132 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 138 iteriert über alle Tipps. Führen einige eine ID-Nummer, nimmt der Skalar »$info« diese IDs für die Aktualisierung der Daten des korrespondierenden Datenbankeintrags auf. 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()« und so weiter auf und bemüht anschließend »update()«, um die Daten vom lokalen Speicher in die Datenbank zurückzuschreiben.

Ist »$info« nicht definiert, legen die Zeilen 150 bis 153 einfach einen neuen Record mit den Werten für die Kopfzeile »head« und dem Tippinhalt »text« an und lassen die Datenbank mit »AUTOINCREMENT« eine neue ID bestimmen. Die Methode »save()« kümmert sich um den Datenbankzugriff. Ausgehende Mails sendet das CPAN-Modul Mail::Mailer. Die Funktion »mail()« ab Zeile 160 nimmt nur die Empfängeradresse, die Kopfzeile und den Inhalt des Tipps entgegen und verhandelt selbstständig mit dem lokalen »sendmail«-Programm.

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 15 ist an die für die Tipps verwendete Mailingliste anzupassen. Damit das Skript jeden Morgen um 7:30 Uhr seine E-Mails verschickt, setzt

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

einen Cronjob auf. »dbfile.dat« ist die SQLite-Datei, die mit »-m« angegebene Mailingliste ist die Zieladresse, auf der Abonnenten auf tägliche Tipps lauern. Zum Auffüllen der Datenbank mit Tipps ruft der Ratgeber

xaday -d /Pfad_zu/dbfile.dat -e

auf, verfasst im aufgehenden Editor seine Tipps und sichert die Änderungen. Alternativ zum Editor darf er dem Skript mit der Option »-f« 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 den Cronjob an oder ändert das Skript dergestalt 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 (Sonntag) bis 6 (Samstag).

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

Das Wichtigste bleiben jedoch die Tipps selbst – die verfasst man am besten für etwa eine Woche im Voraus, sodass ein paar Tage Urlaub nicht den Tippreigen zum Erliegen bringen. Was nun noch fehlt, ist die Auflösung der beiden anderen rätselhaften Vokabeln: “Cynosure” ist der Anziehungspunkt und “Schtick” kommt aus dem Jiddischen und heißt Masche oder Nummer. (jk)

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] SQLite: [http://www.sqlite.org]

[4] C. Dalitz, “SQLite – Datenbank-Engine im Kleinformat”: Linux-Magazin 09/04, S. 96

[5] Michael Owens, “The Definitive Guide to SQLite”: Apress 2006

Der Autor


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.

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