Digitaler Schuhkarton (Linux-Magazin, September 2022)

Hurra, mit dieser Ausgabe wird der Programmier-Snapshot 25 Jahre alt! Richtig, im Oktober 1997 erschien die erste Ausgabe im Linux-Magazin (Abbildung 1), damals noch unter dem Titel "Perl-Snapshot" (Abbildung 2). Doch die Zeiten haben sich geändert, mittlerweile kommen die Programmierbeispiele hauptsächlich in Go, aber auch Ruby, Python oder wie sogar letztens TeX sind keine Tabuthemen.

Abbildung 1: Die Oktober-Ausgabe des Linux-Magazins anno 1997.

Abbildung 2: Der erste Programmier-Snapshot aus dem Jahr 1997.

Zu diesem Dinosaurier-Jubiläum dachte ich mir, ich könnte ein damals zu Dot-Com-Zeiten in Perl zusammengeklopftes Tool aus heutiger Sicht nochmals in Go schreiben. Den Photo-Tagger aus dem Jahr 2003 (Abbildung 3) habe ich mir schon lange wieder gewünscht. Das Tool idb weist einer Reihe von Fotodateien auf der Platte eines oder mehrere Tags zu. Später fieselt es zu einem gegebenen Tag wieder die damit markierten Fotos heraus.

TODO

Abbildung 3: Der fast 20 Jahre alte Originalartikel des Foto-Taggers.

Nun hatte ich aber weder Zeit noch Lust, durch die Installations- und Abhängigkeitshölle aller dafür benötigten Perl-Module zu gehen, und seither ist viel Zeit vergangen, und manch ein Entwickler eines CPAN-Moduls hat zwischenzeitlich bedenkenlos alte Programmier-Interfaces über Bord geworfen. Außerdem kann Go im Jahre 2022 statische Binaries kompilieren, die ohne Heckmeck überall laufen, bis in alle Ewigkeit. Auch nutzte das Skript damals einen MySQL-Server und heute mag ich (zumindest für Tools die nur lokal laufen) lieber alles in einem Guss, wie einer SQLite Flatfile-Datenbank. Dank neuer Technik ging der Rewrite bemerkenswert flott von der Hand.

SQL-osaurus Rex

SQL-Datenbanken sind heutzutage etwas aus der Mode gekommen, und wer nur einen Key-Value-Store für seine Daten braucht, nimmt eher einen persistenten Cache oder eine Serverlösung wie Redis. Für lokale Daten, deren Umfang wohl kaum ein paar Megabyte überschreitet lohnt sich allerdings kein extern laufender Prozess, und den binären Daten in Caches sowie Key-Value-Stores wie BerkeleyDB traue ich eher nicht blind. Lieber nehme ich sie direkt von Zeit zu Zeit persönlich in Augenschein. SQLite als Datenbank ist ideal, da es die Daten in einer einzigen Datei ablegt, in denen ein Kommandozeilentool wie sqlite3 zum Herumstöbern einlädt. Und das Backup einer Einzeldatei geht halt einfacher von der Hand als den Dump einer laufenden Datenbank zu erzeugen und zu archivieren.

Auch ist SQLite eines der wenigen Open-Source-Tools, das wirklich in der Public Domain liegt. So darf zum Beispiel ein Go-Modul auf Github wie mattn/go-sqlite3 einfach den SQLite-Sourcecode mit beipacken. Go macht aus SQLite, der Library und der Applikation dann ein einziges Binary, das man beliebig auf andere Rechner mit ähnlicher Architektur kopieren kann, und das dort ohne zu Murren läuft. Das Ende der Abhängigkeitshölle, dass wir das noch erleben dürfen! Bei der Installation zumindest, beim Neu-Compilieren sieht's natürlich unter Umständen anders aus.

Drei Tabellen

Welches relationale Datenmodell eignet sich nun für die Anwendung eines Foto-Taggers? Das Tool weist einer oder mehreren Dateien ein oder mehrere Tags zu. Für solche Many-To-Many-Relationen hat sich seit der Steinzeit der Datenverarbeitung das Modell mit drei Tabellen als nützlich erwiesen: Zwei Tabellen, um den Namen von Tags sowie Dateipfaden Indexnummern zuzuweisen, die dann eine dritte, zweispaltige Tabelle einander zuordnet, wenn an einer bestimmten Datei ein bestimmtes Tag klebt. So muss die Datenbank den vollen Tag- oder Dateinamen jeweils nur einmal speichern, eine Grundvoraussetzung einer "normalisierten" Datenbank. Das hat Vorteile, die über den sonst verschwendeten Speicherplatz für Duplikate hinausgehen: Verbessert der User zum Beispiel einen Tippfehler in einem Tag, muss ihn die Datenbank nur an einer Stelle korrigieren, auch wenn das Tag an tausenden von Dateien klebt.

Um zum Beispiel die Fotodatei dsc13.jpg mit dem Tag surfing zu versehen (Abbildung 4), erzeugt das Tool erstmal, falls noch nicht vorhanden, einen neuen Eintrag für das Tag surfing in der Tabelle tag (links in Abbildung 4). Die zugehörige, laufende Indexnummer 2 weist SQLite dem Eintrag automatisch zu, da die Einträge bei Index 0 starten und surfing der dritte Eintrag in der name-Spalte ist.

Abbildung 4: Das SQL-Datenbankschema des Foto-Taggers

Weiter muss das Foto dsc13.jpg, falls dort noch nicht vorhanden, in die Tabelle file wandern, in Abbildung 4 landet es in der dritten Reihe, trägt also die ab 0 aufsteigenden Indexnummer 2. Soweit die beiden Lookup-Tabellen, nun fehlt noch die eigentliche Zuweisung des Tags zum Foto, und das geschieht mit einem Eintrag in der (mittleren) Tabelle tagmap, die die Tag-ID 2 der File-ID 2 zuweist. Fertig! Mittels der unter SQL üblichen Joins ist es der Datenbank anschließend ein leichtes, die Frage zu beantworten, welche Fotos mit surfing getaggt wurden, und wird ohne Fisematenten (unter anderem) die Datei dsc13.jpg ausspucken. Ebenfalls leicht findet der Query-Engine heraus, welche Tags am Foto dsc13.jpg kleben, ebenfalls durch einen Join der Tabellen.

Tool nach Hausmacherart

Das Binary idb, aus den Go-Quellen dieses Artikels zusammengelinkt, kann die in Kasten 1 aufgelisteten Kommandos ausführen. Unterstützt wird das Angkleben von Tags an Dateien, suchen von Dateien mit einem bestimmten Tag, und das Auflisten aller Tags. Als besonderes Schmankerl kann die Option --xlink für gefundene Dateien zu einem bestimmten Tag ein Verzeichnis voller Symlinks generieren, die zu den Originalfotos zeigen. Mit einem Tool wie dem letztens vorgestellten inuke ([3]) lassen diese sich anschließend so schön betrachten und die besten herausfiltern.

    # Kasten 1
    idb --tag=foo image.jpg ... # Fotos mit Tag "foo" versehen
    idb --tag=foo               # Fotos mit Tag "foo" suchen
    idb --tag=foo --xlink       # Fotos mit Tag "foo" suchen
                                # und lokalen Symlink erzeugen
    idb --tags                  # Alle Tags auflisten

Alles aus einem Guss

Wie funktioniert das Tool idb nun? Zuerst nimmt es Kontakt zur Tag-Datenbank auf, die in der Datei .idb.db im Home-Verzeichnis liegt. Falls idb noch dort keine Datenbank findet, legt es schlicht eine neue an, mit den SQL-Kommandos in createDBSQL() ab Zeile 23.

Listing 1: dbinit.go

    01 package main
    02 
    03 import (
    04   "database/sql"
    05   _ "github.com/mattn/go-sqlite3"
    06   "os"
    07   "path"
    08 )
    09 
    10 const DbName = ".idb.db"
    11 
    12 func dbInit() (*sql.DB, error) {
    13   homedir, err := os.UserHomeDir()
    14   if err != nil {
    15     return nil, err
    16   }
    17   dbPath := path.Join(homedir, DbName)
    18   db, err := sql.Open("sqlite3", dbPath)
    19   _, err = db.Exec(createDBSQL())
    20   return db, err
    21 }
    22 
    23 func createDBSQL() string {
    24   return `
    25 CREATE TABLE IF NOT EXISTS tag (
    26   id INTEGER PRIMARY KEY,
    27   name TEXT UNIQUE
    28 );
    29 CREATE TABLE IF NOT EXISTS tagmap (
    30   tag_id INTEGER,
    31   file_id INTEGER,
    32   UNIQUE(tag_id, file_id)
    33 );
    34 CREATE TABLE IF NOT EXISTS file (
    35   id INTEGER PRIMARY KEY,
    36   name TEXT UNIQUE
    37 );`
    38 }

Einige SQLite-Besonderheiten in den drei Tabellendefinitionen erleichtern später die Arbeit beim Einfügen. Die Tabellen tag und file, die Tag- und Dateipfaden numerische IDs zuordnen, definieren als erste Spalte id einen Integer mit dem Attribut primary key, was SQLite dazu veranlasst, die IDs neuer Einträge stetig um Eins hochzuzählen. Ideal, um sie später kurz und bündig eindeutig per ID zu referenzieren! Der unique-Specifier in der String-Spalte zur Rechten für die Namen von Tags oder Dateien bestimmt, dass die Tabelle keine Namensduplikate zulässt. Als praktischer Nebeneffekt kann das Tool später mit insert or ignore in einem Rutsch Einträge erstellen, falls diese bislang noch nicht existieren, und bestehende dort ohne Murren zu belassen.

Listing 2: db.go

    01 package main
    02 
    03 import (
    04   "database/sql"
    05   "fmt"
    06   _ "github.com/mattn/go-sqlite3"
    07 )
    08 
    09 func name2id(db *sql.DB, table string, name string) (int, error) {
    10   query := fmt.Sprintf("INSERT OR IGNORE INTO %s(name) VALUES(?)", table)
    11   stmt, err := db.Prepare(query)
    12   panicOnErr(err)
    13 
    14   _, err = stmt.Exec(name)
    15   panicOnErr(err)
    16 
    17   id := -1
    18 
    19   query = fmt.Sprintf("SELECT id FROM %s WHERE name = ?", table)
    20   row := db.QueryRow(query, name)
    21   _ = row.Scan(&id)
    22 
    23   return id, nil
    24 }
    25 
    26 func tagMap(db *sql.DB, tagId, fileId int) {
    27   query := "INSERT OR IGNORE INTO tagmap(tag_id, file_id) VALUES(?, ?)"
    28   stmt, err := db.Prepare(query)
    29   panicOnErr(err)
    30   _, err = stmt.Exec(tagId, fileId)
    31   panicOnErr(err)
    32   return
    33 }
    34 
    35 func tagSearch(db *sql.DB, tagId int) ([]string, error) {
    36   result := []string{}
    37   query := `
    38     SELECT file.name FROM file, tagmap
    39     WHERE tagmap.tag_id = ?
    40     AND file.id = tagmap.file_id;`
    41   rows, err := db.Query(query, tagId)
    42   if err != nil {
    43     return result, err
    44   }
    45 
    46   for rows.Next() {
    47     path := ""
    48     err = rows.Scan(&path)
    49     if err != nil {
    50       return result, err
    51     }
    52     result = append(result, path)
    53   }
    54 
    55   return result, nil
    56 }
    57 
    58 func tagList(db *sql.DB) {
    59   query := `SELECT name FROM tag`
    60   rows, err := db.Query(query)
    61   panicOnErr(err)
    62 
    63   for rows.Next() {
    64     tag := ""
    65     err = rows.Scan(&tag)
    66     panicOnErr(err)
    67     fmt.Printf("%s\n", tag)
    68   }
    69 
    70   return
    71 }

Name zu Nummer

Die Funktion name2id in Listing 2 tut genau dies, nimmt einen Namen entgegen, entweder einen Dateinamen oder einen Tagnamen, und fügt ihn in die Lookup-Tabellen file oder tag ein, je nachdem auf was der Parameter table gesetzt ist. Existiert der Name noch nicht, hängt ihn das insert-Kommando in Zeile 10 unten an die Tabell an, und SQLite generiert dazu automatisch einen bislang ungenutzten Index-Integer id. Nach der Vorbereitung des Statements mit Prepare in Zeile 11 fügt Exec() in Zeile 14 den Namen name gegen SQL-Injections gesichert in das SQL-Kommando ein und führt es aus. Existiert die Datei oder das Tag schon in der Datenbank, passiert dank or ignore gar nichts.

Die anschließende Abfrage des Namens mit select ab Zeile 19 sucht den selben Eintrag wieder, und gibt die dazugehörige id zurück, wenn Zeile 21 die einzige Ergebnisreihe abholt. Die Funktion name2id() ist also nichts anderes als eine bequeme, persistente Methode, Namen numerischen IDs zuzuordnen und letztere auf Anfrage zurückzugeben.

Mit den Indexnummern für Tag und Datei kann tagMap() ab Zeile 26 ein Tag auf eine Datei kleben, indem es mit insert eine neue Tabellenzeile mit den beiden Nummern anhängt. Falls die Datei das Tag schon hat, sorgt or ignore wie vorher für Ruhe im Karton.

Aus Drei mach Eins

Um nun alle Dateien mit einem Tag zu suchen, muss die select-Anweisung in der Funktion tagSearch() ab Zeile 35 alle drei Tabellen kombinieren. Das gesuchte Tag in tag weist eine Indexnummer auf, die in tagmap der Indexnummer einer Datei zugeordnet ist, deren Name wiederum unter einem Index in der Tabelle file steht. Da das gesuchte Tag bereits wegen dem vorhergehenden Aufruf von name2id() als Indexnummer vorliegt, muss der Query eine Bedingung definieren, die die zwei Tabellen tagmap und file zu einer zusammenschweißt: Dabei muss die file_id der tagmap-Tabelle mit der id der file-Tabelle übereinstimmen.

Die Namen der ab Zeile 46 hereinpurzelnden Datei-Treffer ab Zeile 46 hängt Zeile 52 an die Variable result an, ein Slice von Strings, den die Funktion am Ende an der Aufrufer zurückreicht.

Die letzte Funktion in Listing 2, tagList, listet schließlich alle in der Tabelle tag gefundenen Tagnamen auf, nachdem SQLite diese sie mit dem select-Kommando ab Zeile 59 extrahiert.

Listing 3: idb.go

    01 package main
    02 
    03 import (
    04   "flag"
    05   "fmt"
    06   "os"
    07   "path"
    08   "path/filepath"
    09 )
    10 
    11 func main() {
    12   flag.Usage = func() {
    13     fmt.Printf("Usage: %s --tag=tagname photo ...\n", path.Base(os.Args[0]))
    14     os.Exit(1)
    15   }
    16 
    17   tags := flag.Bool("tags", false, "list all tags")
    18   xlink := flag.Bool("xlink", false, "create links in current dir")
    19 
    20   tag := flag.String("tag", "", "tag to assign/search")
    21   flag.Parse()
    22 
    23   db, err := dbInit()
    24   panicOnErr(err)
    25   defer db.Close()
    26 
    27   if *tags {
    28     tagList(db)
    29     return
    30   }
    31 
    32   if tag == nil {
    33     flag.Usage()
    34   }
    35 
    36   tagId, err := name2id(db, "tag", *tag)
    37   panicOnErr(err)
    38 
    39   if flag.NArg() == 0 {
    40     matches, err := tagSearch(db, tagId)
    41     panicOnErr(err)
    42     for _, match := range matches {
    43       if *xlink {
    44         err := os.Symlink(match, filepath.Base(match))
    45         panicOnErr(err)
    46       }
    47       fmt.Println(match)
    48     }
    49   } else {
    50     for _, file := range flag.Args() {
    51       ppath, err := filepath.Abs(file)
    52       panicOnErr(err)
    53       fileId, err := name2id(db, "file", ppath)
    54       panicOnErr(err)
    55 
    56       fmt.Printf("Tagging %s with %s\n", ppath, *tag)
    57 
    58       tagMap(db, tagId, fileId)
    59       panicOnErr(err)
    60     }
    61   }
    62 }
    63 
    64 func panicOnErr(err error) {
    65   if err != nil {
    66     panic(err)
    67   }
    68 }

Das Hauptprogramm in Listing 3 schließlich verarbeitet Flags wie --tag (Tag setzen oder abfragen) oder --tags (Tags auflisten), die der User dem Aufruf auf der Kommandozeile mitgibt. Es öffnet in Zeile 23 die Datenbank, wobei dbInit() die SQLite-Datei neu anlegt, falls sie noch nicht existiert. Falls auf ein --tag=... keine Dateien folgen, holt Zeile 40 mit tagSearch() alle passenden Dateipfade aus der Datenbank und die For-Schleife ab Zeile 42 gibt sie aus. Ist zudem --xlink gesetzt, erzeugt Zeile 44 jeweils einen Symlink zur Fotodatei im gegenwärtigen Verzeichnis. So lassen sich Kollektionen von Fotos mit gleichen Tags in einem temporären Verzeichnis anlegen, ansehen und verarbeiten.

Hat der User eine oder mehrere Dateien zum Tag mitgegeben, klebt der else-Zweig ab Zeile 49 die Tags an die Dateien. Dazu fügt Zeile 53 mit name2id den Namen der Fotodatei erstmal in den Index ein (falls sie dort nicht schon liegt) und ruft dann mit der damit gehobenen Indexnummer in Zeile 58 tagMap() auf, was die Relation in der tagmap-Tabelle herstellt.

Zur Fehlerbehandlung nutzen die Listings wie üblich panicOnErr() ab Zeile 64, was mit Fehlern kurzen Prozess macht und das Programm abbricht. In Produktionssoftware behandelt man Fehler statt dessen meist durch Rückgabe in höhere Ebenen.

Die ganze Chose kompiliert sich, unter Einbindung aller referenzieren Github-Pakete wie üblich mit

    $ go mod init idb
    $ go mod tidy
    $ go build idb.go db.go dbinit.go

was ein Binary idb erzeugt, das alle vorgestellten Funktionen aus dem Eff-Eff beherrscht.

Ausblick

Das Tool weist Fotos Tags zu, ließe sich aber auch anderstweitig zum Markieren von Einträgen im Dateisystem einsetzen. Wichtige Konfigurationsdateien, deren Pfade man immer vergisst? Zuletzt bearbeitete Video-Dateien in den Untiefen der Verzeichnishierarchie? Einmal getaggt, und idb wird sie bei Bedarf blitzschnell hervorholen.

Das Tool lässt sich auch einfach erweitern, wie wäre es mit einer Metadaten-Tabelle, die zu allen Fotos deren GPS-Koordinaten abspeichert? Oder eine Transaktions-Tabelle die es erlaubt, versehentlich getaggte Bilder durch "--undo" wieder von ihren Tags zu befreien? Wie immer sind der Kreativität bei selbstgeschriebenen Tools keine Grenzen gesetzt.

Infos

[1]

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

[2]

Michael Schilli, "Digitale Plattenkamera", Linux-Magazin 04/2003, https://www.linux-magazin.de/ausgaben/2003/04/digitale-plattenkamera/

[3]

Michael Schilli, "Ab ins Kröpfchen", Linux-magazin 11/2021, https://www.linux-magazin.de/ausgaben/2021/11/snapshot/

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