Bilder-Check (serendipity_event_bilderscan)

Ein Serendipity-Event-Plugin, das alle Artikel nach eingebundenen Bildern durchsucht und im Backend eine Übersicht über auffällige Bilder anzeigt: zu große Dateien, fehlende Thumbnail-Verkleinerung, nicht mehr auffindbare Dateien und JPEGs mit Datenmüll hinter dem eigentlichen Bildende. Optional lassen sich große Bilder direkt aus der Übersicht heraus verkleinern bzw. von diesem Datenmüll bereinigen; vor jeder Änderung wird automatisch ein Backup angelegt, das sich über eine eigene Backup-Verwaltung einsehen, zurückspielen oder löschen lässt. Außerdem findet das Plugin Zoom-Links (<a>-Tags um verlinkte Originalbilder) ohne title-Attribut und hilft, sie systematisch nachzupflegen.

Wie es sich ins Backend einhängt

Serendipity kennt zwei grundlegend verschiedene Plugin-Typen: Event-Plugins (reagieren auf Events wie frontend_display oder Cronjobs) und Plugins mit einer eigenen Konfigurationsseite. Dieses Plugin nutzt keinen der "echten" Events – event_hook() gibt immer sofort false zurück und tut nichts. Es klinkt sich stattdessen ausschließlich über die von jedem Serendipity-Plugin bereitgestellte Konfigurationsseite ein:

  • Im Backend unter Plugins konfigurieren → Bilder-Check → Konfigurieren ruft Serendipity automatisch performConfig(&$bag) auf, sobald diese Seite aufgerufen wird.
  • Alles, was auf dieser Seite sichtbar ist – Statistiken, Artikelliste, Top-Liste, Verkleinern-Buttons – wird von performConfig() direkt mit echo ausgegeben. Es gibt keine Template-Datei; das HTML steht im PHP.
  • Die Formularfelder unter "Einstellungen" (Schwellenwerte, Zielgrößen, …) werden von Serendipity selbst gerendert und gespeichert, basierend auf introspect() und introspect_config_item(). Das Plugin muss diese Werte in performConfig() nur noch mit $this->get_config(...) auslesen.

Weil alles innerhalb von performConfig() passiert, hat das Plugin keinen Einfluss auf das öffentliche Frontend – Besucher der Website sehen nichts davon. Es ist ein reines Backend-/Admin-Werkzeug.

Sprachdateien: Haupt- vs. UTF-8/-Ordner

Ganz oben in der Plugin-Datei lädt @serendipity_plugin_api::load_language(dirname(__FILE__)); die passenden Übersetzungskonstanten (PLUGIN_EVENT_BILDERSCAN_NAME, PLUGIN_EVENT_BILDERSCAN_DESC). Serendipity unterstützt seit jeher zwei Zeichensätze für Installationen: das alte ISO-8859-15 und UTF-8 (heute der Normalfall). load_language() schaut sich dafür an, welchen Zeichensatz der Blog verwendet, und sucht bei UTF-8-Blogs gezielt in einem Unterordner UTF-8/ des Plugins nach der passenden lang_xx.inc.php – die Dateien im Plugin-Hauptordner gelten nur als Fallback für ISO-8859-15-Installationen. Fehlt der UTF-8/-Ordner, findet Serendipity dort keine deutsche Datei und fällt am Ende auf die eingebaute Fallback-Sprache Englisch zurück, obwohl lang_de.inc.php im Hauptordner vorhanden und korrekt kodiert ist. Deshalb gibt es hier lang_de.inc.php/lang_en.inc.php doppelt: einmal im Plugin-Hauptordner und identisch nochmal unter UTF-8/.

Bedeutung der wichtigsten Methoden

Methode Aufgerufen von Zweck
introspect() Serendipity, beim Laden der Plugin-Liste Meldet Name, Beschreibung und welche Konfigurationsfelder es gibt
introspect_config_item() Serendipity, pro Konfigurationsfeld Definiert Typ, Label, Beschreibung und Default jedes einzelnen Feldes
generate_content() Serendipity (Pflichtmethode von serendipity_event) Wird für dieses Plugin praktisch nicht genutzt, liefert nur den Titel
performConfig() Serendipity, beim Aufruf der Konfigurationsseite Führt den kompletten Scan durch und rendert die Ergebnisseite
event_hook() Serendipity, bei Frontend-/Backend-Events Leer – dieses Plugin reagiert auf keine Events

Wie der Scan im Detail funktioniert

Der gesamte Ablauf steckt in performConfig() und läuft bei jedem Aufruf der Konfigurationsseite neu ab (kein Caching, keine Hintergrundjobs):

1. Artikel laden

Es wird direkt per SQL auf die bestehende Serendipity-Datenbankverbindung zugegriffen (keine eigenen Zugangsdaten nötig):

SELECT id, title, body FROM {dbPrefix}entries WHERE body LIKE '%<img%'

Nur Artikel, deren Text mindestens ein <img>-Tag enthält, werden überhaupt betrachtet.

2. Bilder pro Artikel erkennen

Für jeden Artikeltext (body) werden per Regex zwei Dinge gesucht:

  1. Gepaarte Zoom-Links: <a href="original.jpg"><img src="thumb.jpg"></a> – das typische Muster "kleines Thumbnail verlinkt auf großes Original". Aus jedem Treffer wird ein Bildpaar (thumb + original) gebildet.
  2. Alle übrigen <img src="...">, die nicht Teil eines solchen Paares sind – also einzeln eingebundene Bilder ohne Zoom-Link.

Jedes gefundene Bild (Thumbnail, Original oder Einzelbild) wird über buildImageEntry() geprüft:

  • is_thumb: Enthält der Dateiname das konfigurierte Thumbnail-Erkennungsmuster (Standard: serendipityThumb)?
  • abs/found: Auflösung des im Artikel gespeicherten Pfads (relativ oder absolute URL) in einen echten Dateisystempfad via resolvePath(), und Prüfung, ob die Datei dort tatsächlich existiert.
  • size: Dateigröße in Byte via filesize(), falls die Datei gefunden wurde.
  • width/height: Bildmaße in Pixel via getimagesize(), falls die Datei gefunden wurde und lesbar ist.
  • garbage_bytes: Bei JPEG-Dateien (Endung .jpg/.jpeg) ermittelt findJpegRealEnd(), wo das Bild laut JPEG-Struktur wirklich endet (siehe unten). Ist die Datei länger als dieses echte Ende, steht die Differenz hier – das ist Datenmüll, der z. B. durch einen nicht sauber begrenzten Speicherpuffer bei App/Kamera oder beim Upload entstanden sein kann.

3. Auffälligkeiten bewerten

Ein Artikel gilt als "auffällig" ($hasIssue = true) und taucht in der Ergebnisliste auf, sobald mindestens eines seiner Bilder eines der folgenden Kriterien erfüllt:

  • Ein als Thumbnail eingebundenes Bild trägt nicht das Thumbnail-Erkennungsmuster im Dateinamen (lädt vermutlich versehentlich das Original statt einer verkleinerten Version).
  • Die Dateigröße liegt über dem jeweiligen Schwellenwert – dabei wird unterschieden:
    • Schwellenwert Thumbnails (KB) gilt für Thumbnails und für unverlinkte Einzelbilder.
    • Schwellenwert Originalbilder (KB) gilt für die verlinkten Original-/Zoom-Bilder, die naturgemäß größer sein dürfen.
  • Die verlinkte Datei wurde auf dem Server nicht gefunden (z. B. nach einem Umzug oder gelöschten Dateien).
  • Bei JPEGs: Die Menge an Datenmüll hinter dem echten Bildende (garbage_bytes) liegt über dem Schwellenwert Datenmüll (KB).

Für Originalbilder wird ein fehlender Thumbnail-Marker nicht als Fehler gewertet, da Originale das Thumbnail-Muster erwartungsgemäß nicht tragen.

Diese vier Kriterien prüft isCritical() pro Bild; das Ergebnis (critical) wird an jedem Bild gespeichert. Ein Artikel gilt als auffällig, sobald mindestens eines seiner Bilder critical ist. In der Artikelansicht lässt sich darüber ein Bild-Filter pro Artikel umschalten (siehe unten).

4. Ausgabe

performConfig() gibt direkt HTML aus (inkl. eines kleinen <style>-Blocks):

  • Statistik-Kacheln: Anzahl auffälliger Artikel, geprüfte Bilder, Bilder ohne Thumb-Marker, nicht gefundene Dateien, Bilder mit Datenmüll am Dateiende.
  • Artikelliste: Pro auffälligem Artikel eine Tabelle mit allen seinen Bildern (Typ, Bildpfad, Status-Badge, Dateigröße, Maße in Pixel, Datenmüll-Badge) sowie einem Link direkt zum Bearbeiten des Artikels im Backend. Der Bildpfad selbst ist anklickbar und öffnet die Datei über ihre öffentliche URL (publicUrl()) in einem neuen Tab (target="_blank").
    • Enthält ein Artikel sowohl kritische als auch unkritische Bilder, erscheint über der Tabelle die Checkbox „Nur kritische Bilder anzeigen“. Das ist reines Client-seitiges JavaScript (kein Neuladen der Seite): jede Bildzeile trägt ein Attribut data-critical="1"/"0", und das onchange-Handler der Checkbox blendet beim Aktivieren alle Zeilen mit data-critical="0" per style.display = 'none' aus. Ohne aktivierte Checkbox sind wie bisher alle Bilder des Artikels sichtbar.
  • Top-N-Liste: Unabhängig von den Schwellenwerten werden alle gefundenen Bilder nach Dateigröße sortiert; die N größten werden separat aufgelistet (Konfigurationsfeld "Anzahl größte Bilder anzeigen"), inkl. Dateigröße, Maße in Pixel und Datenmüll-Badge samt "Bereinigen"-Button. Bei 0 wird dieser Abschnitt komplett ausgeblendet. Gerade hier tauchen Dateien mit angehängtem Datenmüll typischerweise zuerst auf, weil sie dadurch unverhältnismäßig groß werden.

5. Verkleinern-Aktion

Jede Zeile mit einer gefundenen Datei hat einen "Verkleinern"-Button. Das zugehörige <form> sendet bilderscan_action=resize, den Bildpfad und den Typ (thumb/original) per POST an dieselbe Seite. performConfig() erkennt das am Anfang der Methode und ruft resizeImage() auf, bevor der eigentliche Scan läuft (damit die neue Dateigröße sofort mitgemessen wird):

  • Ermittelt die aktuelle Bildgröße via getimagesize().
  • Bricht ab, wenn das Bild bereits innerhalb der Zielgröße liegt (Feld "Zielgröße Thumbnails (px)" bzw. "Zielgröße Originalbilder (px)", je nach Bildtyp).
  • Legt vor der ersten Änderung über backupOriginal() eine Sicherungskopie der Originaldatei in einem Unterordner _originals_backup neben der Datei an (falls dort noch keine existiert – bestehende Backups werden nicht überschrieben, siehe Hinweis unten).
  • Skaliert das Bild proportional auf die längste Kante herunter (imagecopyresampled) und speichert es am selben Pfad neu (JPEG mit Qualität 82, PNG verlustfrei mit Kompressionsstufe 6, inkl. Erhalt von Transparenz).

6. Bereinigen-Aktion (Datenmüll entfernen)

Bilder mit erkanntem Datenmüll (siehe Schritt 2/3) haben zusätzlich einen "Bereinigen"-Button – sowohl in der Artikelliste als auch in der Top-N-Liste. Das zugehörige <form> sendet bilderscan_action=truncate und den Bildpfad per POST. performConfig() geht dabei so vor:

  1. findJpegRealEnd() sucht in der Datei den echten JPEG-Endmarker (EOI, 0xFFD9) und liefert die Byte-Position direkt dahinter – also die Länge, die die Datei eigentlich haben sollte.
  2. truncateFile() legt zuerst über backupOriginal() ein Backup an und kürzt die Datei anschließend per ftruncate() auf genau diese Länge. Es wird kein Pixel neu kodiert – der komplette Bildinhalt bleibt byteidentisch, es wird nur der überflüssige Anhang entfernt.

Wie findJpegRealEnd() im Detail funktioniert: Ein JPEG besteht aus einer Folge von Markern (0xFF gefolgt von einem Marker-Byte). Die Funktion läuft die Datei durch und sucht immer das nächste "echte" 0xFF-Marker-Paar, wobei sie zwei Sonderfälle überspringt, die keine echten Marker sind: Stopfbytes (0xFF 0x00, die innerhalb komprimierter Bilddaten vorkommen, damit ein zufälliges 0xFF dort nicht als Marker missverstanden wird) und Restart-Marker (0xFFD00xFFD7). Alle anderen Marker haben ein 2-Byte-Längenfeld, über das die Funktion direkt zum nächsten Marker springt – das gilt auch für den Scan-Start-Marker (SOS, 0xFFDA): dessen Längenfeld bezieht sich nur auf den kurzen Header, die anschließenden komprimierten Bilddaten werden von der Schleife dank der Stopfbyte-Erkennung automatisch mit-übersprungen, ohne dass die Funktion "wissen" muss, dass sie sich gerade in Bilddaten befindet. Der erste 0xFFD9 (End of Image), auf den man so trifft, ist das echte Bildende; alles danach in der Datei ist der Datenmüll.

Aus Performance-Gründen wird die Datei dafür komplett in den Speicher geladen (file_get_contents) – bei Dateien über 30 MB wird die Prüfung übersprungen (findJpegRealEnd() liefert dann null, kein Fehler). Das ist für Blog-Fotos in der Praxis kein Problem, sollte man aber im Hinterkopf behalten, falls mal wirklich riesige JPEGs im Spiel sind.

Zur Sicherungskopie: backupOriginal() wird sowohl von resizeImage() als auch von truncateFile() genutzt und kopiert nur, wenn unter _originals_backup/<dateiname> noch nichts liegt. Das ist beabsichtigt: Führt man an derselben Datei nacheinander mehrere Aktionen aus (z. B. erst "Bereinigen", später "Verkleinern"), bleibt im Backup immer der allererste, unveränderte Originalzustand erhalten – nicht die bereits bearbeitete Zwischenversion. Einzige Lücke: Wird die Backup-Datei manuell gelöscht, legt die nächste Aktion ein neues Backup an, aber eben vom dann aktuellen (ggf. schon veränderten) Stand.

Nach beiden Aktionen (Verkleinern wie Bereinigen) wird zusätzlich clearstatcache(true, $path) aufgerufen. Ohne das würde PHP innerhalb desselben Seitenaufrufs beim direkt anschließenden Scan noch die alte, zwischengespeicherte Dateigröße melden, obwohl die Datei auf der Platte bereits die neue, korrekte Größe hat.

Verkleinern und Bereinigen sind zwei von drei schreibenden Aktionen des Plugins – die dritte ist die Backup-Verwaltung (siehe nächster Abschnitt). Ohne einen entsprechenden Button werden nie Dateien verändert.

7. Backup-Verwaltung

Ganz unten auf der Seite gibt es einen aufklappbaren Abschnitt „🗄 Backup-Verwaltung“ (natives HTML <details>/<summary>, kein JavaScript nötig), der alle bisher angelegten Backups auflistet – auch solche, deren zugehöriges Bild inzwischen nicht mehr in einem Artikel eingebunden ist und deshalb beim normalen Scan gar nicht mehr auftauchen würde. Pro Backup gibt es zwei Buttons:

  • Zurückspielen: Kopiert die Backup-Datei zurück auf den Original-Pfad und überschreibt damit die aktuelle Datei (bzw. legt sie neu an, falls am erwarteten Pfad gerade nichts liegt). Das Backup selbst bleibt danach unverändert liegen (man kann also mehrfach zurückspielen). Das ist die einzige Aktion des Plugins, die in einen normalen (Nicht-Backup-)Ordner schreibt – dabei immer nur an genau den einen Pfad, der zu diesem spezifischen Backup gehört.
  • Löschen: Entfernt die Backup-Datei endgültig (unlink()). Bei einem nicht zugeordneten Backup (siehe unten) erscheint eine deutlich stärkere, rot hervorgehobene Sicherheitsabfrage.

Zusätzlich gibt es oberhalb der Tabelle:

  • Alle zugeordneten Backups zurückspielen: wie "Zurückspielen", aber für alle Backups, deren Original am erwarteten Pfad gefunden wurde (nur sichtbar, wenn es mindestens ein solches gibt). Läuft über runBulkBackupAction('bulk_restore_matched').
  • Alle Backups löschen: löscht wirklich jede gefundene Backup-Datei, unabhängig vom Zuordnungsstatus – inklusive nicht zugeordneter. Deutlich rot hervorgehoben, mit einer expliziten Sicherheitsabfrage inklusive Dateianzahl (und einem Hinweis, dass darunter auch nicht zugeordnete Backups sein können), da nicht rückgängig zu machen. Läuft über runBulkBackupAction('bulk_delete_all').
  • Checkbox-Auswahl + "Auswahl löschen": Jede Zeile hat eine Checkbox, verknüpft per HTML5-form-Attribut mit einem eigenen kleinen Formular oberhalb der Tabelle (so sind keine verschachtelten <form>-Elemente nötig). Der Button löscht nur die angehakten Dateien in einem Rutsch (bulk_delete_selected). Ein kleines Inline-Skript zählt beim Absenden die angehakten Boxen, verweigert das Absenden bei keiner Auswahl und fragt sonst mit der genauen Anzahl nach. Jede ausgewählte Datei durchläuft dabei ganz normal resolveBackupPath() – die Mehrfachauswahl umgeht also keine der sonstigen Prüfungen, sie spart nur die Klicks/Seiten-Reloads gegenüber einzelnem Löschen.

Jede Sammel-Aktion meldet am Ende, wie viele Dateien erfolgreich verarbeitet wurden (und ggf. wie viele fehlgeschlagen sind, z. B. wegen fehlender Schreibrechte oder einer inzwischen ungültigen Auswahl).

Es gibt bewusst keine Sammelaktion, die automatisch alle nicht zugeordneten Backups löscht. Eine frühere Version dieses Plugins hatte genau das (unter dem Namen "Nicht zugeordnete Backups löschen") – mit der falschen Begründung, das seien vermutlich Fremd-Dateien und daher gefahrlos entfernbar. Das ist falsch herum gedacht: "nicht zugeordnet" heißt nur, dass am erwarteten Pfad gerade kein Original liegt – das kann eine harmlose Fremd-Datei sein, es kann aber genauso gut die letzte noch existierende Kopie eines Bildes sein, dessen Original aus einem ganz anderen Grund verschwunden ist. Eine Ein-Klick-Sammellöschung dieser Kategorie hat in der Praxis zunächst den Verdacht auf einen Bildverlust ausgelöst (der sich im konkreten Fall dann als vermutlich plugin-unabhängig herausstellte) und wurde deshalb durch die Checkbox-Auswahl ersetzt: Man sieht weiterhin auf einen Blick, welche Backups nicht zugeordnet sind (Badge + Warntext), muss aber jede einzelne Datei bewusst anhaken statt dass eine ganze Kategorie blind auf einmal verschwindet.

Woher die Liste kommt: Jedes Mal, wenn backupOriginal() einen Backup-Ordner anlegt (oder eine bereits bestehende Datei dort vorfindet), merkt sich registerBackupDir() diesen Ordnerpfad dauerhaft über set_config()/get_config() – also im normalen Konfigurationsspeicher des Plugins, newline-separiert unter dem internen Schlüssel known_backup_dirs. Zusätzlich prüft buildImageEntry() – das ohnehin bei jedem Bild in jedem Artikel bei jedem Seitenaufruf läuft, nicht nur bei einer Verkleinern-/Bereinigen-Aktion – opportunistisch, ob neben dem gerade betrachteten Bild bereits ein Backup-Ordner liegt, und registriert ihn dann nachträglich. Das holt auch Backups nach, die schon vor Einführung dieser Backup-Verwaltung entstanden sind, ohne dass man extra nochmal "Verkleinern"/"Bereinigen" klicken muss. listBackupEntries() liest beim Seitenaufruf die so aufgebaute Ordnerliste, listet darin jede Datei auf und prüft für jede, ob am zugehörigen Original-Pfad (dirname($backupOrdner) . '/' . $dateiname) noch eine aktuelle Datei existiert. Ordner, die inzwischen manuell gelöscht wurden, fliegen dabei automatisch aus der Registrierung.

Verbleibende Lücke: Ein Backup wird nur gefunden, wenn das zugehörige Bild (egal ob die Datei noch existiert oder nicht) noch mit seinem src-Pfad in mindestens einem Artikeltext auftaucht – denn nur dann läuft es überhaupt durch buildImageEntry(). Wurde das <img>-Tag komplett aus dem Artikel entfernt, bleibt das zugehörige Backup unentdeckt, bis es erneut über eine Aktion an genau diesem Pfad berührt wird.

Annahme, die die Zuordnung "Original vorhanden/fehlt" nutzt: Für jede Datei in einem bekannten Backup-Ordner wird angenommen, dass sie exakt so heißt wie das Original und direkt im übergeordneten Ordner liegt (dirname($backupOrdner) . '/' . $dateiname) – das ist exakt das Schema, nach dem backupOriginal() selbst Backups anlegt. Liegen in einem Backup-Ordner zusätzlich Dateien, die nicht von diesem Plugin stammen (z. B. Reste eines älteren, anderen Skripts mit eigenem Namensschema, oder schlicht jede andere Datei, die dort abgelegt wurde), kann diese Zuordnung fehlschlagen bzw. – wichtiger noch – das Plugin kann eine solche Fremd-Datei nicht von einer echten Backup-Datei unterscheiden: Sobald ein echtes Backup in einem Ordner registriert ist, gilt der gesamte Ordner als bekannt, und jede weitere Datei darin wird bei den Sicherheitsprüfungen gleich behandelt (die Prüfung ist ordner-, nicht dateibasiert). Das Plugin weist bei nicht zugeordneten Dateien über einen Hinweistext in der Backup-Verwaltung darauf hin; eine zuverlässige automatische Unterscheidung "echtes Backup" vs. "fremde Datei im selben Ordner" gibt es nicht.

Sicherheit: Die Formulare senden nur einen Pfad relativ zu serendipityPath. resolveBackupPath() löst diesen Pfad über realpath() auf und lässt eine Aktion nur zu, wenn (a) der aufgelöste Pfad tatsächlich innerhalb der Serendipity-Installation liegt, (b) er auf eine existierende Datei zeigt, und (c) sein direkter Elternordner exakt einer der tatsächlich registrierten Backup-Ordner ist (Abgleich gegen getKnownBackupDirs(), nicht nur ein Namensvergleich – das funktioniert dadurch auch nach einer nachträglichen Umbenennung des Ordnernamens noch für bereits bestehende Backups weiter). Das verhindert, dass über manipulierte Formulardaten (z. B. ../../../../etc/passwd) beliebige Dateien außerhalb bekannter Backup-Ordner gelesen, überschrieben oder gelöscht werden können – alle löschenden Aktionen (einzeln, "Alle löschen", Checkbox-Auswahl) laufen ausnahmslos über diese Funktion, es gibt keinen Code-Pfad, der unlink() außerhalb eines registrierten Backup-Ordners aufruft.

Backup-Ordnername konfigurierbar: Über das Feld "Name des Backup-Unterordners" lässt sich der Ordnername (Default weiterhin _originals_backup) ändern. Der Wert wird validiert (getBackupFolderName()) und fällt bei ungültigen Eingaben (leer, enthält / oder \, oder ist . bzw. ..) automatisch auf den sicheren Default zurück. Eine Umbenennung wirkt sich nur auf neu angelegte Backups aus – bereits bestehende Ordner mit dem alten Namen bleiben unverändert auf der Platte und weiterhin über die Backup-Verwaltung erreichbar, da die Registrierung (siehe oben) auf tatsächlichen Ordnerpfaden basiert, nicht auf dem gerade konfigurierten Namen.

8. Zoom-Links ohne title-Attribut

Ein weiterer aufklappbarer Abschnitt „🏷️ Zoom-Links ohne title-Attribut“ listet alle gefundenen Zoom-Links auf, deren <a>-Tag (z. B. <a class="serendipity_image_link" href="...jpg" title="...">) kein title-Attribut trägt. Diese Funktion ist rein informativ – im Gegensatz zu Verkleinern/Bereinigen/Backup-Verwaltung verändert das Plugin hier nichts automatisch, es wird kein Artikeltext geschrieben:

  • Läuft als eigener, unabhängiger Scan direkt nach dem Haupt-Scan, mit eigener SQL-Abfrage (SELECT id, title, body, extended FROM …). Das ist bewusst vom bestehenden, bereits gut getesteten Scan oben getrennt gehalten, um dessen Logik nicht anzufassen. Als Nebeneffekt deckt dieser Scan zusätzlich das extended-Feld (den „weiterlesen“-Teil eines Artikels) mit ab, das der Haupt-Scan nicht berücksichtigt.
  • Erkennung: Eine erweiterte Version des Zoom-Link-Regex aus Schritt 2 liefert zusätzlich das komplette öffnende <a ...>-Tag. Ein title\s*=\s*["\']-Check darauf entscheidet, ob ein title-Attribut vorhanden ist (auch ein leeres title="" zählt als "vorhanden" – geprüft wird nur, ob das Attribut existiert, nicht ob es einen sinnvollen Wert hat).
  • Vorschläge aus der Mediendatenbank: findMediaId() versucht, zum href des Zoom-Links (dem Originalbild) die zugehörige ID in der Serendipity-Mediendatenbank (Tabelle {prefix}images) zu finden – über Dateiname + Endung (name/extension). Gibt es mehrere Bilder mit demselben Dateinamen (z. B. gleicher Name in verschiedenen Jahres-/Monatsordnern), wird versucht, über den Ordnernamen im path-Feld eindeutig zuzuordnen; gelingt das nicht, liefert die Funktion null – lieber keine Zuordnung als eine falsche. Zur gefundenen ID holt getMediaComments() aus {prefix}mediaproperties alle nicht-leeren Werte der Properties COMMENT1 (Kurzbeschreibung), COMMENT2 (Langbeschreibung) und ALT (Alt-Text) und zeigt jeden gefundenen Wert als eigenen Vorschlag mit eigenem Kopier-Button an. Gibt es zu einer Property mehrere Zeilen (z. B. durch property_group/property_subgroup, etwa bei mehrsprachigen Installationen), wird nur die erste nicht-leere übernommen.
  • Rückfallwert: Nur wenn in der Mediendatenbank gar nichts gefunden wurde, wird ersatzweise das alt-Attribut aus dem <img ...>-Tag im Artikeltext selbst als einziger Vorschlag angezeigt. HTML-Entities darin (z. B. &quot;) werden über html_entity_decode() aufgelöst, damit sie nicht doppelt kodiert werden, wenn sie anschließend für die Anzeige (serendipity_specialchars()) und für den Kopieren-Button (json_encode()) wieder kodiert werden.
  • Kopieren-Button (📋): Nutzt navigator.clipboard.writeText() über eine gemeinsame kleine JS-Funktion (bsCopy(), einmal im <script>-Block der Seite definiert). Kopiert ohne Meldung/alert(); stattdessen wird der Button-Text kurz zu einem Haken (✓) und der Button für 5 Sekunden grün eingefärbt (.bs-copied-Klasse, per setTimeout automatisch wieder zurückgesetzt). Der zu kopierende Text wird per json_encode() sicher als JS-String eingebettet und zusätzlich HTML-attribut-escapt – funktioniert auch mit Anführungszeichen oder Sonderzeichen im Text.
  • „Artikel bearbeiten“-Link: Öffnet die Artikel-Bearbeitungsseite im Backend mit target="_blank" in einem neuen Tab, analog zu den Artikel-Links in der Hauptliste. So lässt sich die Liste systematisch abarbeiten: Zeile für Zeile öffnen, gewünschten Vorschlag kopieren, im Artikel das title-Attribut von Hand ergänzen, nächste Zeile.

Konfigurationsfelder

Feld Bedeutung Default
Schwellenwert Thumbnails (KB) Ab dieser Größe gilt ein Thumbnail/Einzelbild als "groß" 300
Schwellenwert Originalbilder (KB) Ab dieser Größe gilt ein verlinktes Original als "groß" 800
Zielgröße Thumbnails (px) Längste Kante (Breite oder Höhe, je nachdem was größer ist), auf die Thumbnails beim Verkleinern skaliert werden; die kürzere Kante wird proportional mitskaliert 1600
Zielgröße Originalbilder (px) Längste Kante (Breite oder Höhe, je nachdem was größer ist), auf die Originale beim Verkleinern skaliert werden; die kürzere Kante wird proportional mitskaliert 2400
Thumbnail-Erkennungsmuster Zeichenkette im Dateinamen, die ein Bild als Thumbnail kennzeichnet serendipityThumb
Anzahl größte Bilder anzeigen Länge der Top-Liste der größten Bilder; 0 blendet sie aus 20
Schwellenwert Datenmüll (KB) Ab dieser Menge an Bytes hinter dem echten JPEG-Ende (EOI-Marker) gilt ein Bild als betroffen und der "Bereinigen"-Button erscheint 5
Name des Backup-Unterordners Ordnername für Sicherungskopien neben jedem bearbeiteten Bild; ungültige Werte (leer, enthält //\, ./..) fallen automatisch auf den Default zurück _originals_backup

Installation

  1. Den kompletten Ordner serendipity_event_bilderscan inklusive des UTF-8/-Unterordners per FTP in den plugins-Ordner der Serendipity-Installation hochladen (siehe "Sprachdateien" oben – ohne den Unterordner erscheint das Plugin auf Englisch statt Deutsch).
  2. Im Backend: Plugins konfigurieren → Neues Plugin installieren → das Plugin "Bilder-Check" suchen und installieren.
  3. Danach in der Plugin-Liste bei "Bilder-Check" auf Konfigurieren klicken.

Grenzen / Hinweise

  • Der Scan läuft synchron bei jedem Seitenaufruf – bei sehr vielen Artikeln kann das spürbar dauern, da alle Artikeltexte per Regex durchsucht werden.
  • Es werden nur lokal auflösbare Bildpfade geprüft. Bilder, die von einer fremden Domain eingebunden sind, werden als "nicht lokal auflösbar" übersprungen (resolvePath() gibt null zurück).
  • Die Zuordnung Thumbnail/Original funktioniert nur für das Muster <a href="original"><img src="thumb"></a>. Andere Einbindungsarten (z. B. srcset, CSS-Hintergrundbilder) werden nicht erkannt.
  • Das Verkleinern nutzt die PHP-GD-Bibliothek und unterstützt nur JPEG und PNG.