Manche Zeitschriftenartikel gibt es einfach nicht Online. Das heutige Perlskript archiviert sie im PDF-Format und nutzt eine Datenbank für spätere Zugriffe.
Ob ein gelungenes 100-Fragen-Interview im Magazin der Süddeutschen Zeitung steht oder eine Max-Goldt-Kolumne in der Titanic: Beide liest man immer wieder gerne und möchte sie vielleicht archivieren. Leider kommen diese Blätter und Autoren altmodisch daher und haben noch nicht den Sprung ins Internet geschafft. Das geht auf Kosten Vieler, denn gedruckte Zeitschriften nehmen unnötig Platz weg. Kellerräume müssen angemietet, knallrote Ordner aus bretthartem Karton gekauft werden. Dabei interessiert doch nur hier und da ein Artikelchen, ganze Zeitschriften liest nach zwei Jahren kein Mensch mehr.
Statt dessen wäre es viel praktischer, die interessanten Passagen
mit einem Scanner zu digitalisieren und als PDF-Datei
auf der Festplatte abzuspeichern. Damit der Archivar in der
so ständig wachsenden Bibliothek nicht den Überblick verliert,
verwaltet das heute vorgestellte Perlskript magsafe eine
Datenbank mit den eingescannten Druckerzeugnissen.
Zwei, drei Seiten sind schnell eingescannt, das hervorragende
GUI-Programm xsane aus dem Sane-Projekt arbeitet über
den Sane-Backend mit allen bekannten Scannern zusammen. Sowohl
der Epson-Fotoscanner als auch der HP-All-In-One-Officejet
aus dem Perlmeister-Testlabor
ticken problemlos unter Linux. Die Einzelseiten werden gescannt
und als Bilder im PNG-Format abgelegt. Üblicherweise reichen 200 dpi,
damit der Text lesbar bleibt und ein Drucker später eventuell
gerade noch akzeptable Qualität produziert.
Mehrere Seiten zu einem PDF-Dokument zusammenzufassen, das
erledigt die convert
Utility nach einem aus [2] geklauten Trick:
convert -density 200 -quality 95 \
-resize "1600x1600>" *.png archive.pdf
Der Aufruf sammelt alle im gegenwärtigen Verzeichnis liegenden
*.png-Dateien ein, begrenzt sowohl Höhe als auch Breite auf 1600
Pixels. Kleinere Bilder bleiben wegen des >-Zeichens unverändert.
convert bündelt die Einzelseiten zu einer mehrseitigen PDF-Datei mit
200 dpi Auflösung zusammen. Die PNGs werden in PDF zu JPG komprimiert,
mit der angegebenen 95%igen Qualität.
|
| Abbildung 1: Der Titanic-Artikel wird mit xsane digitalisiert. |
Liegt der zweiseitige Artikel von Max Goldt dann in goldt.pdf, verfrachtet
ihn folgender Aufruf ins Archiv:
magsafe -m Titanic -a "Max Goldt"
-t "Tropfen, Klingeln und die üble Weiterleiterei"
-i 2005/03 -p 44 -d goldt.pdf
Dies legt einen Datensatz mit dem Titel der Zeitschrift (``Titanic''), einem Dokumententitel (``Tropfen, Klingeln und ...''), der Ausgabe (``2005/03'') und der Anfangsseite (44) an.
Die Daten liegen in einer waschechten Datenbank mit
SQL-Abfragemöglichkeit. Der Datenbankmotor SQLite kam im Snapshot
schon öfter zum Einsatz, denn er ist einfach unschlagbar einfach zu
installieren. Wie der Abschnitt ``Installation'' zeigen wird, sind
lediglich ein paar CPAN-Module zu laden, und das Skript
erledigt den Rest. Kein Dämon ist zu starten, keine Datebank oder
Tabellen zu definieren, das Skript erledigt alles selbst.
Das Skript kopiert das PDF-Dokument goldt.pdf
nicht in eine Datenbank, sondern in ein Verzeichnis, in
dem alle Dokumente unter durchnumerierten Dateinamen (000001, 000002, ...)
stehen. War die Datenbank bislang leer, hat der Befehl oben ein neues
Dokument ``000001'' erzeugt, das die PDF-Datei goldt.pdf enthält.
Als zweiter Datensatz wandert ein 100-Fragen-Interview von Moritz von Uslar mit Ralph Lauren aus einem SZ-Magazin des Jahrgangs 2004 ins Archiv:
magsafe -m "SZ Magazin"
-t "100 Fragen an Ralph Lauren"
-i 2004/37 -p 56 -d lauren.pdf
Wie man sieht, kann der Autor wegfallen, das kommt manchmal bei Artikeln
in Tageszeitungen vor. Falls magsafe ohne Parameter aufgerufen wird,
fragt es den Benutzer interaktiv nach den Angaben:
$ magsafe
[1] New
[2] Titanic
[3] SZ Magazin
Magazine [1]>
Da vorher schon zwei Zeitschriften eingegeben wurden, hat magsafe
sie sich gemerkt und gibt sie in einem numerierten Menü vor. Für eine
neue Zeitschrift wird einfach 1 gewählt und der Name der Publikation
eingegeben:
Magazine [1]> 1
Enter Magazine Name []> Der Spiegel
Document []> ...
Das mit der Option -d (oder oben interaktiv) angegebene PDF-Dokument
kopiert magsafe in ein Verzeichnis, das intern fest konfiguriert ist. Dabei
numeriert es die einzelnen Dokumente der Reihe nach durch, legt sie
als 000001, 000002, und so weiter im Dokumentenverzeichnis ab und
referenziert die Pfade zu diesen Dateien in der Datenbank.
Eine Datenbank hat natürlich den Vorteil, dass man nach Herzenslust
darin herumsuchen kann. Bei der angelegten Zeitschriftendatenbank, die
in der Datei scanned_docs.dat liegt, geht
das mit dem Kommandozeilenwerkzeug sqlite3 und dem guten alten SQL:
$ sqlite3 scanned_docs.dat
sqlite> SELECT * from doc where
title like '%Ralph%'
2|100 Fragen an Ralph Lauren||2|56|2004/37
CTRL-D
$
Freilich ist das etwas umständlich, deswegen bietet magsafe eine
vereinfachte Abfragesprache an: Wird es mit dem Parameter -s aufgerufen,
erwartet es einen Suchstring im Format
"feld:pattern feld:pattern ..."
Alle Artikel, deren Titel das Wort Ralph enthalten, fördert
magsafe -s title:Ralph
zutage. magsafe formt daraus hinter den Kulissen eine SQL-Abfrage
ähnlich der oben gezeigten, nach dem es den Suchbegriff in Prozentzeichen
eingepackt hat. Wer sich hingegen für alle Artikel interessiert, die
im Jahre 2005 erschienen sind, also im Feld issue den String ``2005''
enthalten, gibt
t -s issue:2005
ein. Wenn das zuviele Treffer anzeigt, beschränkt eine zusätzliche
mag:-Klausel die Abfrage auf alle Artikel aus der Titanic:
t -s "issue:2005 mag:Titanic"
Die von magsafe verwendete Datenbankabstraktion Class::DBI
kam schon einige Male im Snapshot vor. Doch heute geht's sogar noch
einfacher: Das Modul Class::DBI::Loader vereinfacht die Klassendefinition
von Class::DBI weiter, indem es einfach das Datenbanktabellen-Layout
analysiert und daraus die Abstraktionsklassen und deren Beziehungen
generiert.
Listing magsafe zeigt die Implementierung.
Das Modul Getopt::Std verarbeitet die vielen Kommandozeilenoptionen,
die magsafe versteht. Zeile 24 sucht nach -a, -m, -t, und so
weiter. Die Doppelpunkte im Formatstring der getopts-Funktion
bestimmen, dass den Optionen jeweils ein Parameter folgt. Deren Werte
legt getopts im Hash %o zur späteren Verarbeitung ab.
Zeile 26 prüft, ob das Dokumentenverzeichnis zumindest leer existiert und beschreibbar ist. Falls nicht, sollte es vor dem Programmstart angelegt werden.
Die in Zeile 29 aufgerufene Funktion db_init() sorgt dafür, dass
der Benutzer sich niemals mit den Details der Datenbank herumschlagen muss.
Existiert sie noch nicht, legt db_init() ab Zeile 108 mit
einigen SQL-Befehlen eine neue Datenbank
mit zwei Tabellen an. Abbildung 2 zeigt das Schema.
|
| Abbildung 2: Das Layout der SQLite-Datenbank |
Die Tabelle doc enthält für jedes abgespeicherte Dokument eine
Zeile.
Neben dem Titel des Artikels, dem Autor, der Ausgabe und der Seite steht
dort auch der Name der Publikation, aus der er extrahiert wurde.
Da die meisten Leute nur eine begrenzte
Anzahl von Zeitschriften lesen,
diese aber jeden Monat, wäre es schlechtes Datenbankdesign, den
vollständigen Zeitschriftentitel in jede Zeile hineinzuschreiben.
Statt dessen steht in der Tabelle doc im Feld mag eine
numerische ID, die auf eine Zeile der Tabelle mag verweist, die
neben der ID den ausgeschriebenen Zeitschriftentitel führt.
Die Tabellendefinitionen ab Zeile 112 in magsafe
folgen dem SQL-Standard, lediglich die Zeile
mag INT REFERENCES mag,
in der doc-Tabelle ist etwas ungewöhnlich: So wird angezeigt,
dass die mag-Spalte auf die Tabelle mag verweist, um die oben
erläuterte Beziehung zwischen Zeitschriften-ID und -titel
herzustellen.
Die ersten Spalten beider Tabellen sind numerische IDs, die als
Primary Keys markiert sind. Der in Zeile 31 aufgerufene Konstruktor
der Klasse Class::DBI::Loader erwartet sie, um Objekten, die
Tabellenzeilen repräsentieren, eindeutige IDs zuweisen zu können.
Neu angelegten Objekten weist er automatisch noch nicht vergebene
IDs zu, indem er die höchste bisher vergebene ID um eins hochzählt.
Die Zeile
namespace => "Scanned::DB"
im Konstruktoraufruf des Datenbank-Laders
bestimmt, dass alle Klassen zur Tabellenabstraktion im Namensraum
Scanned::DB erscheinen.
Nach dem Aufruf von Class::DBI::Loader->new() holt die
Methode find_class(), wie in den Zeilen 39 und 40 vorgeführt,
Objekte hervor, die die Tabellen repräsentieren.
Hierzu nehmen sie den Tabellennamen als Argument entgegen.
$docdb, ein Objekt vom Typ Scanned::DB::Doc weist auf
die Dokumententabelle docdb, $magdb (Scanned::DB::Mag)
auf die Zeitschriftentabelle
mag.
Damit die Objekte nicht nur die Standard-Queries von Class::DBI
beherrschen, sondern auch etwas kompliziertere WHERE-Klauseln,
holt der Parameter additional_classes in Zeile 34 das Paket
Class::DBI::AbstractSearch hinzu.
Das Flag relationships in Zeile 36 weist Class::DBI::Loader an,
die Beziehungen zwischen den Tabellen doc und mag zu analysieren
und zu verdrahten. Wegen der oben erwähnten REFERENCES-Klausel
im SQL begreift es sofort, dass die Spalte mag in der Tabelle
doc nur ein foreign key ist, der in die Tabelle mag verzweigt.
Die objektorientiert Datenbankabstraktion
wird dann Methoden bereitstellen, um schnell zwischen den beiden
Tabellen zu manövrieren.
Ab Zeile 44 verarbeitet das Skript Kommandozeilenparameter. Ist
-s nicht gesetzt, sucht der Benutzer nicht nach einem
Datenbankeintrag, sondern möchte einen neuen hinzufügen. Die Zeilen
47 bis 50 nehmen die Werte der verschiedenen Kommandozeilenoptionen wie Titel,
Autor, Dokument, Zeitschrift, Seite und Ausgabe entgegen. Falls eine oder
mehrere nicht gesetzt sind, holt die Funktion ask() aus dem
Modul Sysadm::Install die entsprechenden Werte interaktiv beim
Benutzer ab. Nur das Autorenfeld ist optional und wird nicht eingefordert.
Die Auswahl der Zeitschrift ist etwas komplizierter und erfolgt mit
der ab Zeile 133 definierten Funktion mag_pick. Dort holt die
Methode retrieve_all() des Datentabellenobjekts $magsdb alle
bisher eingegebenen Zeilen der mag-Tabelle ein. Die zurückgelieferten
Zeilenobjekte bieten Methoden an, um zu den Werten für die
einzelnen Felder des Datensatzes zu gelangen: $obj->name() gibt
so den Namen der Zeitschrift zurück, der im Feld name der Tabelle
mag liegt.
Falls $picked
noch nicht gesetzt ist, also keine Kommandozeilenoption für den
Zeitschriftennamen vorliegt, lässt Zeile 141 den Benutzer mit
pick() (ebenfalls aus Sysadm::Install) per Menü einen Namen
auswählen. Selektiert der aber den ersten, New betitelten Eintrag,
invalidiert Zeile 143 die Auswahl wieder und ab Zeile 147 hat der
Benutzer Gelegenheit, den Namen einer neuen Zeitschrift einzugeben,
der dann nächstes Mal in der Auswahlliste erscheinen wird.
Die Methode find_or_create() erzeugt dann in der Tabelle mag
entweder einen neuen Zeitschrifteneintrag oder findet den zum
angegebenen Namen passenden.
Neu eingetragene oder bereits bestehende Zeitschriften repräsentiert
dann die Variable $mag in Zeile 45. Weil Class::DBI::Loader
vorher ganze Arbeit geleistet hat und wegen des relationships-Flags
auch die Beziehungen zwischen den Tabellen doc und mag analysiert
hat, kann Zeile 52 einfach $mag->add_to_docs() aufrufen, um
einen Artikel in die Tabelle doc einzufügen, dessen mag-Spalte
auf einen Eintrag der Zeitschrift in der mag-Tabelle verweist.
Die Methode add_to_docs() des Zeitschriftenobjekts wurde wirklich
nicht explizit in magsafe definiert.
Sie entsteht automatisch in der Datenbankabstraktion, sobald die Beziehung
zwischen den Tabellen feststeht.
Um das aktuelle PDF-Dokument in das Dokumentenverzeichnis zu kopieren,
ruft magsafe in Zeile 59 die cp-Funktion aus Sysadm::Install auf.
Den vollständigen, zukünftigen Pfad der Datei ermittelt die ab
Zeile 96 definierte Funktion docpath(), die lediglich die ihr
übergebene ID in einen sechstelligen Integer mit führenden Nullen
umwandelt und den Dokumentenverzeichnispfad davor hängt.
Liegt eine Suchabfrage vor, iteriert Zeile 65 über alle
feld:pattern-Paare, die mit dem Parameter -s hereingereicht
wurden und durch Leerzeichen voneinander getrennt sind.
Zeile 67 spaltet dann den Feldnamen vom gesuchten Wert ab.
Ist der Feldname mag, sucht Zeile 70 erst nach einer passenden
Zeitschrift, indem es den Suchwert in Prozentzeichen einrankt und
eine Suchabfrage mit search_like() in der Tabelle mag startet.
Dies kann keine, ein oder mehrere Magazin-Objekte
in @mags zurückliefern. Deren id()-Methode fördert die Magazin-IDs
hervor, sodass Zeile 73 im Hash %search das Wertepaar
"mag" => [$id1, $id2, ...]
ablegt. $id1, $id2, und so weiter sind die numerischen IDs für die
auf die Suchanfrage passenden Zeitschrifen, und der Hasheintrag unter
"mag" weist auf eine Referenz eines Arrays, der sie alle als Elemente
enthält.
Spezifiert der Benutzer
hingegen eine Suchabfrage mit einer Bedingung auf ein anderes Feld als mag,
tritt der else-Zweig ab Zeile 75 in Kraft und der Suchbegriff wird,
in Prozentzeichen eingeschlossen, unter dem Feldnamen im Hash %search
abgespeichert.
Der Inhalt des Hashes %search entspricht nun genau dem Format, das die
in Zeile 80 aufgerufene Methode search_where() der Dokumententabelle
erwartet. Sie liefert alle Zeilen, auf die alle im Hash angegebenen
Bedingungen passen. Da in einem zusätzlichen optionalen Hash noch der
Compare-Parameter cmp auf "like" gesetzt wird, sucht
search_where() nicht
nach wörtlich passenden Einträgen, sondern nach Patterns,
deren Wildcards gemäß dem SQL-Standard mit % notieren.
Der print-Befehl in Zeile 83 wird wegen des nachgestellten
for @objs für jedes gefundene Objekt aufgerufen. Er fasst alle
Spalten eines gefundenen Tabelleneintrags zusammen und gibt sie
formatiert aus. Den Pfad zum PDF-Dokument setzt wieder die vorher
schon erwähnte Funktion docpath() zusammen. Findet die Suchabfrage
keine Treffer, gibt Zeile 92 No Entries auf STDERR aus.
001 #!/usr/bin/perl -w
002 ###########################################
003 # magsafe - Archive magazine articles
004 # Mike Schilli, 2005 (m@perlmeister.com)
005 ###########################################
006 use strict;
007
008 use DBI;
009 use Class::DBI::Loader;
010 use Sysadm::Install qw(:all);
011 use Getopt::Std;
012 use Text::Iconv;
013
014 my $DB_NAME = "/home/mschilli/DATA/scanned_docs.dat";
015 my $DSN = "dbi:SQLite:$DB_NAME";
016 my $UTF8_TERM = 1;
017
018 my $cv = Text::Iconv->new(
019 "Latin1", "utf8");
020 $cv->raise_error(1);
021
022 my $DOC_DIR = "/ms1/DOCS";
023
024 getopts("a:m:t:i:p:d:s:", \my %o);
025
026 die "$DOC_DIR not ready" if
027 !-d $DOC_DIR or !-w $DOC_DIR;
028
029 db_init($DSN) unless -e $DB_NAME;
030
031 my $loader = Class::DBI::Loader->new(
032 dsn => $DSN,
033 namespace => "Scanned::DB",
034 additional_classes =>
035 qw(Class::DBI::AbstractSearch),
036 relationships => 1,
037 );
038
039 my $docdb = $loader->find_class("doc");
040 my $magdb = $loader->find_class("mag");
041
042 my @objs = ();
043
044 if(! exists $o{s}) {
045 my $mag = mag_pick($magdb, $o{m});
046 my $doc = $o{d} || ask "Document", "";
047 my $author = $o{a} || "";
048 my $title = $o{t} || ask "Title", "";
049 my $page = $o{p} || ask "Page", "";
050 my $issue = $o{i} || ask "Issue", "";
051
052 my $id = $mag->add_to_docs({
053 map { $UTF8_TERM ? $_ : $cv->convert($_) }
054 title => $title,
055 page => $page,
056 issue => $issue,
057 author => $author});
058
059 cp $doc, docpath($id);
060 exit 0;
061 }
062
063 my %search = ();
064
065 for (split ' ', $o{s}) {
066
067 my($field, $expr) = split /:/, $_;
068
069 if($field eq "mag") {
070 my @mags = $magdb->search_like(
071 name => "%$expr%");
072
073 $search{$field} = [
074 map { $_->id() } @mags];
075 } else {
076 $search{$field} = "%$expr%";
077 }
078 }
079
080 @objs = $docdb->search_where(\%search, {cmp => "like"});
081
082 if(@objs) {
083 print join(", ",
084 '"' . $_->title() . '"' ,
085 $_->author() || "Unknown",
086 $_->mag()->name(),
087 $_->issue(),
088 $_->page(),
089 docpath($_->docid())),
090 "\n" for @objs;
091 } else {
092 print STDERR "No entries\n";
093 }
094
095 ###########################################
096 sub docpath {
097 ###########################################
098 my($id) = @_;
099
100 return sprintf "%s/%06d",
101 $DOC_DIR, $id;
102 }
103
104 ###########################################
105 sub db_init {
106 ###########################################
107 my($dsn) = @_;
108
109 my $dbh = DBI->connect($dsn, "", "");
110
111 $dbh->do(q{
112 CREATE TABLE doc (
113 docid INTEGER
114 PRIMARY KEY,
115 title VARCHAR(255),
116 author VARCHAR(255),
117 mag INT REFERENCES mag,
118 page INT,
119 issue VARCHAR(32)
120 );
121 });
122
123 $dbh->do(q{
124 CREATE TABLE mag (
125 magid INTEGER
126 PRIMARY KEY,
127 name VARCHAR(255)
128 )
129 });
130 }
131
132 ###########################################
133 sub mag_pick {
134 ###########################################
135 my($magsdb, $picked) = @_;
136
137 my @mags = map { $_->name() }
138 $magsdb->retrieve_all();
139
140 if(@mags and !$picked) {
141 $picked = pick "Magazine",
142 ["New", @mags], 1;
143 undef $picked if $picked eq "New";
144 }
145
146 if(!$picked) {
147 $picked = ask
148 "Enter Magazine Name", "";
149 }
150
151 $picked = $UTF8_TERM ? $picked :
152 $cv->convert($picked);
153
154 my $mag = $magsdb->find_or_create(
155 {name => $picked});
156 return $mag;
157 }
Zu beachten ist, dass SQLite Strings als UTF-8 erwartet, mit Umlauten
im ISO-8859-1-Format kommt es nicht zurecht.
Ob die Eingabe der Kommandozeilenparameter mit UTF8- oder ISO-8859-1-
Kodierung erfolgt, hängt aber ganz vom lokal betriebenen Terminal ab.
Viele neuere Linux-Distributionen haben UTF-8-Terminals, ältere fahren
typischerweise ISO-8859-1. Einen Hinweis darauf gibt üblicherweise
die Environment-Variable LANG: Steht dort etwas wie en_US.UTF-8,
liegt UTF-8 vor.
Das Skript lässt sich an die äußeren Bedingungen anpassen:
Ist die Variable
$UTF8_TERM in Zeile 16 auf einen wahren Wert gesetzt, interpretiert
das Skript alle Benutzereingeaben als UTF-8 und nimmt keine Umkodierung
vor. Ist $UTF8_TERM hingegen 0, nimmt magsafe an, dass Benutzereingaben
als ISO-8859-1 erfolgen und wandelt alles in UTF-8 um, bevor sie in die
Datenbank wandern.
Für etwaige Umwandlungen zieht
magsafe das Iconv-Modul vom CPAN heran.
Zeile 18 erzeugt ein Objekt vom Typ Text::Iconv für die
Transformation von ISO-8859-1 nach UTF-8. Weiter aktiviert es
dessen Methode raise_error() mit 1, damit etwaige Fehler sofort
eine Exception werfen. Die Methode convert() wandelt ihr
übergebene Strings von der einen in die andere Kodierung um.
Eine andere Methode wäre das Encode-Modul, für diejenigen,
die mindestens perl 5.8.x fahren.
Für die Datenbankabstraktion benötigt das Skript die Module
DBI, Class::DBI, Class::DBI::SQLite, Class::DBI::AbstractSearch, und
DBD::SQLite, die es allesamt auf dem CPAN gibt. Weiter kommen
Text::Iconv und Sysadm::Install zum Einsatz. Alle Module
lassen sich einfach mit einer CPAN-Shell installieren.
Wer den Commandline-Client sqlite3 will, um manuell mit SQL
in der Datenbank
herumzuschnüffeln, lädt sich am besten den Source-Tarball von [3]
herunter, compiliert und installiert ihn.
Frührere Versionen (sqlite 1 oder 2) funktionieren nicht,
da DBD::SQLite zur Zeit auf sqlite 3.x basiert, und Datenbanken,
die mit verschiedenen sqlite-Versionen erzeugt wurden, nicht kompatibel
sind. Das Kommandozeilentool der 3er-Version heisst sqlite3, im Gegensatz
zu den Tools früherer Versionen, die sqlite heißen.
Achtung: Wer bereits SQLite-Datenbanken für die ein oder andere Anwendung
mit früheren Versionen des Moduls DBD::SQLite
fährt (wie zum Beispiel die aus [5]), und diese weiter nutzen will,
sollte diese vor dem Upgrade ins neue Format überführen:
sqlite OLD.DB .dump | sqlite3 NEW.DB
Liegt das Modul DBD::SQLite nämlich einmal in der neuesten Version vor, kann es Datenbanken, die mit früheren Versionen erzeugt wurden, nicht mehr lesen.
Das Dokumentenverzeichnis legt die Variable $DOC_DIR in Zeile
22 des magsafe-Skripts fest. Das Verzeichnis sollte bereits vorliegen und
beschreibbar sein. Der Rest geht automatisch: SQLite wird selbständig
die Datenbank in der Datei scanned_docs.dat (Name festgelegt in Zeile
14) anlegen und die Tabellen initialisieren.
Interessante Artikel ausgelesener Zeitschriften scannen gewitzte Leser nun flugs ein und werfen das Druckerzeugnis anschließend in den Altpapiercontainer. Und der Lebenspartner freut sich über den freiwerdenden Wohnraum.
![]() |
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. |