Einmerkerl von Welt (Linux-Magazin, Mai 2004)

[Anmerkung für die Preiss'n: ``Einmerkerl'' ist bayrisch für ``Lesezeichen''.] Egal ob der Browserbenutzer im Büro sitzt, zuhause surft oder vom Hotelzimmer aus mit dem Laptop das Web abgrast: Ein global zugängliches CGI-Skript gewährleistet, dass immer dieselbe Bookmark-Liste verfügbar ist.

Das heute vorgestellte CGI-Skript nutze ich seit einiger Zeit, um meine wichtigsten URLs überall griffbereit zu haben. Um eine einmal in der Liste gespeicherte Website anzuwählen, klicke ich einfach auf meinen neuen Eintrag ``Bookmarks'' im Toolbar, der auf das Skript zeigt, und eine Seite nach Abbildung 1 anzeigt. Ein Mausklick auf einen Eintrag verzweigt sofort auf die entsprechende Webseite.

Hinter jedem Eintrag in Abbildung 1 steht eine Reihe von klickbaren Operatoren: + (nach oben), - (nach unten) und x (löschen), mit denen sich sowohl Ordner als auch Links herumschieben lassen, um die Bookmark-Liste für den Benutzer schön strukturiert und übersichtlich zu halten.

Abbildung 1: Die globale Bookmark-Liste in Aktion. Als neues "Einmerkerl" wird ein Link auf die "Perlmonks"-Seite im "Perl"-Ordner angelegt.

Kein Abtippen dank JavaScript

Mittels des unter der Liste sichtbaren Web-Formulars kann man neue URLs in Ordner einfügen und permanent speichern. Um eine gerade vom Browser dargestellte Webseite neu in die Bookmarkliste aufzunehmen, will aber natürlich niemand URLs von Hand abtippen. Vielmehr soll der Titel der gerade dargestellten Webseite und ihr URL einfach per Mausklick in die Bookmark-Liste wandern.

Hierzu greifen wir in die JavaScript-Trickkiste: Moderne Browser erlauben in den definierbaren Bookmark-Einträgen der Toolbar-Leiste nicht nur URLs, sondern auch JavaScript-Code. Klickt der Benutzer auf einen Toolbar-Eintrag, der folgenden Code enthält, extrahiert der Browser, Titel und URL der gerade dargestellten Webseite, öffnet ein neues Fenster, ruft dort das Bookmark-CGI-Skript auf und füllt Titel und URL gleich ins Webformular ein:

    javascript:void(win=window.open('http://myserver.com/cgi/bm?a='+location.href+'&t='+document.title))

Der Benutzer wählt dann nur noch einen Ordner aus der angebotenen Liste aus (oder gibt den Namen eines neuen ein) und klickt Submit, um den Eintrag einzuspeichern.

Der obige JavaScript-URL gelangt einfach über den Bookmarks->Manage Bookmarks-Dialog in die Toolbar-Leiste, wie Abbildung 2 zeigt. Außer diesem Toolbar-Eintrag, den wir ``Add'' nennen, sollte auch noch der eingangs erwähnte ``Bookmarks''-Eintrag in die Toolbar-Leiste, der einfach zum Skript zeigt, das die bisher definierten Bookmarks zur Auswahl anbietet. Dies sollte bei jedem neuen Browser, den man längere Zeit benutzt, durchgeführt werden -- ab dann ist die weltweit verfügbare Bookmarkliste nur einen Mausklick entfernt. Damit man den etwas komplexen Java-Script-Eintrag nicht auswendig lernen oder abtippen muss, stellt das Skript ihn praktischerweise am unteren Ende für ein einfaches Cut-and-Paste dar.

Abbildung 2: Mit JavaScript lässt sich ein Toolbar-Shortcut definieren, der Titel und URL der gegenwärtig dargestellten Browserseite an das Bookmark-Skript schickt.

Die Implementierung ist auf zwei Dateien verteilt: Ein Modul Bookmarks.pm, das die Funktionalität der Bookmark-Liste implementiert, und das Skript bm, das sich um die Darstellung im Web-Browser kümmert und Benutzereingaben verarbeitet.

Baum mit Ordnern

Die Bookmark-Hierarchie legt Bookmarks.pm in einer Baumstruktur ab. Das Modul Tree::DAG_Node von Sean Burke erzeugt und manipuliert gerichtete azyklischen Graphen und eignet sich hervorragend, um die in Ordnern liegenden Bookmarks zu implementieren. Sowohl Ordner als auch Bookmarks sind Knoten (Nodes) im Graphen, der an der Wurzel (root) beginnt. Der @ISA-Array in Zeile 11 von Bookmarks.pm bestimmt, dass Bookmarks eine von Tree::DAG_Node abgeleitete Klasse ist. Ein Objekt vom Typ Bookmarks repräsentiert einfach den Wurzelknoten des Baums, der wiederum alle Ordner als Unterknoten enthält, die wiederum die Bookmarks mit URL und Text, wiederum als Unterknoten, enthalten.

Ererbter Konstruktor

Bookmarks.pm definiert keinen Konstruktor new(). Deshalb wird Bookmarks->new() einfach an Tree::DAG_Node weitergeleitet. Objekte vom Typ Tree::DAG_Node führen neben Knoten-typischen Instanzvariablen auch ein Attribut attributes, hinter dem ein Hash hängt, in den applikationsspezifische Attribute passen, ohne mit den Knotenattributen zu kollidieren. Einen neuen Bookmark-Order erzeugt so

     Bookmarks->new({
       attributes => {
         type => "folder",
         path => "Perl",
       }
     });

und einen neuen Knoten, der einen Bookmark-Eintrag führt, kreiert dieses Konstrukt:

     Bookmarks->new({
       attributes => {
         type => "entry",
         text => $text,
         link => $link,
       }
     });

Abbildung 3 zeigt, wie die Ordner unter der Baumwurzel hängen und jeweils ein oder mehrere Bookmarks enthalten.

Abbildung 3: Der Baum, in dem die Bookmarks in den dazugehörigen Ordnern hängen.

[Abbildung 3 ist auch als fig/tree.svg vorhanden].

Das Attribut type dient der Applikation dazu, zwischen Ordnern und Bookmark-Einträgen zu unterscheiden. Beide Konstruktor-Aufrufe führt Bookmarks.pm nicht direkt aus, sondern über die Methode new_daughter(), die aber hinter den Kulissen ein new() der Applikationsklasse aufruft. Mehr davon später.

Die ab Zeile 14 in Bookmarks.pm definierte Methode insert() nimmt als Parameter den Text und URL eines neuen Bookmark-Eintrags sowie den Namen des Ordners entgegen, in dem dieser zu liegen kommt. Als erstes Argument kommt der Wurzelknoten herein, da der Aufruf über

    $bm->insert(...)

erfolgt und $bm das Wurzelobjekt des Baums ist. Dessen Kinder, also die Ordner, fördert in Zeile 22 die daughters()-Methode zutage. In matriarchaischen Tree::DAG_Node gibt es nur Mütter mit Töchtern, Väter und Söhne hat Autor Sean Burke wohl augenzwinkernd ausgespart.

Ist der angegebene Ordner nicht darunter, erzeugt Zeile 32 einen neuen als Kind der Wurzel. Und Zeile 41 erzeugt anschließend den Bookmark-Eintrag als Kind des Ordners.

Die ab Zeile 51 in Bookmarks.pm definierte Methode folders gibt eine Liste der Namen aller Ordner zurück, die nutzt später das CGI-Skript, um die bestehenden Ordner in einer Auswahlliste anzubieten.

Hausnummern

Um einen Knoten innerhalb des Baums zu identifizieren, bietet Tree::DAG_Node die Methode address() an, die den Weg von der Wurzel zum jeweiligen Knoten als Folge von Indizes beschreibt. Der zweite Eintrag (Index 1) des dritten Ordners (Index 2) hört so auf den Namen "0:2:1".

Umgekehrt kommt man von dieser Hausnummer auf das damit referenzierte Knotenobjekt, indem man sie irgendeinem Baumobjekt (z. B. der Wurzel) als Parameter der address()-Methode übergibt:

    my $node = $bm->address("0:2:1");

Die Hausnummer nutzt das CGI-Skript später, um herauszufinden, von welchem Knoten der Benutzer den Navigationslink (rauf, runter, löschen) angeklickt hat. Die Methode as_html() ab Zeile 60 in Bookmarks.pm gibt eine HTML-Darstellung des Bookmark-Baumes zurück und ruft sowohl für Ordner als auch Bookmark-Einträge eine als Referenz $nav hereingegebene Funktion auf, der es die Hausnummer des jeweiligen Knotens übergibt. So kann das aufrufende Skript bestimmen, wie die Navigationslinks jedes Eintrags aussehen. as_html() nutzt die praktischen Funktionen aus dem CGI-Modul, um HTML-Sequenzen zu erzeugen.

Rauf und Runter

Die Methoden move_up und move_down nehmen jeweils eine Hausnummer entgegen und befördern das damit referenzierte Knotenobjekt nach oben oder unten. Sowohl Ordner als auch Bookmark-Einträge können so innerhalb ihres Eltern-Containers umherwandern.

Tree::DAG_Node malt die Kinder eines Elternknotens von links nach rechts, nicht wie in der Bookmarkliste von oben nach unten. Die in einer Zeile aufgereihten Einträge eines Ordners starten also im Baum links mit dem ersten Eintrag und setzen sich nach rechts bis zum letzten fort. Tree::DAG_Node bietet zwar keinen direkten Weg, um einen Knoten nach links oder rechts zu verschieben, aber man kann den Nachbarsknoten bestimmen (left_sister() oder right_sister()), den gegenwärtigen Knoten aus dem Eltern-Container entfernen ($node->unlink_from_mother() und ihn dann entweder links oder rechts vom linken bzw. rechten Nachbarn wieder einfügen. Genau dies tun move_up und move_down mit einem als Hausnummer referenzierten Knoten.

Die delete()-Methode entfernt einen Knoten vom Eltern-Container. Handelt es sich um einen Ordner, verschwinden auch die in ihm enthaltenen Bookmark-Einträge auf Nimmerwiedersehen.

Permanenz mit Storable

Als Datenbank, die den Zustand der Bookmarkliste zwischen den Aufrufen des CGI-Skripts speichert, nutzt Bookmarks.pm das Modul Storable, das komplizierte und verschachtelte Datenstrukturen einfach mit store speichert und mit restore wiederholt. Die Methoden save und restore aus Bookmarks.pm tun dies jeweils mit dem Wurzelobjekt des Baums und schleifen damit indirekt den ganzen Baum mit. Zu beachten ist, dass store() auf einer Instanzvariablen wie in

    $bm->store($file);

aufgerufen wird, während restore eine Klassenmethode ist, die mit

    my $bm = Bookmarks->restore($file);

einen in der angegebenen Datei abgelegten Baum ausgräbt und die Instanz eines Bookmarks-Objekts zurückgibt.

In den Browser per CGI

Das CGI-Skript bm (Listing 2) übernimmt die Benutzerführung im Browser. Es zieht das Modul CGI für die HTML-Sequenzen herein und spezifiziert fatalsToBrowser für CGI::Carp, um im Fehlerfall schön formatierte Fehlermeldungen im Browser anzuzeigen, anstatt sich mit Internal Server Error davonzuschleichen. Außerdem kommt natürlich das vorher erläuterte Bookmarks-Modul zum Einsatz.

In der Variablen $DB_FILE steht in Zeile 9 der Name der Datei, in der Bookmarks.pm den Baum permanent per Storable::store sichert.

Zeile 20 prüft, ob Parameterwerte für URL und Text vorliegen (u und t) und ob der Submit-Knopf gedrückt wurde, was über den Parameter s angezeigt wird, ein versteckter (hidden) Parameter im weiter unten angezeigten Web-Formular. Dies ist notwendig, damit das CGI-Skript unterscheiden kann, ob nur der JavaScript-Toolbareintrag Titel und URL der gerade angezeigten Webseite sandte oder ob der Benutzer schon einen Ordner ausgewählt und den Submit-Knopf gedrückt hat. Im letzeren Fall holt Zeile 23 den Namen des Ordners und Zeile 25 prüft, ob der Benutzer nicht den Namen eines neu anzulegenden Ordners (angezeigt im Parameter fnew) in das Textfeld eingetragen hat und damit diesen favorisiert. Liegt kein Ordner vor, bricht Zeile 26 mit einem Fehler ab. Sonst fügt Zeile 28 mit der insert()-Methode den neuen Eintrag in die Datenbank ein.

Hat der Benutzer auf einen Navigationslink gedrückt, sind entweder del, mvu (move up), oder mvd (move down) gesetzt und die Zeilen 31 bis 33 rufen die passende Methode aus Bookmarks.pm auf, um die Baumstruktur zu manipulieren.

Anschließend folgt die HTML-Ausgabe, eingeleitet vom HTTP-Header in Zeile 36 und gefolgt von der HTML-Repräsentation des Bookmark-Baumes in Zeile 39. Zeile 40 sichert eine eventuell modifizierte Baumstruktur permanent auf Platte.

Die print-Anweisung ab Zeile 42 malt das Webformular, das Modul CGI sorgt dafür, dass die Felder entsprechend den vorliegenden CGI-Parametern vorbesetzt werden. Das ab Zeile 48 erzeugte Popup-Menü mit den Namen aller existierenden Ordner entsteht mit Hilfe der in Bookmarks.pm definierten folders()-Methode.

Zeile 60 gibt den an den örtlichen URL angepassten JavaScript-Eintrag aus, den der Benutzer im Toolbar eintragen muss, damit neue Einträge einfach per Mausklick im Baum landen.

Die in Zeile 39 aufgerufene as_html()-Methode erhielt eine Referenz auf die Funktion nav() mit, die ab Zeile 66 definiert ist. Sie gibt das HTML für die hinter jedem Eintrag angezeigte Navigationsliste zurück. Wie oben ausgeführt, ruft as_html() die Funktion nav() für jeden angezeigten Eintrag auf, und übergibt jeweils die Hausnummer. Die ist dort als $n verfügbar und wird an die Links angehängt, die auf das CGI-Skript selbst zurückdeuten und Navigationsanweisungen wie mvu, mvd oder del enthalten.

Installation

Bookmarks.pm nutzt Tree::DAG_Node und Storable vom CPAN. Sind sie installiert, muss bm ausführbar ins cgi-bin-Verzeichnis des Webservers und Bookmarks.pm entweder ins selbe Verzeichnis oder an eine Stelle, an der bm danach sucht.

Damit nicht die ganze Welt die Bookmarkliste manipuliert, schützt der Webserver sie auf einem Shared-System mit einer .htaccess-Datei a la

    AuthType Basic
    AuthName "Mike's Bookmarks"
    AuthUserFile /var/www/htpasswd
    Require valid-user

zumindest per Basic Auth mit einem Passwort, das der Admin einmal per

    htpasswd username /var/www/htpasswd

setzt und das dann vom Webserver beim ersten Zugriff eingefordert wird. Sicher ist das nicht, da das Passwort quasi im Klartext über die Leitung geht, aber für meine Zwecke reicht's.

Einschränkungen

Das Skript geht davon aus, dass jeweils nur ein Benutzer es nutzt und trifft keine Vorkehrungen um Zugriffe auf die permanenten Daten zu synchronisieren.

Und es handelt nach der Unix-Philosophie, dass ein fortgeschrittener Benutzer immer weiß, was er tut: Einmal auf das ``x'' eines Ordners geklickt, und schon verschwindet dieser mitsamt der in ihm enthaltenen Links auf Nimmerwiedersehen im Orkus.

Wer weitere Manipulationsmöglichkeiten wünscht, wie zum Beispiel das Umbenennen von Ordnern oder Unter-Ordner, kann das Skript erweitern. Wen die in der Datenbankdatei abgelegte Datenstruktur interessiert, kann mittels des dumpsto-Skripts [2] einen Dump der Storable-Datei erzeugen, mit einem Editor darin herumfuhrwerken und anschließend die Daten via dumpsto -u wieder in eine Storable-Datei überführen.

Listing 1: Bookmarks.pm

    001 ###########################################
    002 package Bookmarks;
    003 ###########################################
    004 # Administer browser bookmarks
    005 # Mike Schilli, 2004, m@perlmeister.com
    006 ###########################################
    007 
    008 use Storable;
    009 use CGI qw(:all *dl *dt);
    010 use Tree::DAG_Node;
    011 our @ISA = qw(Tree::DAG_Node);
    012 
    013 ###########################################
    014 sub insert {
    015 ###########################################
    016     my($self, $text, 
    017        $link, $folder_name) = @_;
    018 
    019     my $folder;
    020 
    021       # Search folder node
    022     for($self->daughters()) {
    023       if($_->attributes()->{path} eq
    024          $folder_name) {
    025         $folder = $_;
    026         last;
    027       }
    028     }
    029 
    030       # Not found? Create it.
    031     unless(defined $folder) {
    032       $folder = $self->new_daughter(
    033         { attributes => {
    034             type => "folder",
    035             path => $folder_name,
    036           },
    037         });
    038     }
    039 
    040       # Add it
    041     return $folder->new_daughter(
    042       { attributes => {
    043           type => "entry",
    044           text => $text,
    045           link => $link,
    046         },
    047       });
    048 }
    049 
    050 ###########################################
    051 sub folders {
    052 ###########################################
    053     my($self) = @_;
    054     
    055     return map { $_->attributes()->{path} } 
    056                         $self->daughters();
    057 }
    058 
    059 ###########################################
    060 sub as_html {
    061 ###########################################
    062     my($self, $nav) = @_;
    063 
    064     my $html = start_dl();
    065 
    066     for my $folder ($self->daughters()) {
    067 
    068       $html .= dt(
    069         b($folder->attributes()->{path}), 
    070         $nav->($folder->SUPER::address()));
    071 
    072       for my $bm ($folder->daughters()) {
    073         my $bma = $bm->SUPER::address();
    074 
    075         my($link, $text) = 
    076           map { $bm->attributes()->{$_} } 
    077           qw(link text);
    078 
    079         my $a = $bm->attributes();
    080 
    081         $html .= dd(a({href => $link},
    082                       $text), $nav->($bma));
    083       }
    084     }
    085 
    086     $html .= end_dl();
    087 
    088     return $html;
    089 }
    090 
    091 ###########################################
    092 sub move_up {
    093 ###########################################
    094     my($self, $address) = @_;
    095 
    096     my $node = 
    097            $self->SUPER::address($address);
    098     if(my $left = $node->left_sister()) {
    099         $node->unlink_from_mother();
    100         $left->add_left_sister($node);
    101     }
    102 }
    103 
    104 ###########################################
    105 sub move_down {
    106 ###########################################
    107     my($self, $address) = @_;
    108 
    109     my $node = 
    110            $self->SUPER::address($address);
    111     if(my $right = $node->right_sister()) {
    112         $node->unlink_from_mother();
    113         $right->add_right_sister($node);
    114     }
    115 }
    116 
    117 ###########################################
    118 sub delete {
    119 ###########################################
    120     my($self, $address) = @_;
    121 
    122     my $node = 
    123            $self->SUPER::address($address);
    124     $node->unlink_from_mother();
    125 }
    126 
    127 ###########################################
    128 sub restore {
    129 ###########################################
    130     my($class, $filename) = @_;
    131     my $self = retrieve($filename) or
    132       die "Cannot retrieve $filename ($!)";
    133 }
    134 
    135 ###########################################
    136 sub save {
    137 ###########################################
    138     my($self, $filename) = @_;
    139     store $self, $filename or
    140         die "Cannot save $filename ($!)";
    141 }
    142 
    143 1;

Listing 2: bm

    01 #!/usr/bin/perl
    02 ###########################################
    03 # bm -- Administer bookmarks CGI
    04 # Mike Schilli, 2004 (m@perlmeister.com)
    05 ###########################################
    06 use warnings;
    07 use strict;
    08 
    09 my $DB_FILE = "/tmp/bm.sto";
    10 
    11 use CGI qw(:all *table);
    12 use CGI::Carp qw(fatalsToBrowser);
    13 use Bookmarks;
    14 
    15 my $bm = Bookmarks->new();
    16 
    17 $bm = Bookmarks->restore($DB_FILE) if 
    18                               -f $DB_FILE;
    19 
    20 if(param('t') and param('a') and 
    21    param('s')) {
    22   my $f = param('f');
    23 
    24     # String overrides box selection
    25   $f = param('fnew') if param('fnew');
    26   die "No folder defined" unless length($f);
    27     
    28   $bm->insert(param('t'), param('a'), $f);
    29 }
    30 
    31 $bm->delete(param('del')) if param('del');
    32 $bm->move_up(param('mvu')) if param('mvu');
    33 $bm->move_down(
    34              param('mvd')) if param('mvd');
    35 
    36 print header(),
    37       start_html(-title => "Bookmarks");
    38 
    39 print $bm->as_html(\&nav);
    40 $bm->save($DB_FILE);
    41 
    42 print start_form(),
    43   start_table(),
    44   TR(td("Title"), td(textfield(
    45     -name => 't', -size => 80))),
    46   TR(td("URL"), td(textfield(
    47     -name => 'a', -size => 80))),
    48   TR(td("Folder"), td(popup_menu(
    49     -name => 'f', -values =>
    50                   [$bm->folders()]))),
    51   TR(td("New Folder"), td(textfield(
    52     -name => 'fnew', -size => 80))),
    53   end_table(),
    54   hidden(s => 1),
    55   submit(),
    56   end_form(),
    57   end_html(),
    58   ;
    59 
    60 print "Use this in your toolbar: ",
    61   pre("javascript:void(win=window.open('" .
    62   url(-path_info => 1) . "?a='+location." .
    63   "href+'&t='+document.title))");
    64 
    65 ###########################################
    66 sub nav {
    67 ###########################################
    68   my($n) = @_;
    69 
    70   return " [" .
    71     a({href => url() . "?mvu=$n"}, 
    72       "+") . " " .
    73     a({href => url() . "?mvd=$n"}, 
    74       "-") . " " .
    75     a({href => url() . "?del=$n"}, 
    76       "x") . "]";
    77 }

Infos

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

[2]
dumpsto und andere Skripts in Mike's Script Archive: http://perlmeister.com/scripts

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.