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" |
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. |
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.
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.
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.
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 }
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.
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.
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. |