Muster im Eingeweckten (Linux-Magazin, Dezember 2023)

Meine digitale Bibliothek eingescannter Papierbücher liegt ja bekanntlich in Form von PDF-Dateien in einem Account bei Google Drive [2], und während Google bislang (toi, toi, toi) meine Daten vorbildlich vorrätig gehalten hat, werde ich doch mit dem Suchinterface nicht recht warm. Google-typisch steht im Browser ein Suchfeld, das den Volltext aller Dateien in allen Ordnern indiziert hat und auf Anfrage Treffer liefert, aber eine simple Antwort auf die Frage "Hab ich dieses Buch schon oder nicht" ist schon schwieriger, denn dazu müsste man die Dateinamen untersuchen und die Suche auf bestimmte Ordner beschränken.

Zum Glück bietet Google aber eine intuitiv zu bedienende API auf die Nutzerdaten im Google Drive, also bietet sich eine Kommandozeilen-Utilty an, und wenn wir schon dabei sind, lohnt sich ein Ausflug in die Welt der Pattern-Matcher, von denen es bekanntlich unterschiedliche Varianten gibt: So "matcht" die Shell mit einem Glob-Mechanismus, Programmiersprachen üblicherweise mit regulären Ausdrücken, aber ein simpler String-Matcher wie das Grep-Kommando ist oft die praktischste Lösung.

Paralle Regex-Welten

Wer auf der Kommandozeile ls *.jpg eintippt, erwartet, dass der Shell-Matchmechanismus alle Dateien mit der Endung .jpg findet. Dieser Patternabgleich unterscheidet sich grundlegend von den in Programmiersprachen verwendeten regulären Ausdrücken nach PCRE ([4]). Letztere entsprangen lustigerweise vor vielen vielen Jahren der Skriptsprache Perl, werden aber von allen modernen Sprachen, von Python, Java, C++ oder Go unterstützt.

Die Jokerkarte der Regex-Welt ist ".*", ein Ausdruck, der auf beliebige Strings passt. Dabei lässt der Punkt im Pattern beliebige Zeichen zu, und der nachfolgende Stern wiederholt letztere entweder keinmal oder beliebig oft. Das Äquivalent beim Shell-Globbing hierzu wäre "*" wie oben im Beispiel mit *.jpg erläutert, allerdings mit einer Einschränkung: Die Shell matcht niemals über den Pfadseparator hinaus. Folglich würde /tmp/f* nicht auf /tmp/foo/bar passen.

Das Kommando Grep hingegen akzeptiert mit dem Suchstring foo die Zeile /tmp/foo/bar, interpretiert den Querstrich also keineswegs gesondert und ist schon zufrieden, wenn das Pattern auch nur auf einen Bruchteil der Eingabe passt. Anders ausgedrückt verzichtet Grep auf das "Ankern" (Anchoring), ein Ausstopfen des Patterns mittels *foo* ist nicht notwendig.

Abbildung 1: Drei verschiedene Pattern-Matcher

Treffer nach Gusto

Damit User mit dem vorgestellten Binary gdls später wie in Abbildung 1 ihre Match-Strategie in den Google-Drive-Daten nach Gusto wählen können, spendiert ihnen Listing 1 das Kommandozeilen-Flag --match, das die Werte "contains" (Standardeinstellung), "glob" oder "regex" annehmen darf. Entsprechend verzweigt der Code ab Zeile 29 zu einem reinen Substring-Match mittels der Library-Funktion strings.Contains(), einem Glob-Match aus dem Standard-Paket filepath mit Match() oder einem vollen Regex-Match aus Gos Regexp-Library mit regexp.MatchString().

Abbildung 1 zeigt das neu erstellte Programm gdls in Aktion. Ein Substring-Match auf bukowski findet alle Bücher des amerikanischen Schriftstellers Charles Bukowski, von denen sich sage und schreibe 15 in meiner Bibliothek befinden. Um die Anzahl der Treffer zu begrenzen, schiebt das dritte Kommando die Treffer nach Unix-Manier in ein nachgestelltes grep-Kommando, das aus 15 Treffern ein Buch übrig lässt, dessen Titel die Zeichenkette "mad" enthält. Alternativ hierzu filtert der Regex-Match mit bukowski.*mad im vierten Kommando schon vorab den einzigen Treffer. Der Ausdruck passt auf Dateien mit dem entsprechenden Namen, unabhängig davon, in welchem Ordner sie sich befinden. Im Gegensatz dazu passt der glob-Match mit der Shell-typischen Sternsyntax und Pfadrestriktionen in der letzten Zeile nur auf Dateien, die im Ordner books liegen. Für jeden Geschmack ist etwas dabei!

Listing 1: gdls.go

    01 package main
    02 import (
    03   "flag"
    04   "log"
    05   "os"
    06   "path"
    07   "path/filepath"
    08   "regexp"
    09   "strings"
    10 )
    11 func main() {
    12   matchMethod := flag.String("match", "contains", "match method (contains, glob, regex)")
    13   update := flag.Bool("update", false, "Update from Google Drive")
    14   flag.Parse()
    15   gddb := NewGdDb()
    16   defer gddb.Close()
    17   if *update {
    18     gddb.Init()
    19     updater(gddb)
    20     return
    21   }
    22   pattern := ""
    23   if flag.NArg() == 1 {
    24     pattern = flag.Arg(0)
    25   }
    26   gddb.RegexFu = func(re, s string) (bool, error) {
    27     var matches bool
    28     var err error
    29     switch *matchMethod {
    30     case "contains":
    31       matches = strings.Contains(s, re)
    32     case "glob":
    33       matches, err = filepath.Match(re, s)
    34     case "regex":
    35       matches, err = regexp.MatchString(re, s)
    36     default:
    37       log.Fatalf("Unknown: %s", *matchMethod)
    38     }
    39     if err != nil {
    40       return false, err
    41     }
    42     return matches, nil
    43   }
    44   gddb.Search(pattern)
    45 }
    46 func dbPath() string {
    47   dir, err := os.UserHomeDir()
    48   if err != nil {
    49     panic(err)
    50   }
    51   return path.Join(dir, ".gdrive.db")
    52 }

Damit die Suchkommandos in Abbildung 1 flüssig Ergebnisse liefern, fragt das Go-Programm nicht das Google-Drive direkt ab, sondern eine lokale Kopie der Dateinamen in einer SQLite-Datenbank auf dem ausführenden Rechner. Dieser lokale Cache wird bei Bedarf mit dem Google Drive abgeglichen, dazu wird das Binary gdls mit dem Flag --update aufgerufen. Daraufhin kontaktiert es den Google-Drive-Account des Users, holt die Namen aller aktuell gespeicherten Dateien ein und speist sie in die Tabelle files einer SQLite-Datenbank in der lokalen Datei ~/.gdrive.db ein (Abbildung 2).

Abbildung 2: Eine SQLite-Datenbank speichert alle Dateipfade des Google Drives

Listing 1 verpackt die drei verschiedenen Queries als Library-Aufrufe in der Funktion RegexFu() ab Zeile 26 und Listing 4 registriert diese später mit dem Engine der SQLite-Datenbank. Während die SQLite-Session in Abbildung 2 noch händisch und SQL-typisch nach like %bukowski% sucht (ein viertes Verfahren zum Pattern-Matching!), werden die Suchanfragen des Go-Programms später SQLites eingebaute regexp() Funktion nutzen, die mit der userdefinierten Funktion RegexFu überladen wurde.

Ihren Ausweis bitte!

Wie landen nun die Metadaten des Google-Drive-Inhalts in der SQLite-Datenbank? Google erlaubt den Zugriff auf die Drive-Daten natürlich nur für ausgewiesene User, deswegen muss ein Programm, das die Namen der darin enthaltenen Dateien abholen möchte, sich entsprechend autorisieren.

Hierzu müssen Entwickler auf der Google Cloud Console ([3]) zunächst ein Projekt anlegen, die Google-Drive-API freischalten (Abbildung 3) und einen neue Client-Applikation hinzufügen (Abbildung 4). Der Server antwortet mit einer neu generierten Client-ID und dem Client-Secret (Abbildung 5).

Abbildung 3: Freischalten der Google Drive API vor der Nutzung

Abbildung 4: Anmelden der App auf der Google Cloud Console

Abbildung 5: Client ID und Client Secret als Ausweis für die App

Letzteres berechtigt allerdings noch nicht zum Zugriff auf die Daten, sondern nur zum Einholen eines Access-Tokens auf dem Google-API-Server. Liegt später so ein Token dem Request an die Google-Drive-API bei, wird der Server die Userdaten herausrücken. Der Access-Token gilt zeitlich beschränkt, kann aber mit dem gleichzeitig zugeteilten Refresh-Token wieder und wieder aufgefrischt werden. Das Client-Secret steht in dem Dialog in Abbildung 5 im Json-Format zum Download bereit, und der User legt es in einer Datei creds.json im lokalen Verzeichnis ab.

Abbildung 6: Abholen des Access-Tokens beim ersten Aufruf

Beim ersten Aufruf des Programms mit gdls --update in Abbildung 6 kann es die Credentials-Datei creds.json mit Client-ID und Client-Secret lesen. Zu diesem Zeitpunkt liegt noch kein Access-Token vor, also ruft das Programm die Funktion getTokenFromWeb() ab Zeile 20 in Listing 2 auf. Diese druckt den URL zur Credential-Aktivierung auf die Standard-Ausgabe und fordert den User auf, den URL in das Eingabefenster eines Webbrowsers einzutippen. Der damit kontaktierte Google-Server stellt dann zunächst sicher, dass der Besitzer des Google-Drives eingeloggt ist, um dann in einem Dialog abzufragen, ob es in Ordnung geht, dass der neuen Applikation entsprechende Rechte eingeräumt werden.

Abbildung 7: Warnung wegen hochsuspekter selbstgeschriebener App

Abbildung 8: Der User gibt im Browser sein Einverständnis

Dazu bringt Google zunächst eine Warnung (Abbildung 7) und dann einen sogenannten OAuth-Consent-Dialog (Abbildung 8), den der User abnicken muss, damit der API-Server weiß, dass der User auch damit einverstanden ist, dass eine unregistrierte und damit hochsuspekte Applikation die privaten Drive-Daten lesen darf.

Listing 2: token.go

    01 package main
    02 import (
    03   "context"
    04   "encoding/json"
    05   "fmt"
    06   "golang.org/x/oauth2"
    07   "os"
    08 )
    09 const tokenFile = "token.json"
    10 func readToken() (*oauth2.Token, error) {
    11   f, err := os.Open(tokenFile)
    12   if err != nil {
    13     return nil, err
    14   }
    15   defer f.Close()
    16   tok := &oauth2.Token{}
    17   err = json.NewDecoder(f).Decode(tok)
    18   return tok, err
    19 }
    20 func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
    21   authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
    22   fmt.Printf("Launch in browser and copy auth code: \n%v\n", authURL)
    23   var authCode string
    24   fmt.Scan(&authCode)
    25   tok, err := config.Exchange(context.TODO(), authCode)
    26   panicOnErr(err)
    27   return tok
    28 }
    29 func saveToken(token *oauth2.Token) {
    30   f, err := os.Create(tokenFile)
    31   panicOnErr(err)
    32   defer f.Close()
    33   err = json.NewEncoder(f).Encode(token)
    34   panicOnErr(err)
    35 }

Mit Haken und Ösen

Stimmt der User diesem Prozess trotz vehementer Warnungen zu (schließlich ist die App nicht abgesegnet und der Entwickler nicht öffentlich bekannt), dirigiert der Server den Browser zu einem voreingestellten URL auf localhost, auf dem mangels lokaler Konfiguration niemand lauscht, also meldet der Browser einen Fehler (Abbildung 9). Aber nicht verzagen, in dem angezeigten URL im Eingabefenster steht im Parameter code der Autorisierungs-Code. Den kopiert der User kurzerhand in die Eingabe des wartenden Programms (Abbildung 6), worauf dieses den Google-Server kontaktiert und gegen Abliefern des Codes den sehnlichst erwarteten Access-Token bekommt. Damit klappt der Zugriff auf die Userdaten und der Download-Prozess beginnt loszurattern.

Abbildung 9: Der Autorisierungs-Code steht im Parameter "code"

Damit diese Zirkusnummer nicht bei jedem Aufruf des Programms von neuem beginnt, speichert gdls in saveToken() ab Zeile 29 in Listing 2 den Access-Token samt dem ebenfalls beiliegenden Refresh-Token in der Datei token.json. Von dort liest readToken() ab Zeile 10 ihn später wieder ein. Beim nächsten Aufruf nutzt das Programm hinter den Kulissen auf diese Weise den noch gültigen Access-Token oder holt sich mit dem Refresh-Token einen neuen. Der User bekommt davon nichts mit, ausgenommen vielleicht, dass es hin und wieder ein paar Sekunden länger dauert, bis der Zugriff aufs Drive wieder klappt.

Alle Listings dieser Ausgabe behandeln Fehler übrigens aus Platzgründen mit der Utility-Funktion panicOnErr(), voll ausgereifte Applikationen nutzen stattdessen das log-Modul für hilfreiche Meldungen und geben Fehler an aufrufende Programmteile zurück, statt mit panic() gleich alles hinzuwerfen.

Fangfrisch eingeweckt

Mit griffbereiten Zugriffsdaten darf nun die Funktion updater() in Listing 3 mittels listAllFiles() ab Zeile 32 die Namen aller im Google Drive verstreuten Dateien abholen. Dazu liest die Library-Funktion google.ConfigFromJSON() in Zeile 17 erst die Client-ID und das Client-Secret aus der Konfigurationsdatei, und getClient() ab Zeile 24 versucht zunächst, einen gültigen Access-Token zu finden. Schlägt dies fehl, beginnt getTokenFromWeb() in Zeile 27 den vorher erwähnten Tokentanz mit dem Browser. Der neu erstellte Client hilft dann der in Zeile 11 importierten Library drive/v3, die OAuth-spezifische Kommunikation zu erledigen.

Listing 3: gdrive.go

    01 package main
    02 import (
    03   "context"
    04   "fmt"
    05   "io/ioutil"
    06   "net/http"
    07   "path/filepath"
    08   _ "github.com/mattn/go-sqlite3"
    09   "golang.org/x/oauth2"
    10   "golang.org/x/oauth2/google"
    11   "google.golang.org/api/drive/v3"
    12 )
    13 func updater(gddb GdDb) {
    14   credentialsFile := "creds.json"
    15   data, err := ioutil.ReadFile(credentialsFile)
    16   panicOnErr(err)
    17   config, err := google.ConfigFromJSON(data, drive.DriveReadonlyScope)
    18   panicOnErr(err)
    19   client := getClient(config)
    20   service, err := drive.New(client)
    21   panicOnErr(err)
    22   listAllFiles(service, gddb, "root", "")
    23 }
    24 func getClient(config *oauth2.Config) *http.Client {
    25   tok, err := readToken()
    26   if err != nil {
    27     tok = getTokenFromWeb(config)
    28     saveToken(tok)
    29   }
    30   return config.Client(context.Background(), tok)
    31 }
    32 func listAllFiles(service *drive.Service, gddb GdDb, folderID, parentPath string) {
    33   query := fmt.Sprintf("trashed=false and '%s' in parents", folderID)
    34   pageToken := ""
    35   for {
    36     r, err := service.Files.List().Q(query).Fields("nextPageToken, files(id, name, mimeType)").PageToken(pageToken).Do()
    37     panicOnErr(err)
    38     for _, file := range r.Files {
    39       fullPath := filepath.Join(parentPath, file.Name)
    40       if file.MimeType == "application/vnd.google-apps.folder" {
    41         listAllFiles(service, gddb, file.Id, fullPath)
    42       } else {
    43         fmt.Printf("Adding %s\n", fullPath)
    44         gddb.Add(fullPath)
    45       }
    46     }
    47     pageToken = r.NextPageToken
    48     if pageToken == "" {
    49       break
    50     }
    51   }
    52 }
    53 func panicOnErr(err error) {
    54   if err != nil {
    55     panic(err)
    56   }
    57 }

Diese von Google offiziell herausgegebene Library macht es Entwicklern gar nicht so einfach, das Drive komplett abzusuchen und dabei die Ordnerstruktur im Auge zu behalten. Die Funktion listAllFiles() fängt beim obersten Ordner (mit der ID "root") an, alle darin enthaltenen Einträge mit dem Query in Zeile 33 abzufragen. Eventuell dort gefundene Verzeichnisse erkennt sie mit dem Typ-Check in Zeile 40 und ruft sich jeweils rekursiv auf, um sich in der Hierarchie weiter nach unten vorzubohren. Die Namen normaler Dateien kopiert es mit dem beim Aufruf hereingereichten gddb-Objekt und dessen Funktion Add() in Zeile 44 in den lokalen SQLite-Cache. Dank der mittels Rekursion durchgeschleiften Ordnerpfade stehen so die absoluten Dateipfade im Drive bereit.

Häppchenweise

Die Google-API liefert aber auf einen Search-Query hin keineswegs alle Dateien in einem Ordner, sondern paginiert das Ergebnis ungefragt, falls mehr als 100 Treffer vorliegen. Liegt im Json einer Server-Antwort ein nextPageToken vor, geht's noch weiter. Der Server ist übrigens nicht verpflichtet, genau 100 Ergebnisse in eine Antwort zu packen, diese voreingestellte Größe ist ein Maximalwert, den der Server aus Effizienzgründen auch unterschreiten darf, was besonders bei API-Servern mit verteilter Datenhaltung nicht selten vorkommt. Clients, die nur dann eine Folgeseite anfordern, falls sie 100 Ergebnisse finden (oder erst gar nicht auf Folgeseiten prüfen) liefern unvollständige Daten ohne jegliche Fehlermeldung, und User tappen für immer im Dunkeln!

Abgeheftet und archiviert

Das Tool speichert die im Google-Drive des Users gefundenen Dateinamen in der SQLite-Datei ~/.gdls.db im Home-Verzeichnis. Listing 4 packt die Funktionen, die auf die Datenbank zugreifen, in ein objektorientes Format. Der Konstruktor NewGdDb() ab Zeile 12 öffnet die Verbindung zur Datenbank und verpackt das Handle in eine Struktur vom Typ GdDb (definiert ab Zeile 7), die sie ausfüllt und dem Aufrufer für zukünftige Funktionsaufrufe aus dem Paket zurückgibt.

Listing 4: db.go

    01 package main
    02 import (
    03   "database/sql"
    04   "fmt"
    05   "github.com/mattn/go-sqlite3"
    06 )
    07 type GdDb struct {
    08   Db        *sql.DB
    09   RegexFu   func(re, s string) (bool, error)
    10   TableName string
    11 }
    12 func NewGdDb() GdDb {
    13   db, err := sql.Open("sqlite3", dbPath())
    14   panicOnErr(err)
    15   return GdDb{Db: db, TableName: "files"}
    16 }
    17 func (gddb GdDb) Close() {
    18   gddb.Db.Close()
    19 }
    20 func (gddb GdDb) Init() {
    21   sql := `DROP TABLE If EXISTS ` + gddb.TableName
    22   _, err := gddb.Db.Exec(sql)
    23   panicOnErr(err)
    24   sql = `CREATE TABLE ` + gddb.TableName + ` (
    25     id INTEGER PRIMARY KEY AUTOINCREMENT,
    26     path TEXT
    27   );`
    28   _, err = gddb.Db.Exec(sql)
    29   panicOnErr(err)
    30 }
    31 func (gddb GdDb) Add(path string) error {
    32   _, err := gddb.Db.Exec("INSERT INTO "+gddb.TableName+" (path) VALUES (?)", path)
    33   return err
    34 }
    35 func (gddb GdDb) Search(pattern string) {
    36   sql.Register("sqlite3_FunctionRegistration", &sqlite3.SQLiteDriver{
    37     ConnectHook: func(conn *sqlite3.SQLiteConn) error {
    38       if err := conn.RegisterFunc("regex", gddb.RegexFu, true); err != nil {
    39         return err
    40       }
    41       return nil
    42     }})
    43   db, err := sql.Open("sqlite3_FunctionRegistration", dbPath())
    44   panicOnErr(err)
    45   defer db.Close()
    46   query := fmt.Sprintf("SELECT path FROM %s", gddb.TableName)
    47   var rows *sql.Rows
    48   if pattern == "" {
    49     rows, err = db.Query(query)
    50   } else {
    51     query += fmt.Sprintf(" WHERE regex(?, path)")
    52     rows, err = db.Query(query, pattern)
    53   }
    54   panicOnErr(err)
    55   defer rows.Close()
    56   for rows.Next() {
    57     var name string
    58     panicOnErr(rows.Scan(&name))
    59     fmt.Printf("%s\n", name)
    60   }
    61   panicOnErr(rows.Err())
    62 }

Ruft der Client später db.Init() auf, löscht der Code ab Zeile 21 mit Hilfe eines SQL-Kommandos die Tabelle files (falls sie in einer alten Version aus vorherigen Läufen existiert) und legt eine neue an, die laufenden IDs die vollständigen Pfade gefundener Dateien zuweist. In Abbildung 2 war vorher das Datenbankschema zu sehen. Die Funktion Add() ab Zeile 31 fügt mit dem SQL-Kommando insert neue Pfadeinträge als Tabellenzeilen an, während Search() ab Zeile 35 Einträge nach den vordefinierten Matchalgorithmen absucht und Treffer ausgibt.

Dazu registriert sie in SQLite die userdefinierte Funktion regex(), und setzt sie auf die in den Konstruktor hereingereichte Go-Funktion mit den drei Match-Algorithmen, von denen zu diesem Zeitpunkt bereits einer vom Hauptprogramm her vorselektiert ist. Bei der Suche mit select in der Datenbank mit SQL springt dann die userdefinierte Funktion in einer where-Klausel ein und filtert die Treffer entsprechend. Die For-Schleife ab Zeile 56 iteriert über die genehmigten Treffer und gibt sie auf Stdout aus.

Abbildung 10: Drei Build-Kommandos erzeugen das Go-Binary

Abteilung Marsch

Wie immer führt der Dreisprung in Abbildung 10 zu einem ausführbaren Programm, nachdem der go-Compiler die im Code referenzierten Libraries vom Netz geladen und vorkompiliert hat. Wer möchte, kann die Suchfunktionen weiter anpassen. Groß- und Kleinschreibung zu ignorieren, böte sich zum Beispiel an. Wie immer bei Selbstgeschriebenem sind der Fantasie keine Grenzen gesetzt.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2023/12/snapshot/

[2]

"Papierbuch am Ende", Michael Schilli, Linux-Magazin 2012-12, https://www.linux-magazin.de/ausgaben/2012/12/perl-snapshot/

[3]

Google Cloud APIs & Services Credentials, https://console.cloud.google.com/apis/credentials

[4]

Perl Compatible Regular Expressions, https://en.wikipedia.org/wiki/Perl_Compatible_Regular_Expressions

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc