Online-Bücherei (Linux-Magazin, Februar 2013)

Um Online-Dokumente auf Google Drive mit Tags zu versehen, legt ein Skript unter Nutzung zweier APIs Metadaten auf Evernote ab.

Wie vor zwei Monaten hier im Snapshot ([2]) erörtert, liegen meine ehemaligen Papierbücher jetzt als PDFs auf Google Drive. Sitze ich aber vor meinem mobilen Lesegerät und möchte in der Online-Bibliothek schmökern, ist die lange textbasierte Bücherliste in Abbildung 1 wenig hilfreich. Was hier noch fehlt, ist ein Mechanismus, mit dem ich zum Beispiel gelesene Bücher als solche markieren kann. Weiter eignen sich manche großformatige Bücher wie Informatikwerke mit seitenlangen Programm-Listings nicht für Mobiltelefone, und wenn ich an der Supermarktkasse Schlange stehe, will ich aus kleinformatigen Werken wählen, die das kleine Smartphone-Display auch anzeigen kann.

Abbildung 1: Gescannte Papierbücher als PDFs auf Google Drive erscheinen leider nur als graue Textlisten.

Abbildung 2: Mit Hilfe eines API-Skripts zeigt Evernote die Bücher der Bibliothek mit Titelbild und Tagging-Funktion.

Drive ohne Tags

Auf Google Drive fehlen also Tags, die Dateien mit Eigenschaften versehen. Ein "finished"-Tag für ausgelesene Werke, ein "active"-Tag für halb gelesene, und ein "pocket"-Tag für Taschenbücher, die auch auf einem Smartphone gut zu lesen sind. Dann könnte man entsprechend aktueller Anforderungen nur Bücher mit bestimmten Tags auflisten und aus der beschränkten Auswahl viel zügiger auswählen.

Bis die Google-Entwickler in die Hufe kommen, fiel deshalb meine Wahl auf die Applikation Evernote. Sie ist auf allen Geräten verfügbar, auf denen auch Google-Drive läuft, und speichert, wie vor einiger Zeit hier erörtert ([3] und [4]) beliebige Daten in "Note" genannten Einträgen ab, die wiederum in "Notebooks" genannten Ordnern liegen. Außerdem bietet Evernote eine API auf den Datenspeicher an, sodass man ohne weiteres mal kurz hunderte oder tausende Einträge maschinengesteuert anlegen kann.

Evernote mit Skripteinsatz

Die gewünschten Metadaten legt Evernote einfach als Tags ab, die es Notes zuweist. Der Anwender vergibt Tags entweder mit der Maus in einem Webbrowser auf evernote.com oder mittels einer der vielen Clients auf Windows-, Mac- oder Mobilgeräten. Auch die API weist Notes auf Wunsch ebenfalls per API generierte Tags zu.

Wie kommen nun die Einträge für die PDF-Dateien in die Evernote-Datenbank? Das heute vorgestellte Skript in Listing 1 legt von allen lokal gefundenen PDF-Dateien ein JPG-Bild der Titelseite an und lädt es in einen Note-Eintrag im Folder 50-Ebooks meines Evernote-Accounts hoch. Schiebe ich dann grafisch mit der Maus Tags auf die Einträge, kann ich mit Evernotes Suchfunktion (Abbildung 4) anschließend nur Einträge mit bestimmten Tags auswählen.

Abbildung 3: Im Browser hat der User dem Buch "Der App-Entwickler Crashkurs" die Tags "computer-book" und "finished-book" zugewiesen.

Abbildung 4: Sind die Tags "fun-book" und "pocket-book" gesetzt, zeigt die Suche nur unterhaltsame Taschenbücher.

Einfacher als OAuth

Seit dem Erscheinen der Perl-Snapshots, die die Evernote-API nutzten ([3] und [4]) hat Evernote von Passwort-basierter Authentisierung auf OAuth2 umgestellt. Folglich funktionieren die Skripts von damals nicht mehr. Der Aufwand für neue Applikationen stieg damit leicht an, allerdings hatten die Evernote-Entwickler ein Einsehen mit Hobbybastlern, die statt Daten von wildfremden App-Kunden nur ihre eigenen Accounts manipulieren wollen. Freizeitschrauber können sich nämlich unter [5] einen sogenannten "Developer-Token" abholen, einen Textstring, der sich lokal speichern lässt und ähnlich wie ein Passwort Zugang zu einem einzigen Evernote-Datenspeicher gewährt.

Listing 1: en-ebooks

    001 #!/usr/local/bin/perl -w
    002 use strict;
    003 use Net::Evernote::Simple;
    004 use Log::Log4perl qw(:easy);
    005 use File::Basename;
    006 use File::Temp qw( tempfile );
    007 use Digest::MD5 qw( md5_hex );
    008 use Sysadm::Install qw( :all );
    009 
    010 Log::Log4perl->easy_init( $INFO );
    011 
    012 my( $HOME ) = glob "~";
    013 
    014 my $EN_FOLDER = "50-Ebooks";
    015 my $BOOKS_DIR = "$HOME/books";
    016 
    017 my $enote   = Net::Evernote::Simple->new();
    018 my $enstore = $enote->note_store();
    019 
    020 my $en_folder_id = en_folder_id( 
    021   $enote, $enstore );
    022 
    023 my %en_books = map { $_ => 1 }
    024    en_folder_notes( $enote, $enstore, 
    025                     $en_folder_id );
    026 
    027 for my $pdf ( <$BOOKS_DIR/*.pdf> ) {
    028   my $file = basename $pdf;
    029   (my $title = $file ) =~ s/\.pdf$//;
    030 
    031   if( exists $en_books{ $title } ) {
    032     DEBUG "$title already in Evernote";
    033     next;
    034   }
    035 
    036   en_add( $enote, $enstore, $title, 
    037         title_pic( $pdf ), $en_folder_id );
    038 }
    039 
    040 ###########################################
    041 sub en_add {
    042 ###########################################
    043   my( $enote, $enstore, $title, 
    044       $title_pic, $en_folder_id ) = @_;
    045 
    046   my $PRFX = "Net::Evernote::Simple::" .
    047     "EDAMTypes::";
    048   my $data_class     = $PRFX . "Data";
    049   my $resource_class = $PRFX . "Resource";
    050   my $note_class     = $PRFX . "Note";
    051 
    052   eval "require $data_class";
    053   eval "require $resource_class";
    054   eval "require $note_class";
    055 
    056   INFO "Adding $title to Evernote";
    057 
    058   my $data = $data_class->new();
    059 
    060   my $content = slurp $title_pic;
    061   $data->body( $content );
    062   my $hash = md5_hex( $content );
    063   $data->bodyHash( $hash );
    064   $data->size( length $content );
    065 
    066   my $r = $resource_class->new();
    067   $r->data( $data );
    068   $r->mime( "image/jpeg" );
    069   $r->noteGuid( "" );
    070 
    071   my $note = $note_class->new();
    072   $note->title( $title );
    073   $note->resources( [$r] );
    074   $note->notebookGuid( $en_folder_id );
    075 
    076   my $enml = <<EOT;
    077 <?xml version="1.0" encoding="UTF-8"?>
    078 <!DOCTYPE en-note SYSTEM 
    079 "http://xml.evernote.com/pub/enml2.dtd">
    080 <en-note>
    081    <en-media type="image/jpeg" 
    082              hash="$hash"/>
    083 </en-note>
    084 EOT
    085 
    086   $note->content( $enml );
    087 
    088   $enstore->createNote(
    089       $enote->dev_token(), $note );
    090 }
    091 
    092 ###########################################
    093 sub title_pic {
    094 ###########################################
    095   my( $in_pdf ) = @_;
    096 
    097   my ($fh1, $pdf_file) = tempfile( 
    098       SUFFIX => '.pdf', UNLINK => 1 );
    099 
    100   my ($fh2, $jpg_file) = tempfile( 
    101       SUFFIX => '.jpg', UNLINK => 1 );
    102 
    103   eval {
    104       tap { raise_error => 1 }, 
    105         "pdftk", "A=$in_pdf", "cat", "A1",
    106         "output", $pdf_file;
    107 
    108       tap { raise_error => 1 }, 
    109         "convert", "-resize", "100x", 
    110         $pdf_file, $jpg_file;
    111   };
    112 
    113   return $jpg_file;
    114 }
    115 
    116 ###########################################
    117 sub en_folder_id {
    118 ###########################################
    119   my( $enote, $store ) = @_;
    120 
    121   my $notebooks = $enstore->listNotebooks( 
    122     $enote->dev_token() );
    123 
    124   for my $notebook (@$notebooks) {
    125     if ( $notebook->name() eq $EN_FOLDER ){
    126       return $notebook->guid();
    127     }
    128   }
    129 
    130   die "$EN_FOLDER not found";
    131 }
    132 
    133 ###########################################
    134 sub en_folder_notes {
    135 ###########################################
    136   my( $enote, $store, $en_folder_id ) = @_;
    137 
    138   my $filter_class = "Net::Evernote::" .
    139    "Simple::EDAMNoteStore::NoteFilter";
    140   eval "require $filter_class";
    141   
    142   my $filter = $filter_class->new();
    143   $filter->notebookGuid( $en_folder_id );
    144 
    145   my @titles = ();
    146 
    147   my $max_per_call = 50;
    148 
    149   for( my $offset = 0; ;
    150        $offset += $max_per_call ) {
    151 
    152     my $note_list = $store->findNotes( 
    153         $enote->dev_token(),
    154         $filter, $offset, $max_per_call );
    155 
    156     my $notes_found = 0;
    157 
    158     for my $note ( 
    159         @{ $note_list->{ notes } } ) {
    160       $notes_found++;
    161     
    162       push @titles, $note->title();
    163     }
    164     
    165     last if $notes_found != $max_per_call;
    166   }
    167 
    168   return @titles;
    169 }

Listing 1 zieht als wichtigstes CPAN-Modul Net::Evernote::Simple herein, das der Einfachheit halber die offizielle Evernote-Thrift-API schon beinhaltet. Eine YAML-Datei namens ~/.evernote.yml im Home-Verzeichnis des Users enthält unter dem Eintrag dev_token den Developer Token, den der auf Evernote eingeloggte User unter [5] abholen kann. In Zeile 18 liefert dann die Methode note_store() ein Objekt, mit dem der User direkt Evernote-API-Funktionen aufrufen kann, die intern als Web-Requests an den Evernote-Server geschickt werden.

Zeile 20 ermittelt über die Funktion en_folder_id die GUID des Notebooks 50-Ebooks, das der User vorher auf Evernote angelegt hat. Ab Zeile 115 ruft sie zunächst die API-Funktion listNotebooks() auf, übergibt den Developer-Token, und erhält eine Liste aller Notebooks des Accounts. Die if-Bedingung in Zeile 123 filtert das gesuchte heraus. Die Methode guid() extrahiert daraus die GUID und gibt sie ans Hauptprogramm zurück.

Schrittweise abpumpen

Bei jedem Aufruf holt das Skript mit en_folder_notes() alle im Notebook 50-Ebooks auf Evernote gespeicherten Notes ab, iteriert anschließend über die lokal gespeicherten PDF-Dateien mit den Ebooks (ginge natürlich auch mit Cloud-PDFs auf Google Drive) und findet so heraus, welche lokalen PDFs noch nicht in Evernote liegen. Für jedes so gefundene Dokument legt der Aufruf der Funktion en_add() in Zeile 36 eine neue Evernote-Note an. Die Funktion en_folder_notes() legt zum Finden der Einträge in einem Notebook einen Filter an, der die Notebook-GUID vorgibt und holt dann jeweils 50 Einträge auf einmal ab. Kommen 50 zurück, erhöht es den Zähler $offset und fragt nochmal nach.

Da Net::Evernote::Simple die Evernote-API enthält, muss es sie im Modulraum mit dem gleichnamigen Präfix versehen. Modulnamen wie EDAMTypes::Data werden dem vorangestellten Präfix etwas länglich, und wegen der beschränkten Spaltenbreite im Heft stellen die Zeilen 46-54 die erforderlichen Modulnamen der API zeilenweise zusammen und ziehen dann die Module mit require herein. In einem normalen Skript nutzt man einfach "use Net::...::Data".

PDF-Dateien zerfieseln

Das neben dem Namen der PDF-Datei ebenfalls benötigte JPG-Bild der Titelseite fieselt die Utility pdftk in der Funktion title_pic heraus und convert aus dem ImageMagick-Fundus wandelt die PDF-Titelseite in Zeile 107 in ein JPG-Foto um. Beide Utilities lassen sich mit einem Package-Installer wie zum Beispiel apt-get installieren.

Das Anlegen einer neuen Note in Evernote mit einer eingebetteten JPG-Datei erfordert etwas XML, wie die Funktion en_add() ab Zeile 41 zeigt. Zunächst schlürft die Funktion slurp aus dem Fundus des CPAN-Moduls Sysadm::Install die JPG-Daten in die Variable $content. Diese Rohdaten nimmt die Methode body() der Klasse EDAMTypes::Data entgegen und verlangt außerdem einen MD5-Hash des Inhalts, den die Funktion md5_hash() aus dem CPAN-Modul Digest::MD5 generiert. Auch die Größe der Datei in Bytes muss explizit eingetütet werden.

Aus dieser Datenklasse formen die Zeilen 66-69 ein Objekt der Klasse EDAMTypes::Resource, die die Methode resources() wiederum in ein Objekt der Klasse EDAMTypes::Note eintütet. Jede Evernote-Notiz besteht aus einer Anzahl dieser Resourcen, auf die die Notes dann verlinken, damit Browser und Evernote-Applikationen sie als Teil der Note anzeigen können. Schließlich stopft noch Zeile 86 das weiter oben angegebene XML zur Formatierung des Inhalts in den Content-Bereich der Note und ein abschließender Aufruf der API-Funktion createNote() mit dem Developer-Token spielt die neue Note auf dem Evernote-Server ein.

Download-Link inklusive

Mit der in [2] vorgstellten Google-Drive-API lässt sich in Listing 1 dann noch leicht ein Download-Link in jeden Evernote-Eintrag einbauen, der auf das PDF-Dokument auf dem Google-Drive zeigt. Dazu liefert die API für gefundene Dateien ein Feld namens "downloadUrl", das einen https-Link auf den Google Server enthält. In ihm ist der Query-Parameter gd=true gesetzt. Entfernt man ihn, lädt ein Browserklick die Drive-Datei auf das lokal verwendete Gerät herunter. Zur Performance-Optimierung lassen sich die Dokumente wie in [2] vorgeschlagen auch aufgesplittet ablegen und entsprechend verlinken. So kann der Lesewütige auch von Unterwegs die Werkte häppchenweise zügig herunterladen und gleich loslesen.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2013/02/Perl

[2]

"Papierbuch am Ende", Michael Schilli, Linux-Magazin 12/2012, http://www.linux-magazin.de/Ausgaben/2012/12/Perl-Snapshot

[3]

"Zettels Trauma", Michael Schilli, Linux-Magazin 01/2012, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2012/01/Perl-Snapshot

[4]

"Unvergesslich", Michael Schilli, Linux-Magazin 04/2012, http://www.linux-magazin.de/Ausgaben/2012/04/Perl-Snapshot

[5]

Evernote gibt "Developer Tokens" zum vereinfachten privaten Datenzugriff aus: http://dev.evernote.com/documentation/cloud/chapters/Authentication.php#devtoken

Michael Schilli

arbeitet als Software-Engineer bei Yahoo in Sunnyvale, Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen der Skriptsprache Perl. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.