diff --git a/FUNKTIONSWEISE.md b/FUNKTIONSWEISE.md new file mode 100644 index 0000000..123e319 --- /dev/null +++ b/FUNKTIONSWEISE.md @@ -0,0 +1,330 @@ +# mdgrep – Funktionsweise + +Dieses Dokument erklärt, **wie mdgrep innen arbeitet und warum es so gebaut ist**. Für die Benutzung siehe +[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md), für Änderungen je Version [CHANGELOG.md](CHANGELOG.md). + +Stand: Version 0.1.1 + +--- + +## Inhaltsverzeichnis + +1. [Überblick](#1-überblick) +2. [Module](#2-module) +3. [Suchsprache: vom Text zum Syntaxbaum](#3-suchsprache-vom-text-zum-syntaxbaum) +4. [Auswertung und Zeitlimit](#4-auswertung-und-zeitlimit) +5. [Hervorhebung und Ausschnitte](#5-hervorhebung-und-ausschnitte) +6. [Markdown-Zerlegung](#6-markdown-zerlegung) +7. [Suchmaschine](#7-suchmaschine) +8. [Web-API und Widget](#8-web-api-und-widget) +9. [Designentscheidungen](#9-designentscheidungen) +10. [Grenzen und Erweiterungsideen](#10-grenzen-und-erweiterungsideen) + +--- + +## 1. Überblick + +mdgrep hat **keinen Suchindex**. Alle Texte liegen als *Blöcke* im Speicher; jede Suche prüft jeden Block +(nach Filtern) gegen die übersetzte Suchanfrage. Das ist einfach, unterstützt beliebige reguläre Ausdrücke +und ist bei einigen Megabyte Text schnell genug. + +```mermaid +flowchart LR + subgraph Anfrage + Q["Suchtext
(rust OR go) -crypto"] --> T[Tokenizer] --> P[Parser] --> AST["Syntaxbaum
And/Or/Not/Term"] + end + subgraph Daten + D["Markdown-Dateien
oder eigene Daten"] --> L["load_markdown()
split_markdown()"] --> B["Blöcke
(title, text, meta)"] + end + AST --> E["SearchEngine.search()"] + B --> E + E --> F[Filter] --> M["Treffer prüfen
(mit Zeitbudget)"] --> S["sortieren, gruppieren,
Seite ausschneiden"] + S --> H["Fundstellen (Spans)
→ Ausschnitte (Segmente)"] --> R["SearchResult
.to_dict()"] + R --> CLI["mdgrep (Terminal)"] + R --> API["Flask: GET /search (JSON)"] --> W["Widget (Browser)"] +``` + +Drei Begriffe tragen das ganze Design: + +| Begriff | Bedeutung | +|---|---| +| **Block** | kleinste durchsuchbare Einheit – ein Absatz, Listenpunkt, Abschnitt, eine Datei oder ein eigener Datensatz. Die logische Verknüpfung gilt *pro Block*. | +| **Span** | Fundstelle als `(start, ende)` im Text eines Blocks | +| **Segment** | Anzeigestück `{"t": Text, "hit": bool}`; eine Liste von Segmenten ergibt den hervorgehobenen Ausschnitt | + +--- + +## 2. Module + +| Modul | Aufgabe | Wichtigste Namen | +|---|---|---| +| `query.py` | Suchsprache: Tokenizer, Parser, Auswertung, Fundstellen | `compile_query`, `Query`, `QuerySyntaxError`, `QueryTimeout` | +| `engine.py` | Blöcke halten, filtern, prüfen, sortieren, gruppieren, Seiten | `Block`, `SearchEngine`, `SearchResult`, `Group` | +| `snippets.py` | Fundstellen → Anzeige-Segmente, Kürzen um Treffer | `segments` | +| `markdown.py` | Markdown → Blöcke; Markdown → Klartext | `load_markdown`, `split_markdown`, `markdown_to_text`, `slugify` | +| `cli.py` | Kommandozeile | `main` (Einstieg `mdgrep`) | +| `web.py` | Flask-Blueprint/-App, Entwicklungsserver | `create_blueprint`, `create_app` | +| `static/` | Browser-Widget | `mdgrep-widget.js` (`MdGrep.mount`), `mdgrep-widget.css` | +| `__init__.py` | öffentliche API, `export_static()` | | + +Abhängigkeiten zeigen nur in eine Richtung: `engine` nutzt `query` und `snippets`, `markdown` nutzt +`engine` (für `Block`), `cli` und `web` nutzen alles. `query` und `snippets` hängen von nichts im Paket ab. +Nur `web.py` braucht Flask; der Rest kommt mit dem Paket `regex` aus. + +--- + +## 3. Suchsprache: vom Text zum Syntaxbaum + +### 3.1 Tokenizer (`_tokenize`) + +Der Suchtext wird Zeichen für Zeichen in Tokens zerlegt: + +| Zeichen am Token-Anfang | Token | +|---|---| +| Leerraum | wird übersprungen | +| `(` `)` | Klammer | +| `&` / `&&`, `\|` / `\|\|` | `AND` / `OR` | +| `!` | `NOT` | +| `-` **gefolgt von Nicht-Leerraum** | `NOT` (ein einzelnes `-` mit Leerzeichen danach ist ein normales Suchwort) | +| `"` … `"` | Phrase; `\"` steht für ein Anführungszeichen | +| `/` … `/` | regulärer Ausdruck; `\/` wird zu `/`, andere `\x` bleiben erhalten | +| sonst | Wort bis zum nächsten Leerraum, `(`, `)` oder `"` | + +Ein Wort, das genau `AND`, `OR`, `NOT`, `UND`, `ODER` oder `NICHT` lautet, wird zum Operator – **nur in +Großbuchstaben**. Weil `-`, `&`, `|`, `!` und `/` nur *am Token-Anfang* Sonderbedeutung haben, bleiben Wörter +wie `e-mail`, `AT&T` oder `TCP/IP` normale Suchwörter. + +### 3.2 Parser (rekursiver Abstieg) + +```text +query := or_expr EOF +or_expr := and_expr ( OR and_expr )* +and_expr := not_expr ( [AND] not_expr )* ← AND darf fehlen (Leerzeichen = UND) +not_expr := NOT not_expr | primary +primary := TERM | "(" or_expr ")" +``` + +Daraus ergibt sich der Vorrang **NOT > AND > OR**. Ein implizites UND wird erkannt, wenn nach einem Ausdruck +direkt ein Term, eine öffnende Klammer oder ein NOT folgt. + +Fehler werden als `QuerySyntaxError(message, position, code)` gemeldet; `position` zeigt auf das Token (bzw. die +öffnende Klammer oder das Textende), `code` ist stabil und übersetzbar (Liste in der Bedienungsanleitung). + +Nebenbei merkt sich der Parser für jeden Term, ob er unter einer **ungeraden Anzahl von NOT** steht +(`negated`). Das braucht später die Hervorhebung: `rust -crypto` markiert nur „rust". + +### 3.3 Terme werden zu regulären Ausdrücken + +| Term | Muster | Beispiel | +|---|---|---| +| Wort | `regex.escape(wort)` | `c++` → `c\+\+` | +| Phrase | Wörter maskiert, verbunden mit `\s+` | `"open source"` → `open\s+source` (trifft auch über Zeilenumbrüche) | +| Regex | unverändert | `/llms?\b/` | + +Alle Muster werden mit `IGNORECASE | UNICODE` des PyPI-Moduls **`regex`** kompiliert (Umlaute inklusive). +Ungültige reguläre Ausdrücke fallen schon hier als `invalid_regex` auf – nicht erst bei der Suche. + +--- + +## 4. Auswertung und Zeitlimit + +Der Syntaxbaum besteht aus `Term`, `Not`, `And`, `Or`; jeder Knoten hat `evaluate(text, deadline)`. +`And`/`Or` nutzen `all()`/`any()` und brechen daher früh ab. + +**Zeitlimit:** Bestimmte reguläre Ausdrücke brauchen exponentiell lange („katastrophales Backtracking", +z. B. `/(a|aa)+$/`). Das Modul `regex` kann jede Suche mit `timeout=` abbrechen. Der Parameter ist aber teuer +(etwa vierfache Laufzeit pro Aufruf), daher: + +- **Wörter und Phrasen** laufen **ohne** Zeitlimit – maskierte Literale und `\s+` sind linear, können also nicht + entarten. +- **Reguläre Ausdrücke** bekommen als Zeitlimit die Restzeit bis zur Frist (`deadline`). +- `Query.matches()` prüft die Frist **einmal pro Aufruf**; ist sie abgelaufen, folgt `QueryTimeout`. + +Die Suchmaschine setzt eine Frist für die ganze Suche (`time_budget`). Läuft sie ab, endet die Schleife und +das Ergebnis trägt `truncated = True` – der Server bleibt erreichbar, der Nutzer bekommt ein Teilergebnis. + +--- + +## 5. Hervorhebung und Ausschnitte + +**Fundstellen (`Query.spans`)**: Für jeden *positiven* Term (nicht unter NOT) werden alle Treffer gesucht, +Treffer der Länge 0 (z. B. von `/x*/`) verworfen, dann sortiert und überlappende oder angrenzende Bereiche +zusammengefasst. Aus `open OR source` auf „open source" werden so zwei getrennte Markierungen, aus +`abc OR bcd` auf „abcd" eine durchgehende. + +**Ausschnitte (`snippets.segments`)**: Ist der Text kürzer als `max_chars`, wird er komplett in Segmente +zerlegt. Sonst: + +1. Um jede Fundstelle wird ein Fenster von `context` Zeichen gelegt. +2. Fensterränder werden an Wortgrenzen verschoben (höchstens 20 Zeichen weit), damit keine Wörter zerschnitten + werden. +3. Überlappende Fenster verschmelzen. +4. Fenster werden aufgenommen, solange das Budget `max_chars` reicht – das **erste Fenster immer**, damit + mindestens ein Treffer sichtbar ist. +5. Lücken und abgeschnittene Enden werden durch `" … "` markiert. + +Ohne Fundstelle im Text (Treffer nur im Titel) wird der Textanfang gezeigt. + +--- + +## 6. Markdown-Zerlegung + +`split_markdown()` arbeitet **zeilenbasiert** mit einem kleinen Scanner, nicht mit einem vollständigen +Markdown-Parser. Der Scanner liefert je Zeile: Index, Inhalt, „in Code-Block?" und ggf. die erkannte +Überschrift (`#` bis `######`). Zeilen innerhalb von Code-Blöcken (` ``` ` oder `~~~`) werden nie als +Überschrift oder Listenpunkt gedeutet. + +| Modus | Regel | +|---|---| +| `file` | eine Datei = ein Block; Titel = erste Überschrift | +| `heading` | neuer Block bei jeder Überschrift; Titel = Überschrift, Anker = `slugify(Überschrift)` | +| `paragraph` | neuer Block nach jeder Leerzeile (außerhalb von Code-Blöcken); Trennlinien werden übersprungen | +| `list` | neuer Block bei jedem Listenpunkt der obersten Ebene; eingerückte Zeilen und Zeilen direkt darunter („lazy continuation") gehören dazu; Text außerhalb von Listen wird zu Absatz-Blöcken; Trennlinien und Überschriften beenden einen Block | + +**Dialekte** (nur Modus `list`): Markdown-Renderer zerlegen Listen unterschiedlich. Damit Blöcke zu den +Elementen im gerenderten HTML passen (wichtig für Sprungmarken), gibt es zwei Regelsätze: + +| | `commonmark` | `python-markdown` | +|---|---|---| +| Listenmarker | `-` `*` `+` `1.` `1)` am Zeilenanfang | dazu bis zu 3 Leerzeichen Einrückung; nummeriert nur `1.` | +| Liste direkt unter einer Absatzzeile | beginnt eine Liste | gehört zum Absatz | + +Jeder Block bekommt `meta["section"]` (letzte Überschrift darüber), `meta["kind"]` und `meta["markdown"]` +(Originaltext), dazu `source`, `line` und `id = ":"`. + +**Markdown → Klartext** (`markdown_to_text`): pro Zeile werden Listen-, Zitat- und Überschriftenmarker entfernt, +Bilder durch ihren Alternativtext, Links durch ihren Linktext ersetzt, Inline-Code, Fett, Kursiv und +Durchgestrichen ausgepackt (Unterstriche nur an Wortgrenzen, damit `snake_case` erhalten bleibt). Zeilen +bleiben erhalten, mehrere Leerzeilen werden zu einer. Durchsucht und angezeigt wird dieser Klartext. + +In den Modi `file` und `heading` wird der Titel vom Textanfang entfernt, damit er im Ergebnis nicht doppelt +erscheint. + +--- + +## 7. Suchmaschine + +### 7.1 Laden + +`SearchEngine(quelle, reload_check=…, reload_interval=…)` nimmt eine Liste von Blöcken oder eine Funktion, die +sie liefert. Beim Laden wird für jeden Block der **Suchtext** `titel + "\n" + text` einmal vorberechnet – +Titel und Text werden gemeinsam durchsucht, aber getrennt angezeigt. + +**Neuladen:** Mit Ladefunktion und `reload_check` (liefert einen beliebigen vergleichbaren Wert, etwa +Dateianzahl und neueste Änderungszeit) prüft die Suchmaschine höchstens alle `reload_interval` Sekunden, ob +sich der Wert geändert hat, und lädt dann neu. Blöcke und Suchtexte werden zusammen als ein Tupel ersetzt – +laufende Suchen sehen immer einen konsistenten Stand. + +### 7.2 Ablauf von `search()` + +```mermaid +flowchart TD + A["Anfrage kompilieren
(oder fertige Query)"] --> B["Frist = jetzt + time_budget"] + B --> C["ggf. neu laden"] + C --> D{"für jeden Block"} + D -->|"Filter / Prädikat passen nicht"| D + D -->|passt| E{"query.matches()"} + E -->|ja| F[in Trefferliste] + E -->|nein| D + E -->|QueryTimeout| G["truncated = True,
Schleife beenden"] + F --> D + D -->|fertig| H["sortieren (sort_key, reverse)"] + G --> H + H --> I["gruppieren (group_by)
Reihenfolge = erster Treffer je Gruppe"] + I --> J["Seite: Gruppen offset … offset+limit"] + J --> K["nur für die Seite:
Spans + Segmente des ersten Blocks je Gruppe"] + K --> L[SearchResult] +``` + +Wichtige Details: + +- **Filter** vergleichen `block.meta[schlüssel]` mit einem Wert oder einer Menge von Werten; leere Werte werden + ignoriert. +- **Gruppierung**: `group_by(block)` liefert einen Schlüssel oder `None` (= eigene Gruppe). Weil vorher sortiert + wird, ist der erste Block einer Gruppe der „beste" (z. B. neueste); die übrigen landen in `also`. +- **Seiten** zählen Gruppen, nicht Blöcke. `next_offset` ist `None`, wenn keine weitere Seite existiert. +- **Teure Arbeit nur für die sichtbare Seite**: Fundstellen und Ausschnitte werden nur für die bis zu + `limit` angezeigten Gruppen berechnet, nicht für alle Treffer. + +--- + +## 8. Web-API und Widget + +### 8.1 Web-API (`web.py`) + +`create_blueprint()` registriert `GET /search` und liefert die Widget-Dateien unter `/mdgrep-static/` aus. +Ablauf einer Anfrage: Parameter lesen und begrenzen (`offset ≥ 0`, `1 ≤ limit ≤ max_limit`, Länge der +Anfrage) → Filter-Parameter übernehmen (leer/`all` = keine Einschränkung, `a,b` = mehrere Werte) → +`engine.search()` → interne Metadaten (`hide_meta`, Standard `markdown`) entfernen → JSON. +`QuerySyntaxError` wird zu HTTP 400 mit `{"error": {code, message, position}}`. + +`create_app()` baut eine eigenständige Flask-App (Unicode im JSON nicht maskiert) und kann zusätzlich ein +Verzeichnis statisch ausliefern; `send_from_directory` verhindert dabei Zugriffe außerhalb dieses Verzeichnisses. + +### 8.2 Widget (`static/mdgrep-widget.js`) + +Ein einzelnes Skript ohne Abhängigkeiten, das `window.MdGrep = {mount, labels, version}` bereitstellt. + +```mermaid +sequenceDiagram + participant U as Nutzer + participant W as Widget + participant A as API + U->>W: tippt „rust -cr" + Note over W: Debounce 300 ms – nichts senden + U->>W: tippt „ypto" + W->>W: URL aktualisieren (?q=…) + W->>A: GET search?q=rust -crypto (AbortController #1) + U->>W: ändert Filter „Sprache" + W->>A: Anfrage #1 abbrechen, GET … &lang=de (#2) + A-->>W: JSON #2 + W->>W: Kästchen per createElement/textContent bauen + W-->>U: Ergebnisse, Statuszeile +``` + +- **Zustand in der URL** (`history.replaceState`): Suchbegriff und Filter stehen in `?q=…&lang=…`; beim Laden + liest das Widget sie wieder ein und sucht sofort. Dadurch sind Suchen teilbar, und die Zurück-Taste aus einem + geöffneten Treffer stellt die Suche wieder her. +- **Veraltete Antworten** werden doppelt abgefangen: `AbortController` bricht die alte Anfrage ab, und eine + Antwort wird nur angezeigt, wenn sie zur zuletzt gestarteten Anfrage gehört. +- **Sicherheit:** Alle Texte aus der Antwort werden mit `textContent` bzw. als Textknoten eingefügt, `` + entsteht per `createElement`. HTML aus Dokumenten wird so nie ausgeführt. +- **Fehler:** HTTP 400 → übersetzte Meldung zum `code` plus Position; 429 → „zu viele Anfragen"; sonst + allgemeine Meldung. +- **Gestaltung:** nur Klassen `mdgrep-*` und CSS-Variablen `--mdgrep-*` mit Standardwerten als Fallback; + `[hidden]` wird mit `!important` erzwungen, damit Seiten-CSS ausgeblendete Elemente nicht wieder einblendet. +- **Ohne Skriptcode:** Elemente mit `data-mdgrep` werden beim Laden automatisch eingerichtet. + +--- + +## 9. Designentscheidungen + +| Entscheidung | Verworfene Alternativen | Grund | +|---|---|---| +| **Kein Index – Volltext-Scan im Speicher** | Whoosh, SQLite FTS, Elasticsearch | Reguläre Ausdrücke lassen sich ohnehin nicht über einen Wortindex beantworten; Whoosh ist seit 2016 ungepflegt; bei einigen MB Text ist der Scan schnell genug (≈ 30–150 ms bei 15 MB) und es gibt nichts zu synchronisieren. | +| **PyPI-`regex` statt `re`** | Standardmodul `re` | Nur `regex` kann eine laufende Suche per `timeout=` abbrechen – Voraussetzung, um Nutzer-Regex auf einem Server zu erlauben. | +| **Zeitlimit nur für Regex-Terme** | Zeitlimit für alle Terme | Der Timeout-Parameter vervierfacht die Kosten pro Aufruf; Wörter und Phrasen können nicht entarten. Zusammen mit den vorberechneten Suchtexten gemessen 4–9× schnellere Suchen (Version 0.1.1). | +| **Logik pro Block** | pro Datei, pro Satz | Datei ist zu grob (`a AND b` trifft fast immer), Satz zu fein; der Split-Modus lässt den Nutzer die passende Einheit wählen. | +| **Titel und Text getrennt** | Titel als erste Textzeile | Sonst erscheint der Titel im Ergebnis doppelt; Programme mit eigenen Daten können einen echten Titel setzen. | +| **Zeilenbasierter Markdown-Zerleger** | vollständiger Markdown-Parser (z. B. markdown-it) | Für Blockgrenzen genügt ein kleiner Scanner, keine zusätzliche Abhängigkeit; Abweichungen einzelner Renderer deckt die `dialect`-Option ab. | +| **`dialect`-Option** | eine feste Regel | Sprungmarken funktionieren nur, wenn Blöcke exakt den HTML-Elementen des Renderers entsprechen; CommonMark und Python-Markdown unterscheiden sich bei Listen messbar (im hnews-Archiv zwei Fehlerbilder in 70 bzw. 197 Dateien). | +| **`relative_to`** | absolute Pfade | Eine Web-API soll keine Serverpfade preisgeben. | +| **Stabile Fehlercodes** | nur Fehlermeldungstext | Oberflächen können Meldungen übersetzen (Widget: de/en), ohne Texte zu parsen. | +| **Widget ohne Framework, nur `textContent`** | React/Vue, `innerHTML` | Läuft in jeder Seite, keine Build-Kette, keine XSS-Lücke durch Dokumentinhalte. | +| **Eigenes Repository, Installation per Git-Tag** | PyPI, Unterordner eines Projekts | Wiederverwendbar in beliebigen Projekten, unabhängig versioniert, ohne Veröffentlichung auf PyPI. | +| **MIT-Lizenz** | GPL | Einfache Wiederverwendung, auch in fremden Projekten. | + +--- + +## 10. Grenzen und Erweiterungsideen + +| Grenze | Auswirkung | Mögliche Erweiterung | +|---|---|---| +| Linearer Scan | Laufzeit wächst mit der Textmenge; bei Hunderten MB zu langsam | Vorfilter mit Trigramm-Index für Wörter/Phrasen | +| Jeder Server-Worker hält alle Blöcke | ca. 90 MB pro Worker bei 15 MB Markdown | gemeinsamer Speicher oder Index auf Platte | +| Keine Wortstämme | „Haus" findet nicht „Häuser" | optionale Stemming-Stufe (z. B. Snowball) | +| Keine Relevanz-Sortierung | Reihenfolge nur nach `sort_key` | Score aus Trefferzahl/Position | +| Doppelte Überschriften | `slugify` hängt kein `-1`, `-2` an (anders als GitHub) | Zähler je Datei wie GitHub | +| Nur ATX-Überschriften (`#`) | Setext-Überschriften (unterstrichen mit `===`/`---`) werden nicht erkannt | Scanner erweitern | +| Operatoren nur in Großbuchstaben | `and` ist ein Suchwort | bewusst so; ggf. Option | +| Titel und Text gemeinsam durchsucht | eine Phrase kann theoretisch über die Grenze Titel/Text hinweg treffen | Grenzzeichen statt Zeilenumbruch |