WP Manifestindependent plugin directory
manifest / events / igm-bildungsprogramm

Bildungsprogramm

Wordpress Plugin zur Darstellung des Bildungsprogramms der IG Metall.

★ 0stars

Install

Download source zip No release zip yet

This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.

Install straight from GitHub with WP-CLI:

wp plugin install https://github.com/gibro/igm-bildungsprogramm/archive/refs/heads/main.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

Eigenständiges WordPress-Plugin für Seminar- und Veranstaltungsprogramme: durchsuchbare Seminarliste mit Filterleiste, Online-Anmeldung mit automatischem Mail-Versand, CSV-Import und Marketing-Kacheln. Entwickelt für das Bildungsprogramm der IG Metall, aber ohne externe Abhängigkeiten und damit auf jeder WordPress-Installation einsetzbar – es werden keine Page-Builder- oder Formular-Plugins benötigt.

Inhalt

Funktionen

  • Seminare als eigener Beitragstyp (bi_seminar) – gepflegt mit der WordPress-eigenen Editier-Oberfläche, inklusive Start-/Enddatum, Seminarnummer, Plätzen, Kosten und Buchungsstatus.

  • Online-Seminare als zweiter Beitragstyp (bi_online) – eigenes Feldset (Untertitel, Referent*innen, Veranstalter*in, Webinar-Tool, Anmelde- und Teilnahme-Link), aber dieselben Taxonomien und dieselbe Darstellung im Frontend. Im Backend stehen sie gemeinsam mit den Präsenz-Seminaren unter „Seminare"; das Auswahlfeld Seminarform grenzt bei Bedarf ein.

  • Such- & Filterleiste per Shortcode: Volltextsuche mit Autovervollständigung, Filter-Chips mit Mehrfachauswahl (echtes ODER), Datumsbereich mit Kalender, Live-Trefferzähler. Der Chip Seminarform trennt Präsenz- und Online-Seminare. Die Volltextsuche liest alle Datenfelder – Titel, Beschreibung, Themen, Seminarort, Referent*innen, Ansprechpartner*in, Seminarnummer, den Namen der Ausbildungsreihe und die Begriffe aller Filter-Chips. Alles zusammen steht in einer eigenen Indextabelle mit FULLTEXT-Index. Wer mag, verknüpft mit ODER, NICHT, "Wortgruppen" und Klammern — ohne Zutun bleibt es beim UND.

  • Online-Anmeldung als vierstufiger Formular-Wizard; Anmeldungen werden in der Datenbank gespeichert und sind im Backend einsehbar.

  • Benachrichtigungen: beliebig viele Mails pro Anmeldung – an Teilnehmer*innen, die zuständige Geschäftsstelle (per PLZ-Zuordnung), das Bildungszentrum oder feste Adressen; mit Bedingungen und Platzhaltern.

  • CSV-Import für Seminare mit frei zuordenbaren Spalten (Mapping-Schritt mit Auto-Erkennung) – je ein eigener Tab für Präsenz- und Online-Seminare.

  • CSV-Import für PLZ → Geschäftsstelle (eigene Tabelle, schneller Lookup).

  • Marketing-Kacheln: Bild-/Text-Teaser, die auf die Suche mit vorbefüllten Filtern verlinken – inklusive Backend-Builder mit Live-Vorschau.

  • Verfügbarkeits-Ampel: auf der Detailseite steht neben dem Termin, ob er noch frei ist (Verfügbar / Fast ausgebucht / Warteliste möglich). Die Angabe wird nachts mit der Seminarsuche auf igmetall.de abgeglichen und über die Seminarnummer zugeordnet.

  • Kampagnen-Auswertung: eigene Links für Newsletter und Mailings, die den Weg vom Klick über die Seminaransicht bis zur abgeschickten Anmeldung nachvollziehbar machen.

  • Mini-Dashboard im Backend mit Kennzahlen, Warnhinweisen (z. B. fehlende E-Mail-Adressen) und Einrichtungs-Anleitung.

  • Abgleich mehrerer Websites – Sprockhövel und Berlin pflegen ihre Seminare selbst, bildung.igmetall.de holt sie ab. Kein iframe: Jede Website führt ihren eigenen Bestand und findet in der Suche nur ihn.

Voraussetzungen

Version
WordPress ≥ 5.8
PHP ≥ 7.4

Keine weiteren Plugin-Abhängigkeiten. Der Datums-Kalender (flatpickr) wird mitgeliefert.

Installation

Aus dem Release (empfohlen):

  1. Aktuelles Release als ZIP herunterladen.
  2. Im WP-Adminbereich unter Plugins → Installieren → Plugin hochladen die ZIP-Datei hochladen – oder den entpackten Ordner igm-bildungsprogramm nach wp-content/plugins/ kopieren.
  3. Plugin unter Plugins aktivieren.

Aus dem Repository:

cd wp-content/plugins
git clone https://github.com/<user>/igm-bildungsprogramm.git

Beim Aktivieren werden die Tabellen wp_bi_plz, wp_bi_anmeldungen, wp_bi_kampagnen und wp_bi_events angelegt, der Beitragstyp registriert und drei Standard-Benachrichtigungen erstellt. Deaktivieren und Löschen entfernt keine Daten.

Einrichtung

Alle Schritte finden sich auch im Backend unter Bildungsprogramm → Übersicht.

  1. PLZ importieren – Einstellungen → Tab „PLZ-Import": CSV mit den Spalten PLZ, Geschäftsstelle, E-Mail (Trennzeichen , oder ; wird automatisch erkannt).
  2. Seminare importieren – Einstellungen → Tab „Seminar-Import": CSV hochladen, im zweiten Schritt jede Spalte einem Feld zuordnen. Der Import läuft anschließend in Häppchen mit Fortschrittsbalken und Zähler. Mehrfach-Felder (Zielgruppe, Freistellung) dürfen mehrere Werte je Zelle enthalten, getrennt mit | oder ,. Alternativ lassen sich Seminare komplett von Hand anlegen.
  3. Bildungszentren-Mails – unter Seminare → Bildungszentrum je Eintrag eine E-Mail-Adresse hinterlegen (für den Trigger „Mail an Bildungszentrum").
  4. Benachrichtigungen prüfen und anpassen – gleichnamiger Menüpunkt.
  5. Such-Seite anlegen mit [bi_seminarsuche].
  6. Anmelde-Seite anlegen mit [bi_anmeldung].
  7. Unter Einstellungen → Seiten die beiden Seiten zuordnen (oder die automatische Erkennung nutzen) und die Anmeldevarianten konfigurieren.
  8. Optional: unter Kampagnen je Newsletter einen Link anlegen, wenn die Wirkung von Mailings ausgewertet werden soll.

Shortcodes

Shortcode Zweck Attribute
[bi_seminarsuche] Such-/Filterleiste + Ergebnisliste anmeldung_url, per_page (Vorgabe, Standard 20), pro_seite (wählbare Stufen, Standard 10\|20\|50\|100, nein = keine Wahl), programm, form (praesenz/online)
[bi_suchmaske] Nur die Suchmaske, ohne Ergebnisliste – Button springt auf die Übersicht ziel_url, button, titel, kicker, hinweis, programm, form
[bi_anmeldung] Anmeldeformular seminar="ID" für ein festes Seminar, reihe="ID" für eine Sammelanmeldung
[bi_kachel] Marketing-Kachel (Teaser mit Filter-Link) siehe unten
[bi_kacheln] Optionaler Grid-Container für mehrere Kacheln spalten (2/3/4, Standard 3)
[bi_reihen] Kachelübersicht aller Ausbildungsreihen titel, overline, subline, anzahl

[bi_seminarsuche] – Such- und Filterleiste

Zeigt die Filterleiste (Volltextsuche, Seminarform, Programm, Bildungszentrum, Themenfeld, Zielgruppe, Freistellung, Zeitraum) mit der Ergebnisliste. Gefiltert wird über GET-Parameter (?thema=…&ort=…, Mehrfachwerte pipe-getrennt) – Filterseiten sind damit verlinkbar und bookmarkfähig. Angezeigt werden nur kommende, sichtbare Seminare, sortiert nach Startdatum.

Wie viele Seminare auf eine Seite kommen (seit 1.124.0)

Zwischen der Filterleiste und der Trefferliste steht rechtsbündig die Wahl „Seminare je Seite" (10 · 20 · 50 · 100). Die Redaktion kann das nicht für beide Seiten zugleich richtig entscheiden: Wer sucht, will eine kurze Liste; wer stöbert, will scrollen statt klicken. Also entscheidet es, wer davorsitzt.

Sie steht über der Liste, weil sie zu dem gehört, was gleich kommt – unter der Liste hätte man erst zwanzig Treffer durchgescrollt, um zu erfahren, dass es auch fünfzig auf einmal gegeben hätte. Rechtsbündig, weil dort schon die Trefferzahl der Filterleiste steht; links bleibt der Rand für die roten Balken der Trefferzeilen frei. Das Blättern bleibt unverändert mittig unter der Liste.

[bi_seminarsuche]                          Vorgabe 20, Stufen 10|20|50|100
[bi_seminarsuche per_page="50"]            Seite startet mit 50 – 50 steht mit zur Wahl
[bi_seminarsuche pro_seite="10|25|50"]     eigene Stufen
[bi_seminarsuche pro_seite="nein"]         keine Wahl anbieten, per_page gilt fest

Drei Entscheidungen stecken darin:

  • Links, kein Auswahlfeld mit JavaScript. Jede Stufe ist eine eigene Adresse (?bi_pro_seite=50) – teilbar, als Lesezeichen brauchbar, und sie funktioniert auch dort, wo das Skript der Filterleiste nicht ankommt (Page-Builder, eingebetteter Rahmen). Die Vorgabe der Seite kommt ohne Parameter aus, damit die Adresse beim Zurückstellen wieder sauber ist.
  • Nur die angebotenen Stufen gelten. Der Wunsch steht in der Adresse und ist damit öffentlich; ?bi_pro_seite=5000 wäre eine Einladung, den Server rechnen zu lassen, und jede beliebige Zahl wäre zusätzlich ein eigener Eintrag im Seiten-Cache. Die Vorgabe aus per_page reiht sich immer mit ein, sonst ließe sich der Ausgangszustand nicht wiederherstellen.
  • Die Wahl verschwindet, wo sie folgenlos wäre – bei pro_seite="nein", wenn nur eine Stufe übrig bleibt, und wenn ohnehin alle Treffer auf die kleinste Stufe passen. Ein Bedienelement, das nichts ändert, ist keine Freundlichkeit.

Die gewählte Länge hängt an der Adresse und bleibt deshalb beim Blättern und beim Filtern erhalten. Auch „Alle zurücksetzen" in der Filterleiste lässt sie stehen: Das ist eine Aussage über die Ansicht, nicht darüber, welche Seminare gesucht werden. Ein Wechsel der Länge beginnt wieder auf Seite eins – Seite 7 von 40 hat bei 100 Treffern je Seite keine Entsprechung.

Im Einbettungsmodus (?bi_embed=1) bleibt es zusätzlich bei der alten Freiheit: Dort nennt die Adresse weiterhin jede Zahl zwischen 3 und 50, weil das fremde Redaktionssystem oft nur ein Adressfeld anbietet und bestehende Rahmen nicht umspringen sollen.

Geprüft wird das in tests/test-pro-seite.php (ohne WordPress lauffähig).

Warum die Seite schnell lädt (seit 1.96.0)

Die Seminarsuche brauchte auf dem echten Bestand (2.400 Seminare, 51.000 Meta-Zeilen) rund 500 ms, davon 95 % in der Datenbank — und fast alles in drei Abfragen à ~120 ms: die Facetten-Basis, die Trefferliste und die Gesamtzahl.

Schuld war eine einzige Bedingung: „Auf der Website anzeigen". Als meta_query geschrieben ist sie ein ODER („Wert ist 1 oder es gibt gar keine Zeile"), und ein ODER zwingt WordPress, den meta_key aus der ON-Bedingung zu nehmen und postmeta zweimal zusätzlich anzuhängen — einmal davon ganz ohne Einschränkung. Aus 2.400 Seminaren wurden so über 40.000 Zwischenzeilen, die ein GROUP BY danach wieder einsammelte.

Dieselbe Aussage als korrelierte Unterabfrage — „es gibt keine Zeile _bi_anzeigen mit einem anderen Wert als 1" — kostet nichts, weil sie über den post_id-Index läuft:

Abfrage vorher nachher
Facetten-Basis 121 ms 4 ms
Trefferliste 125 ms 7 ms
Gesamtzahl 124 ms 4 ms

Gemessen an derselben Datenbank, mit identischem Ergebnis (1777 Treffer) und identischer Reihenfolge. Dazu holt die Gesamtzahl nicht mehr alle IDs (nopaging) und sortiert sie auch nicht mehr nach post_date — gebraucht wird die Zahl, also liest sie found_posts aus einer Abfrage über eine Zeile.

Benutzt wird das über BI_CPT::sichtbar_arg(), das ein Abfrage-Argument (bi_sichtbar) setzt; BI_CPT::sichtbar_where() hängt die Unterabfrage an. Das alte Fragment BI_CPT::visible_clause() bleibt bestehen und ist für kleine Abfragen — zwei Dutzend Reihen, eine Kachel — völlig in Ordnung.

Eine Falle steckt darin. get_posts() setzt suppress_filters auf true, und dann läuft posts_where nicht: Die Einschränkung fiele still weg, die Liste zeigte ein paar Einträge zu viel, und niemand suchte danach. Deshalb meldet BI_CPT::sichtbar_pruefen() diesen Fall über _doing_it_wrong(), statt ihn hinzunehmen. Die Gleichwertigkeit beider Formulierungen steht als Wahrheitstafel in tests/test-sichtbarkeit.php.

Welche Chips erscheinen, stellt man unter Einstellungen → Tab „Such-Filterleiste" je Filter mit einem Haken ein. Gedacht ist das für Filter, die nur zeitweise gebraucht werden: Der Chip Programm (?programm=2027) ist im Auslieferungszustand aus und wird für den Übergang eingeschaltet, in dem zwei Programmjahre nebeneinander buchbar sind – danach wieder aus.

Ein abgeschalteter Filter verschwindet nur aus der Leiste; steht sein Wert in der Adresse, filtert er weiterhin. Sonst würden bestehende Links und Marketing-Kacheln in dem Moment still eine größere Treffermenge zeigen, in dem jemand den Haken entfernt.

Eigene Chips („Schnellzugriff"): die Adresse einer Suche

Unter Einstellungen → Such-Filterleiste → Eigene Chips steht je Zeile ein Name und die Adresse einer Suche. Ein Klick auf den Chip setzt genau deren Filter, ohne die Seite zu wechseln; ein zweiter Klick nimmt sie wieder zurück. Aus der Adresse zählt nur der Filterteil — die Seite, utm_… und paged fallen weg (BI_Filter::url_params).

Die Volltextsuche gehört dazu, samt Operatoren. Steht beim Kopieren ein Suchbegriff im Feld, hängt er als q=… mit in der Adresse:

In der Adresszeile Was der Chip sucht
?q=%22BR+kompakt%3A%22 "BR kompakt:" — genau diese Wortfolge
?q=bonn+ODER+berlin bonn ODER berlin
?q=arbeitsrecht+-online arbeitsrecht -online
?q=%28bonn+%7C+berlin%29+jav (bonn \| berlin) jav

Kopiert wird die Adresse so, wie sie im Browser steht — kodiert. Die Zeile unter dem Eingabefeld zeigt, was daraus gelesen wurde, und wie viele Seminare der Chip gerade träfe.

Behoben in 1.126.1. Bis 1.126.0 lief die eingegebene Adresse beim Speichern durch sanitize_text_field() — und das entfernt jede Prozentfolge. Aus ?q=%22BR+kompakt%3A%22 wurde damit ?q=BR+kompakt (die Anführungszeichen der Wortgruppe verschwanden) und aus ?ort=Sprockh%C3%B6vel ein ?ort=Sprockhvel. Geprüft wurde beim Speichern die rohe Eingabe — deshalb sah die Zeile unter dem Feld noch richtig aus und erst nach dem nächsten Öffnen falsch. Gespeichert wird jetzt über BI_Filter::url_saeubern(): kaputtes UTF-8, Steuerzeichen, HTML und Länge fliegen raus, die Kodierung bleibt.

Ein vorhandener Chip mit kodierten Zeichen muss einmal neu eingefügt werden — was beim Speichern verlorenging, steht nirgends mehr.

Dass es so lange unbemerkt blieb, lag an der Attrappe im Test: Sie war trim(strip_tags(…)) und damit gutmütiger als WordPress. Eine Attrappe, die mehr durchlässt als das Original, prüft nichts — sie bestätigt nur. Sie bildet die Prozentfolgen-Falle jetzt nach.

Geprüft in tests/test-filterleiste-chips.php.

Seiten-Cache: das Plugin leert ihn selbst (seit 1.110.0)

Ein Seiten-Cache liefert fertiges HTML aus, ohne PHP zu starten — auf einer gemessenen Installation der Unterschied zwischen rund einer Sekunde und rund fünfzig Millisekunden. Er weiß aber nichts davon, wann sich der Seminarbestand ändert. Ohne Zutun zeigte die Liste nach einem CSV-Import stundenlang den alten Stand, und die Trefferzahl in der Filterleiste („1.200 buchbare Seminare") stünde eingefroren im HTML. Ein Cache, der falsche Zahlen ausliefert, ist schlimmer als kein Cache — beim langsamen Aufruf sieht man wenigstens, was stimmt.

Das Plugin leert ihn deshalb bei zwei Anlässen:

  1. Daten geändert — Seminar gespeichert, importiert, massenbearbeitet, gelöscht, Begriffe zugeordnet. Anders als beim Suchindex zählt hier jedes Meta-Feld: „Ausgebucht", „Freie Plätze" und das Startdatum tragen keine Wörter und stehen deshalb nicht im Suchindex — die Seite ändern sie trotzdem.
  2. Der Tag hat gewechselt. Buchbar heißt „Startdatum ab heute", und das wird beim Rendern ausgewertet. Eine um 23:50 gespeicherte Seite zeigte um 00:10 noch den Termin von gestern. Deshalb läuft kurz nach Mitternacht ein Leeren, ohne dass sich irgendetwas geändert haben muss.

Einmal je Aufruf, höchstens einmal je Minute. Ein Import setzt zwanzig Meta-Felder je Zeile und legt hundert Zeilen je Häppchen an; würde jede Änderung sofort leeren, liefen tausende Löschläufe über das Cache-Verzeichnis — und der Cache wäre dauerhaft leer, also wirkungslos. Während des Aufrufs wird deshalb nur vorgemerkt, am Ende einmal geleert. Kommt in der Sperrfrist noch etwas, bleibt es als offen stehen und wird nachgeholt — von jedem beliebigen Aufruf, nicht nur von WP-Cron. Sonst bliebe auf einer Installation mit DISABLE_WP_CRON genau der letzte Import-Stand ungeleert.

Erkannt werden WP Super Cache, WP Rocket, W3 Total Cache, WP Fastest Cache, Cache Enabler, LiteSpeed Cache, SG Optimizer, Kinsta und WP Engine — an ihrer Funktion bzw. Klasse, nicht am Plugin-Namen, damit es auch bei zwei parallel laufenden greift. Ist keiner da, passiert schlicht nichts. Für alles andere:

add_action( 'bi_cache_leeren', function () { /* eigenen Cache leeren */ } );

Nicht dabei ist Autoptimize: Das ist kein Seiten-Cache, sondern ein Asset-Optimierer. Seinen Zwischenspeicher bei jeder Seminaränderung zu leeren, hieße CSS und JS ohne Grund neu bauen zu lassen.

Unter Einstellungen → Such-Filterleiste steht unten, welcher Cache erkannt wurde und wann zuletzt geleert wurde, dazu ein Knopf Cache jetzt leeren (der geht an der Sperrfrist vorbei — wer ihn drückt, will jetzt leeren).

Wichtig bei der Einrichtung des Cache-Plugins: Adressen mit Parametern (?q=, ?ort=, ?thema= …) müssen vom Caching ausgenommen bleiben. Sonst bekämen alle Besucher dieselbe Trefferliste, egal was sie filtern. Die Blätterung ist davon nicht betroffen — /seminare/page/2/ ist ein Pfad und darf gecacht werden.

Geprüft in tests/test-cache-purge.php.

Volltextsuche: Wörter statt Wortfolge, mit Tippfehler-Korrektur

Gesucht wird Wort für Wort und mit UND verknüpft (seit 1.87.0): arbeitsrecht grundlagen findet „Grundlagen des Arbeitsrechts", die Reihenfolge spielt keine Rolle. Vorher wurde die ganze Eingabe als eine Zeichenkette verglichen — schon zwei Wörter in der falschen Reihenfolge fanden nichts.

Seit 1.126.0 lässt sich das UND überschreiben — siehe den nächsten Abschnitt.

Boolesche Operatoren (seit 1.126.0)

Das UND bleibt die Vorgabe — wer nichts weiter tut, sucht wie vorher. Wer mehr braucht, schreibt es hin:

Eingabe Bedeutung
arbeitsrecht bonn beide Wörter müssen vorkommen (wie bisher)
bonn ODER berlin eines von beiden genügt — auch OR, auch |
arbeitsrecht UND bonn dasselbe wie ohne UND — auch AND, auch &
NICHT online ohne dieses Wort — auch NOT, auch -online
"neue Betriebsräte" genau diese Wortfolge — auch „…" aus dem Programmheft
(bonn ODER berlin) jav Klammern fassen zusammen

Vorrang wie überall: NICHT bindet stärker als UND, UND stärker als ODER. a ODER b c heißt also a ODER (b UND c).

Unter dem Suchfeld steht der aufklappbare Hinweis „Suche verfeinern" mit denselben Beispielen. Zugeklappt ist er eine Zeile: Wer die Operatoren nicht braucht, soll sie nicht wegsehen müssen.

Vier Entscheidungen dahinter:

  • Der Bindestrich ist nur am Anfang eines Stücks ein Ausschluss. Mitten im Wort ist er ein Bindestrich — sonst würde aus E-Learning ein „alles ohne Learning" und aus der Seminarnummer LO-1234 eine Suche ohne die 1234. Ein Gedankenstrich mit Leerzeichen ringsum (Grundlagen – Teil 1) ist gar kein Operator und fällt weg.
  • Klein geschrieben ist ein Angebot, GROSS geschrieben eine Ansage. „nicht", „und" und „oder" sind auch gewöhnliches Deutsch: Nicht nur reden ist ein Seminartitel, keine Ausschlussbedingung. Findet eine Anfrage mit kleingeschriebenen Operatorwörtern nichts, wird sie deshalb ein zweites Mal wortwörtlich gelesen — ohne Hinweis über der Liste, denn es steht nichts anderes darin, als eingetippt wurde; nur die Verknüpfung war eine andere. Bei NICHT in Großbuchstaben und bei jedem Zeichen (-, ", |, &, Klammern) passiert das nicht: Wer so schreibt, meint es, und eine leere Liste ist dann die richtige Antwort. Sonst zeigte arbeitsrecht NICHT online am Ende genau die Online-Seminare, die jemand gerade ausgeschlossen hat.
  • Was MATCH nicht kann, geht sofort zu LIKE. BOOLEAN MODE kennt kein UND innerhalb einer ODER-Gruppe ((a b) ODER c), keinen Ausschluss einer ganzen Gruppe (NICHT (a ODER b)) und liefert bei einer Anfrage aus lauter Ausschlüssen die leere Menge statt „alles außer". Solche Bäume gehen deshalb von vornherein über LIKE — das bildet jeden Baum ab. Die Antwort wäre sonst nicht langsam, sondern falsch. Entschieden wird das in BI_Suche::modus_fuer(), einmal je Suche, vor der ersten Abfrage.
  • Es bleibt EINE Unterabfrage. Egal wie verschachtelt die Eingabe ist — aus dem Baum wird ein einziges EXISTS auf wp_bi_suchindex.

Die Tippfehler-Korrektur lässt Operatoren stehen und behält das Vorzeichen: aus -arbeitsercht wird -Arbeitsrecht, nicht Arbeitsrecht. Auch die Autovervollständigung ergänzt nur das angefangene Wort und rührt weder Operatorwörter noch ein gesetztes - an.

Die Operatoren tragen auch über die Adresse: ?q=%22BR+kompakt%3A%22 ist eine gültige Suche und damit als eigener Chip speicherbar — siehe Eigene Chips.

Geprüft in tests/test-suche-boolean.php. Ausschalten lassen sich die Operatorwörter über den Filter bi_suche_operatoren (die Zeichen -, " und die Klammern bleiben davon unberührt).

Über alle Datenfelder (seit 1.107.0)

Bis 1.106.3 verglich die Suche nur den Titel. Alles andere, was ein Seminar ausmacht, war unsichtbar: Wer Sprockhövel eintippte, bekam nichts — obwohl dreihundert Seminare dort stattfinden. Wer sich an ein Stichwort aus den Themen erinnerte, an die Referentin oder an die Ansprechpartner*in aus dem Telefonat, suchte vergeblich.

Jedes Suchwort wird jetzt gegen alles gehalten:

Wo Was
Titel post_title
Beschreibung und Themen im Seminar ohne HTML, siehe unten
alle Meta-Felder Seminarnummer, Untertitel, Seminarort, Referent*innen, Ansprechpartner*in und deren Mailadresse, Kosten-Hinweis, Webinar-Tool, Anmelde- und Online-Link, „Teil | Reihe" — und jedes selbst angelegte Feld (_bix_…)
alle Taxonomien Bildungszentrum, Themenfeld, Zielgruppe, Freistellung, Programmjahr

Alles zusammen steht seit 1.109.0 in einer Zeile je Seminar in der Tabelle wp_bi_suchindex — siehe Der Suchindex.

Vier Entscheidungen dahinter:

  • Das UND gilt zwischen den Wörtern, das ODER zwischen den Feldern. sprockhövel betriebsrat findet ein Seminar, das in Sprockhövel stattfindet und „Betriebsrat" im Titel trägt. Beide Wörter im selben Feld zu verlangen wäre unbrauchbar — niemand tippt so. (Das UND zwischen den Wörtern ist seit 1.126.0 nur noch die Vorgabe, siehe oben; das ODER zwischen den Feldern gilt unverändert immer.)
  • Zahlen, Daten und Haken bleiben draußen. Ja/Nein, Datum, Uhrzeit, Zahl und Betrag tragen keine Wörter. Wären sie dabei, fände 1 jedes Seminar mit irgendeinem gesetzten Haken und 12 jeden Dezember-Termin, jeden Termin am 12. und jeden Preis mit einer 12 darin. Für genau diese Felder gibt es den Chip Zeitraum. Die Liste steht in BI_Suche::keine_wortfelder().
  • Ein eigenes Feld ist von allein durchsuchbar. Die Liste der Meta-Schlüssel kommt aus der Feld-Registry beider Seminarformen (BI_Suche::meta_keys()) — ein in der Datenpflege angelegtes Textfeld muss nirgends nachgetragen werden. Filter: bi_suche_meta_keys, bi_suche_taxonomien.
  • Eine einzige Unterabfrage, egal wie viele Wörter. Bis 1.108.0 stand je Suchwort eine Unterabfrage über dreißig postmeta-Schlüssel und eine über die drei Term-Tabellen in der Bedingung — sechs Wörter waren zwölf Unterabfragen, und keine davon konnte einen Index benutzen. Das kostete auf dem Bestand Sekunden. Seit 1.109.0 ist es ein EXISTS auf wp_bi_suchindex.

Was die Leiste als Chip anbietet, ändert sich dadurch nicht: Filterbare Facetten bleiben Taxonomien. Neu ist nur, dass man ihre Begriffe auch tippen kann, statt zweimal zu klicken.

Der Suchindex (seit 1.109.0)

Neben jedem Seminar steht eine Zeile in wp_bi_suchindex: Titel, Beschreibung, alle Meta-Werte, alle Begriffe — in einem Feld, ohne HTML, mit einem FULLTEXT-Index darauf.

Warum eine eigene Tabelle. Bis 1.108.0 suchte die Bedingung je Wort mit zwei Unterabfragen: eine über postmeta (dreißig Schlüssel, meta_value LIKE '%wort%') und eine über die drei Term-Tabellen. Sechs Wörter waren zwölf Unterabfragen, und keine davon konnte einen Index benutzen — LIKE '%…%' kann das nie. Gemessen auf 20.000 Indexzeilen:

Weg eine selektive Suche
MATCH … AGAINST auf wp_bi_suchindex 1 ms
LIKE auf wp_bi_suchindex 58 ms
die alten EXISTS über postmeta 98 ms

Zwei Wege, weil Deutsch zusammensetzt. MATCH findet Wortanfänge: betriebs findet „Betriebsrat". Es findet aber nicht mitten im Wort — rat findet kein „Betriebsrat", und im Deutschen ist das kein Sonderfall, sondern der Alltag. Deshalb läuft die Suche erst mit MATCH, und nur wenn das nichts findet, noch einmal mit LIKE auf derselben Tabelle. Der teure Weg wird also nur bezahlt, wenn der billige nichts bringt — dieselbe Regel wie bei der Tippfehler-Korrektur.

Von vornherein mit LIKE gesucht wird, wenn ein Wort kürzer als drei Zeichen ist. Das ist keine Feinheit: InnoDB nimmt kürzere Wörter gar nicht erst in den Index auf (innodb_ft_min_token_size), und ein Pflichtwort ohne Indexeintrag lässt MATCH die gesamte Abfrage leer zurückgeben. Aus BR im Betrieb würde also nicht etwa eine ungenauere Suche, sondern gar keine. Dasselbe gilt für E-Learning — zerlegt sind das „E" und „Learning".

Lässt sich der FULLTEXT-Index nicht anlegen (alte MySQL-Version, MyISAM), merkt das Plugin sich das und sucht durchgehend mit LIKE. Langsamer als MATCH, aber immer noch um Größenordnungen schneller als die alten Unterabfragen.

Kein rohes HTML

Die Beschreibung (post_content) und die Themen im Seminar (_bi_themen) sind HTML. Roh mit LIKE verglichen war das in beide Richtungen falsch:

  • class traf jeden formatierten Absatz, https jeden Link, strong jede fette Stelle. Treffer, die mit dem Inhalt nichts zu tun haben.
  • Betriebs<strong>rat</strong> enthält kein „Betriebsrat". Das Seminar war für die Suche danach unsichtbar, obwohl das Wort auf der Seite steht.

Beim Entkleiden (BI_Suche::text_entkleiden()) gilt deshalb eine Unterscheidung, die sich nicht von selbst versteht:

HTML wird zu warum
Betriebs<strong>rat</strong> Betriebsrat Auszeichnungen im Wort (b, strong, em, a, span …) verschwinden spurlos
<li>Kündigung</li><li>Abmahnung</li> Kündigung Abmahnung jedes andere Tag hinterlässt ein Leerzeichen
<p class="hinweis">Kosten</p> Kosten Attribute werden nie zu Wörtern
Arbeit &amp; Recht Arbeit & Recht Entities werden aufgelöst, sonst stünde „amp" im Index
`` (nichts) dort steht Code, kein Text
Umlaute stehen doppelt drin

Zu jedem Wort mit Umlaut kommt die aufgelöste Schreibweise dazu — „Betriebsräte" bringt „betriebsraete" mit. Die Datenbank hält „ä" und „a" für gleich, „ae" und „ä" aber nicht; wer ohne Umlauttaste tippt, fände das Seminar sonst erst über die Tippfehler-Korrektur, also nach einer zweiten Abfrage und mit einem Hinweis über der Liste. So trifft schon die erste.

Wie der Index aktuell bleibt

Beim Speichern, bei jeder Änderung an einem beteiligten Feld und beim Setzen von Begriffen — das deckt auch CSV-Import und Massenbearbeitung ab, die beide an save_post vorbeiarbeiten. Gesammelt wird während des Aufrufs, geschrieben einmal am Ende: Ein Import setzt zwanzig Meta-Felder je Zeile und würde die Zeile sonst zwanzigmal neu bauen.

Für den Bestand läuft ein Nachbau in Häppchen (200 Zeilen), angestoßen bei jedem Aufruf im Backend und zusätzlich von einem Cron-Ereignis. Je Aufruf laufen so viele Häppchen, wie in drei Sekunden passen: Solange der Index leer ist, findet die Suche gar nichts, und ein einzelnes Häppchen je Aufruf hieße bei zweitausend Seminaren zehn Klicks im Backend, bis die Suche wieder trägt. Kein Knopf, den jemand drücken muss — und keine Abhängigkeit von WP-Cron allein, das auf vielen Installationen abgeschaltet ist. Solange etwas fehlt, steht der Stand als Hinweis im Backend:

Bildungsprogramm: Der Suchindex wird aufgebaut – 1.200 von etwa 5.400 Einträgen. Bis er fertig ist, findet die Volltextsuche nicht alle Seminare.

Geprüft in tests/test-suche-vorschlag.php und tests/test-suche-nummer.php.

Eine Suche, nicht vier (seit 1.109.0)

Ein Aufruf mit ?q=… fuhr bis 1.108.0 drei bis vier Mal dieselbe teure Bedingung:

  1. eine Probeabfrage, nur um zu wissen, ob es Treffer gibt
  2. die Facettenzähler (count_base_ids, mit nopaging über alle Treffer-IDs)
  3. die eigentliche Liste
  4. und griff die Tippfehler-Korrektur, noch einmal Nummer 1

Jetzt wird zuerst die Liste geholt — ob sie leer ist, sagt found_posts von selbst — und die Facetten rechnen danach mit dem Text und dem Suchweg, mit dem am Ende tatsächlich gesucht wurde. Dazu merkt sich count_base_ids() sein Ergebnis für die Dauer eines Aufrufs; dieselbe Frage kommt je Seite mehrfach (Gesamtzahl, je Facette mit eigener Auswahl, Kopfzahl der Suchmaske).

Autovervollständigung (seit 1.108.0)

Ab dem dritten Zeichen schlägt das Suchfeld etwas vor, nach einer Denkpause von 300 ms. Drei Arten, weil es drei Arten von Absicht gibt:

Gruppe Beispiel ein Klick bewirkt
Suchbegriffe betrieb → „Betriebsrat" das angefangene Wort wird zu Ende geschrieben und gesucht
Filter sprock → „Bildungszentrum Sprockhövel" setzt den Chip, nicht den Volltext
Seminare der Treffer selbst, mit Datum und Ort führt direkt auf die Detailseite

Entschieden ist dabei:

  • Die Suche läuft weiterhin erst mit Enter. Die Vorschläge sind ein Angebot, keine Suche beim Tippen. Wer sie ignoriert und Enter drückt, bekommt genau das, was im Feld steht.

  • Erst verfeinern, dann treffen — außer bei einer Seminarnummer. Wer eine Nummer abtippt, will genau ein Seminar und nichts sonst; dann stehen die Seminare oben und sonst nichts.

  • Nur Wortanfänge. arbeit schlägt „Arbeitsrecht" vor, nicht „Sozialarbeit". Ein Vorschlag, der irgendwo in der Mitte passt, wirkt wie ein Fehler.

  • Ein Filtervorschlag liefert den Wert, den die Leiste kennt. Bildungszentrum und Zielgruppe fasst die Leiste zu Gruppen zusammen („Berlin-Pichelssee" gehört zu „Bildungszentrum Berlin"); ein Vorschlag mit dem rohen Begriff führte auf einen Chip, den es gar nicht gibt. Verglichen wird deshalb gegen die fertigen Optionen der Leiste.

  • Das angefangene Wort wird ersetzt, der Rest bleibt. Aus arbeitsrecht sprock + Klick auf das Bildungszentrum wird q=arbeitsrecht plus Chip — nicht beides doppelt.

  • Bedienbar ohne Maus. ↓ ↑ wandern durch die Liste (und wieder heraus zur eigenen Eingabe), Enter übernimmt, Esc schließt. Das Feld ist eine combobox mit aria-activedescendant.

  • Über JSON geht Text, kein HTML (seit 1.109.1). get_the_title() schickt den Titel durch wptexturize(); aus "Ent-rüstet euch!" wird dabei &#8222;Ent-rüstet euch!&#8220; und aus einem Gedankenstrich &#8211;. Für HTML ist das richtig — aber das JavaScript maskiert beim Einsetzen selbst, und dann stand &amp;#8211; in der Liste und der Browser zeigte die Zeichenfolge an. Anzeigetexte werden deshalb serverseitig aufgelöst. Filterwerte nicht: Ein Wert wandert in die Adresszeile und wird gegen den Begriffsnamen gehalten, so wie er in der Datenbank steht. Dasselbe gilt für die Beschriftungen der Filter-Chips (Arbeit &amp; Recht → „Arbeit & Recht").

  • Seminarvorschläge erst ab vier Zeichen. Suchbegriffe und Filter kommen aus zwischengespeicherten Listen und kosten praktisch nichts; ein Seminarvorschlag ist dagegen eine echte Abfrage gegen den Bestand — bei drei Buchstaben mit einer Trefferliste, die noch nichts über die Absicht verrät.

Technisch: eine AJAX-Aktion bi_suche_vorschlag (auch für Gäste, rein lesend), entprellt um 300 ms, laufende Anfragen werden abgebrochen. Zwischengespeichert wird zweimal — die Facettenliste eine Stunde (sie hängt nur am Bestand) und jede fertige Antwort fünf Minuten. Ohne das liefe bei jedem Tastendruck eine Zählabfrage über den ganzen Bestand; und jede Anfrage kostet auf dem Server einen vollen WordPress-Start, ganz gleich wie billig die Abfrage dahinter ist.

Die Seminarnummer findet ihr Seminar (seit 1.95.0)

Die Seminarnummer steht im gedruckten Programm, in jeder Bestätigungsmail und auf jedem Anmeldebogen. Wer sie in das Suchfeld tippt — B00027030, SE14822 —, landet auf dem Seminar. Vorher verglich die Suche nur den Titel: Die Nummer, mit der am Telefon und in der Geschäftsstelle gearbeitet wird, fand als Einzige nichts.

  • Nur wenn die Eingabe nach einer Nummer aussieht wird die Nummer überhaupt abgefragt: ein Wort, mindestens vier Zeichen, mindestens eine Ziffer darin. Sonst zöge eine Suche nach 2026 alle Nummern mit einer 26 darin herein, und die Trefferliste wäre nicht mehr die Antwort auf die Frage.
  • Teilstücke reichen. B0002 findet B00027030 — beim Abtippen aus dem Heft bricht es sonst am letzten Zeichen ab.
  • Neben dem Titel, nicht statt seiner. Gefunden wird, was im Titel passt oder die Nummer trägt. Alle übrigen Filter gelten unverändert weiter.
  • Keine Tippfehler-Korrektur auf Nummern. Findet eine Nummer nichts, ist das kein Buchstabendreher, sondern eine Nummer ohne Seminar. Sie zu einem ähnlich geschriebenen Titelwort zu verbiegen, beantwortete eine Frage, die niemand gestellt hat.

Was die Suche im Frontend nicht zeigt, zeigt sie auch mit Nummer nicht: Die Ergebnisliste kennt nur veröffentlichte, sichtbare Seminare ab heute. Die Nummer eines vergangenen oder ausgeblendeten Termins bleibt deshalb ohne Treffer — im Backend findet dieselbe Nummer ihn weiterhin.

Nicht zu verwechseln mit dem Parameter ?nr=…: Der filtert exakt auf eine oder mehrere Nummern (pipe-getrennt) und ist für verlinkte Listen und Marketing-Kacheln gedacht. Das Suchfeld vergleicht dagegen auch Teilstücke. Geprüft in tests/test-suche-nummer.php.

Findet die Suche nichts, wird einmal korrigiert. Jedes Wort der Eingabe wird mit dem Wortschatz aller Seminartitel verglichen (Levenshtein-Abstand); liegt ein Wort nah genug an einem bekannten, wird damit erneut gesucht:

Keine Treffer für Arbeitsercht. Gezeigt werden Treffer für Arbeitsrechts.

Vier Entscheidungen dahinter:

  • Nur bei null Treffern. Solange die Suche etwas findet, wird nichts geraten. Ein Wort, das im Wortschatz steht, ist richtig geschrieben — auch wenn es einem anderen ähnlich sieht.
  • Nie stillschweigend. Der Hinweis über der Liste nennt beides: was eingetippt wurde und wonach gesucht wird. Eine Suche, die heimlich etwas anderes sucht, verwirrt mehr, als sie hilft — man sieht Ergebnisse und weiß nicht, wieso.
  • Umlaute sind der halbe Nutzen. betriebsraete findet „Betriebsräte". Die Datenbank hält „ae" und „ä" nicht für dasselbe; der häufigste „Tippfehler" ist gar keiner, sondern eine Tastatur ohne Umlauttaste. Deshalb steht im Wortschatz zu jedem normalisierten Wort auch die Originalschreibweise — gesucht wird mit der.
  • Lieber zaghaft als falsch. Die Toleranz wächst mit der Wortlänge (1 Fehler bis 5 Zeichen, 2 bis 9, sonst 3), Wörter unter vier Zeichen bleiben unangetastet, und ein Wortanfang wie bilanz wird nicht zu „Bilanzanalyse" gemacht — er findet ohnehin. Bringt auch die Korrektur nichts, steht in der Meldung wieder das, was eingetippt wurde.

Der Wortschatz kommt seit 1.108.0 aus drei Quellen: den Titeln aller veröffentlichten Seminare und Reihen, den Begriffen aller Taxonomien (Bildungszentrum, Themenfeld, Zielgruppe, Freistellung, Programm) und einer Handvoll Begriffsfelder — Seminarort, Untertitel, Referent*innen, Ansprechpartner*in, „Teil | Reihe". Damit wird auch sprockhövl zu „Sprockhövel" korrigiert, was vorher nicht ging: Der Ort steht in keinem Titel.

Begriffe zählen dabei mit ihrer Verwendung: Ein Bildungszentrum steht einmal in der Term-Tabelle, hängt aber an dreihundert Seminaren. Ohne Gewicht verlöre „Sprockhövel" jeden Gleichstand gegen ein beliebiges Titelwort.

Bewusst nicht aus dem Fließtext. Beschreibung und Themen bestehen zu großen Teilen aus „werden", „einer", „Teilnehmerinnen". Im Wortschatz verdrängten diese Wörter die Begriffe — die Häufigkeit entscheidet ja bei gleichem Abstand —, und jede Korrektur führe gegen das nächstbeste Füllwort. Ein Wortschatz ist eine Begriffsliste, kein Textkorpus. Die Liste steht in BI_Suche::wortschatz_felder(), Filter bi_suche_wortschatz_felder.

Er liegt in einem Transient und wird verworfen, sobald ein Seminar oder ein Begriff gespeichert oder gelöscht wird. Die Korrektur gilt für die Ergebnisliste ([bi_seminarsuche]) — die eigenständige Suchmaske zählt weiter die Eingabe wörtlich, weil sie bei j

This README is longer than the copy stored here. Read the rest on GitHub →