diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md deleted file mode 100644 index ba01f0d..0000000 --- a/BEDIENUNGSANLEITUNG.md +++ /dev/null @@ -1,851 +0,0 @@ -# mdgrep – Bedienungsanleitung - -**mdgrep** ist eine Volltextsuche für Markdown-Dateien und beliebige andere Texte. Sie versteht -Wortbestandteile, Phrasen, reguläre Ausdrücke und logische Verknüpfungen mit Klammern – und lässt -sich auf vier Arten nutzen: - -| Baustein | Wofür | -|---|---| -| **Kommandozeile** `mdgrep` | schnell im Terminal suchen, wie `grep` – aber blockweise und mit UND/ODER/NICHT | -| **Python-Bibliothek** | Suche in eigene Programme einbauen, auch für Nicht-Markdown-Daten | -| **Web-API** (Flask) | JSON-Suchschnittstelle für Webseiten und andere Dienste | -| **Such-Widget** (JavaScript) | fertige Suchmaske mit Live-Ergebnissen für jede Webseite | - -Version: 0.1.2 · Lizenz: MIT · Repository: https://kitux.de/forgejo/dschlueter/mdgrep - ---- - -## Inhaltsverzeichnis - -1. [Installation](#1-installation) -2. [Suchsyntax](#2-suchsyntax) -3. [Kommandozeile](#3-kommandozeile) -4. [Python-Bibliothek](#4-python-bibliothek) -5. [Web-API](#5-web-api) -6. [Such-Widget](#6-such-widget) -7. [Betrieb auf einem Server](#7-betrieb-auf-einem-server) -8. [Voraussetzungen an die Dokumente](#8-voraussetzungen-an-die-dokumente) -9. [Rezepte](#9-rezepte) -10. [Fehlermeldungen](#10-fehlermeldungen) -11. [Fehlersuche](#11-fehlersuche) -12. [Entwicklung & neue Versionen](#12-entwicklung--neue-versionen) - ---- - -## 1. Installation - -Voraussetzung: Python 3.10 oder neuer, `git` (für die Installation aus dem Repository). - -```bash -# nur Bibliothek + Kommandozeile -pip install "mdgrep @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2" - -# zusätzlich Web-API (installiert Flask) -pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2" -``` - -In einer `requirements.txt`: - -``` -mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2 -``` - -Prüfen: - -```bash -mdgrep --version # mdgrep 0.1.2 -python -c "import mdgrep; print(mdgrep.__version__)" -``` - -> **Hinweis:** mdgrep ist nicht auf PyPI veröffentlicht. Immer mit Git-URL und Versions-Tag -> installieren – ein bloßes `pip install mdgrep` findet das Paket nicht (oder, falls jemand den -> Namen dort später belegt, ein fremdes). - ---- - -## 2. Suchsyntax - -Groß- und Kleinschreibung spielen **nie** eine Rolle (auch nicht bei Umlauten). - -| Eingabe | Bedeutung | Beispiel findet … | -|---|---|---| -| `rust` | Wortbestandteil | „Rust", „Rustacean", „trusted" | -| `"open source"` | genaue Phrase; beliebiger Leerraum (auch Zeilenumbruch) zwischen den Wörtern | „Open Source", „open
source" | -| `/llms?\b/` | regulärer Ausdruck | „LLM", „LLMs" | -| `a b` · `a AND b` · `a UND b` · `a & b` · `a && b` | beide Begriffe | | -| `a OR b` · `a ODER b` · `a \| b` · `a \|\| b` | mindestens einer | | -| `NOT a` · `NICHT a` · `-a` · `!a` | Begriff darf nicht vorkommen | | -| `( … )` | Klammern | | - -**Vorrang:** NICHT bindet stärker als UND, UND stärker als ODER. -`a OR b AND c` bedeutet also `a OR (b AND c)`. Im Zweifel Klammern setzen. - -**Hinweise:** - -- Operatoren werden nur in **Großbuchstaben** erkannt: `and` ist ein normales Suchwort, `AND` der Operator. -- Wortbestandteile treffen auch mitten im Wort: `go` findet „**Go**ddard". Für **ganze Wörter** - einen regulären Ausdruck mit Wortgrenzen verwenden: `/\bgo\b/`. -- Im regulären Ausdruck einen Schrägstrich als `\/` schreiben: `/tcp\/ip/`. -- In Phrasen ein Anführungszeichen als `\"` schreiben. -- Wörter mit Sonderzeichen funktionieren direkt: `AT&T`, `TCP/IP`, `e-mail` - (ein `-` oder `&` *innerhalb* eines Wortes ist kein Operator). - -**Beispiele:** - -```text -(rust ODER go) UND NICHT crypto -"machine learning" -/pytorch|tensorflow/ -llm (datenschutz OR privacy) -/\bgpt-\d+\b/ NICHT "open source" -``` - -**Grenzen (Standard):** höchstens 500 Zeichen und 50 Suchbegriffe pro Anfrage -(die Web-API begrenzt zusätzlich auf 300 Zeichen). - ---- - -## 3. Kommandozeile - -```bash -mdgrep SUCHANFRAGE [PFAD ...] [Optionen] -``` - -Ohne Pfad wird das aktuelle Verzeichnis durchsucht. Verzeichnisse werden rekursiv nach `*.md` -durchsucht. Die Suchanfrage immer in **einfache Anführungszeichen** setzen, damit die Shell -Klammern, `!`, `|` und `&` nicht selbst auswertet. - -### 3.1 Beispiele - -```bash -mdgrep '(rust OR go) AND NOT crypto' notizen/ # alle *.md unter notizen/ -mdgrep '/\bllms?\b/' docs/*.md --split list # ein Treffer pro Listenpunkt -mdgrep 'todo' . -l # nur Dateinamen -mdgrep 'release' . -c # nur Anzahl der Treffer -mdgrep '"open source"' . --json > treffer.json # JSON für andere Programme -mdgrep 'fehler' logs/ -g '*.txt' # andere Dateien als Markdown -mdgrep 'rust' . -m 5 # nach 5 Treffern aufhören -``` - -Ausgabe (Fundstellen farbig, wenn die Ausgabe ein Terminal ist): - -```text -notizen/sprachen.md:5 - Rust guarantees memory safety without a garbage collector. Its borrow checker - rejects data races at compile time. -``` - -### 3.2 Optionen - -| Option | Bedeutung | Standard | -|---|---|---| -| `-s`, `--split MODUS` | Einheit, die durchsucht und angezeigt wird (siehe 3.3) | `paragraph` | -| `-g`, `--glob MUSTER` | Dateimuster in Verzeichnissen | `*.md` | -| `-c`, `--count` | nur Anzahl der Treffer-Blöcke ausgeben | | -| `-l`, `--files-with-matches` | nur Dateinamen ausgeben | | -| `-j`, `--json` | JSON-Ausgabe (gleiches Format wie die Web-API) | | -| `-C`, `--context N` | Zeichen Kontext um Treffer bei langen Blöcken | `80` | -| `--max-chars N` | Blöcke über N Zeichen kürzen (`0` = nie) | `400` | -| `-m`, `--max-count N` | nach N Treffern aufhören (`0` = alle) | `0` | -| `--timeout SEK` | Zeitlimit für die ganze Suche | `30` | -| `--color auto\|always\|never` | Farbe (`NO_COLOR` wird beachtet) | `auto` | -| `-V`, `--version` | Version anzeigen | | - -### 3.3 Split-Modi – was ist ein Treffer? - -| Modus | Ein Block ist … | Geeignet für | -|---|---|---| -| `paragraph` | ein durch Leerzeilen getrennter Absatz | Fließtext, Notizen | -| `list` | ein Listenpunkt der obersten Ebene (mit eingerückten Unterpunkten); Text außerhalb von Listen wird zu Absätzen | Aufzählungen, Berichte, Changelogs | -| `heading` | ein Abschnitt von Überschrift zu Überschrift | Dokumentation, Wikis | -| `file` | eine ganze Datei | „In welchen Dateien kommt X und Y vor?" | - -Die logische Verknüpfung gilt **pro Block**: `rust AND go` findet nur Blöcke, in denen beide -Wörter vorkommen. Mit `--split file` gilt sie für die ganze Datei. - -### 3.4 Rückgabewerte (für Skripte) - -| Code | Bedeutung | -|---|---| -| `0` | Treffer gefunden | -| `1` | kein Treffer | -| `2` | Fehler: Syntaxfehler (mit Markierung der Position), Datei nicht lesbar oder Zeitlimit erreicht | - -```bash -if mdgrep 'TODO OR FIXME' src/ -c > /dev/null; then echo "Es gibt noch offene Punkte"; fi -``` - ---- - -## 4. Python-Bibliothek - -### 4.1 Nur die Suchsprache - -```python -from mdgrep import compile_query, QuerySyntaxError - -q = compile_query('(rust OR go) AND NOT /crypto(currency)?/') -q.matches("Rust 2.0 is out") # True -q.spans("Rust and Go") # [(0, 4), (9, 11)] → Positionen zum Hervorheben - -try: - compile_query("(rust OR go") -except QuerySyntaxError as e: - print(e.code, e.position, e.message) # unclosed_paren 0 Missing closing parenthesis -``` - -- `compile_query(text, max_length=500, max_terms=50)` → `Query` -- `Query.matches(text, timeout=None)` → `bool` -- `Query.spans(text, timeout=None)` → Liste von `(start, ende)`, sortiert und zusammengefasst; - Begriffe unter NICHT werden nicht markiert -- Bei Überschreitung von `timeout` (Sekunden) wird `QueryTimeout` ausgelöst - -### 4.2 Markdown-Dateien durchsuchen - -```python -from mdgrep import load_markdown, SearchEngine - -blocks = load_markdown("docs/", split="list", relative_to="docs/") -engine = SearchEngine(blocks) - -result = engine.search("rust -crypto", limit=20) -print(result.total_blocks, "Treffer") -for group in result.groups: - b = group.block - print(f"{b.source}:{b.line}", "".join(s["t"] for s in group.segments)) -``` - -`load_markdown(pfade, ...)`: - -| Parameter | Bedeutung | Standard | -|---|---|---| -| `paths` | Datei, Verzeichnis oder Liste davon | | -| `split` | `file` · `heading` · `list` · `paragraph` | `paragraph` | -| `meta` | Funktion `pfad → dict`; wird in die Metadaten jedes Blocks der Datei übernommen | | -| `relative_to` | Verzeichnis, relativ zu dem `source`/`id` angegeben werden (wichtig für Web: keine Serverpfade preisgeben) | absolute/angegebene Pfade | -| `pattern` | Dateimuster in Verzeichnissen | `*.md` | -| `dialect` | `commonmark` oder `python-markdown` (siehe 4.6) | `commonmark` | -| `encoding` | Zeichenkodierung | `utf-8` | - -Jeder Block bekommt in `meta` automatisch: `section` (nächste Überschrift darüber), -`kind` (`file`/`section`/`item`/`paragraph`) und `markdown` (Original-Quelltext). -Durchsucht wird der Text **ohne** Markdown-Zeichen (Links, Fett, Code …). - -### 4.3 Eigene Daten durchsuchen - -mdgrep ist nicht auf Markdown beschränkt. Jede Datenquelle lässt sich als Liste von `Block`-Objekten -übergeben: - -```python -from mdgrep import Block, SearchEngine - -tickets = [ - Block(id="42", title="Ticket 42", text="Login schlägt mit SSO fehl", meta={"status": "offen"}), - Block(id="43", title="Ticket 43", text="Dunkelmodus-Schalter defekt", meta={"status": "erledigt"}), -] -engine = SearchEngine(tickets) -engine.search("login ODER sso", filters={"status": "offen"}) -``` - -| Feld | Bedeutung | -|---|---| -| `id` | eindeutige Kennung | -| `title` | wird durchsucht und als Überschrift angezeigt (nicht im `text` wiederholen) | -| `text` | wird durchsucht und als Ausschnitt angezeigt | -| `anchor` | optionale Sprungmarke (`#anchor`), Linkziel im Widget, wenn kein `href` gesetzt ist | -| `source`, `line` | Herkunft (Datei, Zeile ab 1) | -| `meta` | beliebiges Wörterbuch; wird unverändert durchgereicht, Filter arbeiten darauf | - -### 4.4 Suchoptionen - -```python -result = engine.search( - "rust", - filters={"lang": ["de", "en"]}, # alle Schlüssel müssen passen; leere Werte = egal - predicate=lambda b: b.line > 1, # zusätzlicher eigener Filter - group_by=lambda b: b.meta.get("url"), # gleiche Schlüssel → ein Ergebnis, Rest in group.also - sort_key=lambda b: b.meta["date"], reverse=True, # neueste zuerst - offset=0, limit=30, # Seiten (zählt Gruppen) - time_budget=1.5, # Sekunden; danach Teilergebnis mit truncated=True - snippet_chars=700, context=120, # lange Texte auf Fenster um die Treffer kürzen -) -``` - -Das Ergebnis (`SearchResult`): - -| Attribut | Bedeutung | -|---|---| -| `groups` | Treffer der aktuellen Seite; je Gruppe `block`, `segments`, `title_segments`, `hits`, `also` | -| `total_groups`, `total_blocks` | Anzahl Ergebnisse bzw. Fundstellen insgesamt | -| `next_offset` | `offset` der nächsten Seite oder `None` | -| `truncated` | `True`, wenn das Zeitbudget aufgebraucht wurde | -| `elapsed_ms` | Suchdauer | -| `to_dict()` | JSON-fähiges Wörterbuch (Format siehe 5.3) | - -`segments` ist eine Liste `[{"t": "Text", "hit": False}, {"t": "Treffer", "hit": True}, …]` – -fertig zum Anzeigen mit Hervorhebung. - -### 4.5 Automatisch neu laden - -Ändern sich die Dateien, lädt die Suchmaschine sie selbst neu – ohne Neustart: - -```python -from pathlib import Path - -def stand(verzeichnis=Path("docs")): - dateien = [p.stat() for p in verzeichnis.rglob("*.md")] - return len(dateien), max((s.st_mtime for s in dateien), default=0) - -engine = SearchEngine(lambda: load_markdown("docs", relative_to="docs"), - reload_check=stand, reload_interval=60) -``` - -`reload_check` liefert einen beliebigen vergleichbaren Wert; ändert er sich, wird neu geladen. -Geprüft wird höchstens alle `reload_interval` Sekunden. `engine.reload()` lädt sofort neu. - -### 4.6 Markdown-Dialekt – wenn Treffer auf HTML-Elemente verlinken - -Wer Treffer mit Sprungmarken in gerenderten HTML-Seiten verknüpft, braucht dieselbe Aufteilung wie -der Markdown-Renderer. Die Renderer unterscheiden sich bei Listen: - -| | `commonmark` (Standard) | `python-markdown` | -|---|---|---| -| Liste direkt unter einer Absatzzeile (ohne Leerzeile) | beginnt eine Liste | gehört zum Absatz | -| Unterpunkt mit 1–3 Leerzeichen Einrückung | verschachtelt | eigener Punkt auf oberster Ebene | - -```python -split_markdown(text, split="list", dialect="python-markdown") -``` - -### 4.7 Weitere Funktionen - -| Funktion | Zweck | -|---|---| -| `split_markdown(text, source=…, split=…, meta=…, dialect=…)` | einen Markdown-Text (statt Dateien) zerlegen | -| `markdown_to_text(md)` | Markdown-Zeichen entfernen, Zeilen erhalten | -| `segments(text, spans, max_chars=…, context=…)` | Text + Fundstellen → Anzeige-Segmente | -| `export_static(zielverzeichnis)` | Widget-Dateien (JS + CSS) kopieren, siehe 6.4 | - ---- - -## 5. Web-API - -Benötigt `mdgrep[web]`. - -### 5.1 Eigenständige App - -```python -# app.py -from mdgrep import SearchEngine, load_markdown -from mdgrep.web import create_app - -engine = SearchEngine(lambda: load_markdown("docs", split="list", relative_to="docs")) -app = create_app(engine, filters=["kind"]) # GET /search?q=…&kind=… -``` - -### 5.2 In eine bestehende Flask-App einhängen - -```python -from mdgrep.web import create_blueprint - -app.register_blueprint(create_blueprint(engine, filters=["lang"]), url_prefix="/api") -# → GET /api/search?q=… und Widget-Dateien unter /api/mdgrep-static/ -``` - -Optionen von `create_blueprint()` (zusätzlich zu `group_by`, `sort_key`, `reverse`, -`time_budget`, `snippet_chars`, `context` aus 4.4): - -| Option | Bedeutung | Standard | -|---|---|---| -| `filters` | Metadaten-Schlüssel, die als URL-Parameter filterbar sind | keine | -| `default_limit` / `max_limit` | Ergebnisse pro Seite / Obergrenze | `30` / `100` | -| `max_query_length` | maximale Länge der Suchanfrage | `300` | -| `hide_meta` | Metadaten-Schlüssel, die nicht ausgeliefert werden | `("markdown",)` | -| `name` | Blueprint-Name (bei mehreren Suchen in einer App) | `mdgrep` | - -`create_app(engine, url_prefix="", static_dir=None, **optionen)` – mit `static_dir` liefert die App -zusätzlich die Dateien dieses Verzeichnisses unter `/` aus (praktisch zum Testen einer statischen Seite). - -### 5.3 Anfragen und Antworten - -```text -GET /search?q=rust%20-crypto&lang=de&offset=0&limit=30 -``` - -- `q` – Suchanfrage (leer → leeres Ergebnis) -- je Name in `filters` ein Parameter: leer oder `all` = keine Einschränkung, `de,en` = mehrere Werte -- `offset`, `limit` – Seiten - -Antwort (HTTP 200): - -```json -{ - "query": "rust -crypto", "total_groups": 12, "total_blocks": 15, - "offset": 0, "limit": 30, "next_offset": null, "truncated": false, "elapsed_ms": 3.2, - "results": [{ - "block": { - "id": "a.md:4", "title": "…", "anchor": "", "source": "a.md", "line": 4, "meta": {}, - "title_segments": [{"t": "…", "hit": false}], - "segments": [{"t": "The ", "hit": false}, {"t": "Rust", "hit": true}, {"t": " team …", "hit": false}], - "hits": 1 - }, - "also": [{"id": "b.md:9", "title": "…", "anchor": "", "source": "b.md", "line": 9, "meta": {}}] - }] -} -``` - -Fehler (HTTP 400): - -```json -{"error": {"code": "unclosed_paren", "message": "Missing closing parenthesis", "position": 0}} -``` - -### 5.4 Entwicklungsserver - -```bash -python -m mdgrep.web --dir docs --split list --static site --port 8000 -# → http://127.0.0.1:8000/ (Dateien aus site/) -# → http://127.0.0.1:8000/api/search (API) -``` - -| Option | Bedeutung | Standard | -|---|---|---| -| `--dir` | Verzeichnis mit Markdown-Dateien (Pflicht) | | -| `--split` | Split-Modus | `paragraph` | -| `--static` | dieses Verzeichnis unter `/` ausliefern; API dann unter `--api-prefix` | | -| `--api-prefix` | Pfad der API, wenn `--static` gesetzt ist | `/api` | -| `--filters` | kommagetrennte Metadaten-Schlüssel als Filter, z. B. `kind,section` | | -| `--host`, `--port` | Adresse | `127.0.0.1`, `8000` | - -Neue oder geänderte Dateien werden im Entwicklungsserver nach spätestens 5 Sekunden übernommen. -Ein vollständiges Beispiel liegt in `examples/`: - -```bash -python -m mdgrep.web --dir examples/docs --split list --static examples/site -``` - ---- - -## 6. Such-Widget - -Eine fertige Suchmaske: Ergebnisse erscheinen beim Tippen, veraltete Anfragen werden abgebrochen, -die Suche steht in der URL (teilbar, Zurück-Taste funktioniert), Filter, „Mehr laden", -aufklappbare Syntaxhilfe, Fehlermeldungen mit Position, Beschriftung Deutsch oder Englisch. -Alle Texte werden sicher als Text eingefügt (kein HTML vom Server → kein Cross-Site-Scripting). -Keine Abhängigkeiten, kein Framework. - -### 6.1 Einbinden - -```html - -
- - -``` - -Ganz ohne eigenes JavaScript: - -```html -
-``` - -### 6.2 Optionen - -| Option | Bedeutung | Standard | -|---|---|---| -| `endpoint` | URL der Such-API (relativ oder absolut) | `search` | -| `labels` | `'de'`, `'en'` oder Objekt mit einzelnen Texten (siehe 6.5) | `'en'` | -| `filters` | Auswahlfelder `[{name, label, options:[{value,label}], value}]`; `name` = URL-Parameter der API | `[]` | -| `live` | beim Tippen suchen | `true` | -| `debounce` | Wartezeit nach dem letzten Tastendruck (ms) | `300` | -| `minChars` | Mindestlänge der Eingabe | `2` | -| `limit` | Ergebnisse pro Seite | `30` | -| `syncUrl` | Suche und Filter in der Seiten-URL halten | `true` | -| `autofocus` | Eingabefeld beim Laden fokussieren | `true` | -| `help` | Syntaxhilfe anzeigen | `true` | -| `hrefField` | Metadaten-Feld mit dem Linkziel | `href` | -| `href(block)` | eigene Funktion für das Linkziel (hat Vorrang) | | -| `dateField` | Metadaten-Feld, das als Datum angezeigt wird | | -| `badgeFields` | Metadaten-Felder, die als Plaketten angezeigt werden | `[]` | -| `formatBadge(feld, wert, block)` | Text einer Plakette anpassen | | -| `alsoText(block)` | Linktext in der Liste „auch in …" | Datum bzw. Titel | - -Ohne `href` und ohne `hrefField`-Wert verlinkt ein Treffer auf `#anchor` des Blocks. - -### 6.3 Steuern per JavaScript - -```javascript -var suche = MdGrep.mount(element, optionen); -suche.setQuery('rust -crypto'); // Suchbegriff setzen und suchen -suche.search(); // erneut suchen -suche.input; // das Eingabefeld - -element.addEventListener('mdgrep:results', function (e) { - console.log(e.detail.total_groups); // vollständige API-Antwort -}); -``` - -Ist die Seite mit `?q=…` aufgerufen worden, sucht das Widget sofort. So kann ein einfaches -Formular auf einer anderen Seite zur Suchseite weiterleiten: - -```html -
-``` - -### 6.4 Statische Webseiten - -Liegt die Seite nicht in Flask, die Widget-Dateien einmalig (oder bei jedem Build) kopieren: - -```python -import mdgrep -mdgrep.export_static("site/static") # schreibt mdgrep-widget.js und mdgrep-widget.css -``` - -Die Dateien werden nur neu geschrieben, wenn sich ihr Inhalt geändert hat. - -### 6.5 Aussehen anpassen - -Farben und Rundungen über CSS-Variablen (an `:root` oder einem umgebenden Element): - -```css -:root { - --mdgrep-text: #1a1d2e; /* Text */ - --mdgrep-muted: #6b7394; /* Nebentexte */ - --mdgrep-surface: #ffffff; /* Kästchen, Eingabefeld */ - --mdgrep-border: #d5d8e3; /* Rahmen */ - --mdgrep-accent: #4a7cff; /* Fokus, Hover */ - --mdgrep-mark-bg: #ffe58a; /* Hervorhebung */ - --mdgrep-mark-text: inherit; - --mdgrep-error: #c0392b; /* Fehlermeldungen */ - --mdgrep-radius: 0.5rem; -} -``` - -Für einen Dunkelmodus die Variablen auf die eigenen Farbvariablen abbilden, z. B. -`--mdgrep-surface: var(--meine-flaeche);` – dann folgt das Widget dem Seitenthema automatisch. - -Einzelne Texte überschreiben: - -```javascript -labels: { lang: 'de', none: 'Leider nichts gefunden.', more: 'Weitere Treffer' } -``` - ---- - -## 7. Betrieb auf einem Server - -Der Flask-Entwicklungsserver ist nur zum Testen gedacht. Für den Dauerbetrieb: - -### 7.1 Mit gunicorn - -```bash -pip install gunicorn -gunicorn --workers 2 --bind 127.0.0.1:8790 app:app -``` - -Jeder Worker hält alle Blöcke im Speicher (Richtwert: ca. 90 MB für 15 MB Markdown / 26 000 Blöcke). - -### 7.2 Als systemd-Dienst - -```ini -# /etc/systemd/system/meine-suche.service -[Unit] -Description=Volltextsuche (mdgrep) -After=network.target - -[Service] -User=meinuser -WorkingDirectory=/home/meinuser/projekt -ExecStart=/home/meinuser/projekt/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8790 app:app -Restart=on-failure -NoNewPrivileges=true -PrivateTmp=true - -[Install] -WantedBy=multi-user.target -``` - -```bash -sudo systemctl daemon-reload && sudo systemctl enable --now meine-suche -systemctl status meine-suche -journalctl -u meine-suche -n 50 -``` - -### 7.3 Hinter nginx mit Ratenbegrenzung - -```nginx -# http-Kontext, z. B. /etc/nginx/conf.d/suche.conf -limit_req_zone $binary_remote_addr zone=suche:10m rate=5r/s; - -# im server-Block -location = /api/search { - limit_req zone=suche burst=20 nodelay; - limit_req_status 429; - proxy_pass http://127.0.0.1:8790/search$is_args$args; - proxy_read_timeout 10s; -} -``` - -Das Widget zeigt bei HTTP 429 „Zu viele Anfragen – bitte einen Moment warten." - -### 7.4 Sicherheit - -- **Bösartige reguläre Ausdrücke** (z. B. `/(a|aa)+$/`) können exponentiell lange rechnen. mdgrep - begrenzt jede Suche auf `time_budget` (Web-API: 1,5 s) und liefert danach ein Teilergebnis - mit `truncated: true`. -- **Ratenbegrenzung** im Reverse-Proxy (7.3) verhindert, dass viele Anfragen den Server auslasten. -- **Keine Serverpfade** preisgeben: `load_markdown(..., relative_to=…)` verwenden. -- **Interne Metadaten** mit `hide_meta` aus Antworten entfernen. -- Das Widget fügt Ergebnisse nur als Text ein – auch Markdown mit eingebettetem HTML ist sicher. - -### 7.5 Leistung - -Ohne Index werden alle Blöcke bei jeder Suche durchlaufen. Richtwerte für 26 000 Blöcke (15 MB): -einfache Begriffe und Phrasen 30–70 ms, reguläre Ausdrücke und Verknüpfungen 100–150 ms. -Für deutlich größere Datenmengen (Hunderte MB) ist ein Index-basiertes Werkzeug besser geeignet. - ---- - -## 8. Voraussetzungen an die Dokumente - -Welche Bedingungen eine Sammlung (z. B. Markdown-Dateien) erfüllen sollte – getrennt nach -„sinnvoll durchsuchen" und „Treffer springen direkt an die Fundstelle". - -### 8.1 Für die Suche - -1. **Textdateien mit bekannter Kodierung** – Standard UTF-8; sonst `encoding=`. Andere Endungen - als `.md` über `-g '*.txt'` bzw. `pattern=`. - -2. **Größe passend zum Ansatz** – mdgrep arbeitet ohne Index: alle Blöcke liegen im Speicher und - werden bei jeder Suche durchlaufen. Bis etwa 50 MB Text unproblematisch (Richtwert: 15 MB → - 30–150 ms pro Suche); bei Hunderten MB ist ein Index-basiertes Werkzeug besser. - -3. **Gliederung, die zu einem Split-Modus passt** – die UND/ODER/NICHT-Logik gilt **pro Block**, - die Blockgröße bestimmt also die Trefferqualität: - - | Aufbau der Dokumente | Split-Modus | - |---|---| - | Absätze, durch Leerzeilen getrennt | `paragraph` | - | Einträge als Listenpunkte (Berichte, Changelogs, Protokolle) | `list` | - | Abschnitte mit Überschriften (Dokumentation, Wiki) | `heading` | - | kurze Einzeldokumente | `file` | - - Zu große Blöcke machen `a AND b` beliebig (in einer langen Datei steht fast alles), zu kleine - verfehlen Begriffe, die in benachbarten Sätzen stehen. - -4. **Ableitbare Metadaten** – für Filter (Sprache, Kategorie), Sortierung (Datum) und Gruppierung - (gleicher Eintrag in mehreren Dateien) braucht jeder Block Werte in `meta`: - - am einfachsten über ein **einheitliches Datei- oder Pfad-Schema** - (z. B. `2026_10_12_bericht_de.md`) und `meta=lambda pfad: {…}`; - - YAML-Frontmatter wird **nicht** ausgewertet, sondern als Text durchsucht – dafür einen kleinen - Adapter schreiben, der Frontmatter liest und `Block`-Objekte erzeugt (siehe 4.3); - - zum Zusammenfassen von Duplikaten (`group_by`) einen **stabilen Schlüssel** pro Eintrag - (URL, ID). - -5. **Grenzen einplanen** - - **Keine Wortstammsuche:** „Haus" findet nicht „Häuser" → `/h(au|äu)s/` oder `haus ODER häuser`. - - **Keine Relevanz-Sortierung:** die Reihenfolge bestimmt `sort_key` (z. B. Datum). - - Markdown-Zeichen (Links, Fett, Code) werden vor der Suche entfernt; **direkt eingebettetes - HTML** (`
`) und Tabellen-Striche bleiben als Text stehen. - -### 8.2 Für Sprungmarken - -Damit ein Treffer direkt an die Fundstelle einer gerenderten Seite springt, müssen **zwei Seiten -exakt zusammenpassen**: die Blöcke von mdgrep und die IDs im HTML. - -1. **Gerenderte Seiten mit vorhersagbarer URL** – aus jeder Quelldatei muss sich ihr HTML-Pfad - berechnen lassen (`docs/a/b.md` → `site/a/b.html`). Diesen Pfad mit Sprungmarke in - `meta["href"]` schreiben (das Widget verlinkt darauf, siehe 6.2). - -2. **Jeder Block hat eine ID, die im HTML existiert** – drei Wege: - - | Weg | Voraussetzung | Bewertung | - |---|---|---| - | **Überschriften** (`split="heading"`) | Der Renderer erzeugt Überschriften-IDs mit **demselben** Verfahren wie mdgrep (`slugify`, GitHub-Stil) | wenig Aufwand, aber prüfen: Python-Markdown (`toc`), MkDocs, Hugo und GitHub behandeln Umlaute, Sonderzeichen und doppelte Überschriften unterschiedlich | - | **Listenpunkte/Absätze** (`split="list"` / `paragraph`) | Eigenes Nachbearbeitungs-Skript nummeriert die HTML-Elemente nach **derselben Reihenfolge-Regel** wie die Blöcke und setzt `block.anchor` entsprechend | mittlerer Aufwand, robust | - | **Text Fragments** (`seite.html#:~:text=…`) | Aktueller Browser; keine Änderung am HTML. Im Widget über die Option `href(block)` bauen | kein Aufwand, aber unzuverlässig bei mehrdeutigem oder umformatiertem Text | - - > **Bekannte Einschränkung:** `slugify` hängt bei **doppelten** Überschriften kein `-1`, `-2` an - > (anders als GitHub). Kommen Überschriften mehrfach vor, springen Treffer zur ersten. - -3. **Gleicher Markdown-Dialekt wie der Renderer** – Renderer zerlegen Listen unterschiedlich - (siehe 4.6). Passt `dialect` nicht, verschiebt sich die Nummerierung und Links zeigen auf falsche - Absätze. - -4. **Quelle und HTML auf demselben Stand** – nach jeder Änderung einer Quelldatei deren Seite neu - erzeugen; ändert sich die Zählregel, **alle** Seiten. - -5. **Einmal vollständig prüfen** – für jeden Block muss die Sprungmarke im HTML existieren **und** - auf ein Element zeigen, das seinen Text enthält. Skizze: - - ```python - import re - from bs4 import BeautifulSoup - - def pruefe(blocks, html_von): # html_von(block) → gerendertes HTML der Seite - fehler = [] - for b in blocks: - el = BeautifulSoup(html_von(b), "html.parser").find(id=b.anchor) - probe = re.sub(r"\s+", " ", (b.title or b.text))[:40].lower() - if el is None or probe not in re.sub(r"\s+", " ", el.get_text()).lower(): - fehler.append((b.source, b.anchor)) - return fehler - ``` - - Das deckt Formatvarianten älterer Dokumente auf, die man sonst übersieht. - -### 8.3 Checkliste - -| | Bedingung | -|---|---| -| ☐ | Textdateien, bekannte Kodierung, insgesamt höchstens im zweistelligen MB-Bereich | -| ☐ | Gliederung passt zu einem Split-Modus; Blöcke sind sinnvolle Einheiten | -| ☐ | Metadaten über einheitliches Datei-Namensschema (oder Adapter) | -| ☐ | stabiler Schlüssel pro Eintrag, falls Duplikate zusammengefasst werden | -| ☐ | *Sprungmarken:* HTML-Pfad aus Quellpfad berechenbar | -| ☐ | *Sprungmarken:* IDs im HTML nach derselben Regel wie die Blöcke, gleicher Dialekt | -| ☐ | *Sprungmarken:* HTML nach jeder Änderung neu erzeugt; Konsistenz einmal geprüft | - ---- - -## 9. Rezepte - -**Notizen nach zwei Begriffen im selben Absatz durchsuchen** - -```bash -mdgrep 'kubernetes "persistent volume"' ~/notizen -``` - -**Dateien finden, die A enthalten, aber nicht B** - -```bash -mdgrep 'docker -podman' ~/wiki --split file -l -``` - -**Changelog: alle Einträge zu Sicherheitslücken** - -```bash -mdgrep '/\bCVE-\d{4}-\d+\b/ OR security' CHANGELOG.md --split list -``` - -**Treffer in einem Skript weiterverarbeiten** - -```bash -mdgrep 'rust' docs --json | python -c "import json,sys; print([r['block']['source'] for r in json.load(sys.stdin)['results']])" -``` - -**Suche mit Sprachfilter aus dem Dateinamen** (`bericht_de.md`, `bericht_en.md`) - -```python -engine = SearchEngine(load_markdown("berichte", split="list", relative_to="berichte", - meta=lambda p: {"lang": p.stem.rsplit("_", 1)[-1]})) -engine.search("datenschutz", filters={"lang": "de"}) -``` - -**Gleiche Einträge aus mehreren Dateien zusammenfassen, neueste zuerst** - -```python -engine.search("rust", group_by=lambda b: b.meta.get("url"), - sort_key=lambda b: b.meta["date"], reverse=True) -``` - -**Praxisbeispiel:** Die Hacker-News-Berichte auf https://jamulix.de/HackerNews/search.html nutzen -mdgrep mit einem kleinen Adapter (`hnews_search.py` im Projekt *hackernews*): Stories und Insights -als Blöcke, Sprache/Datum/Linkziel als Metadaten, Gruppierung gleicher Stories, Sprungmarken in den -HTML-Seiten (`dialect="python-markdown"`). - ---- - -## 10. Fehlermeldungen - -Ungültige Anfragen liefern einen festen Code (für eigene Übersetzungen), eine Meldung und die -Position (0-basiert; das Widget zeigt sie 1-basiert an). - -| Code | Bedeutung | Beispiel | -|---|---|---| -| `empty_query` | leere Anfrage | ` ` | -| `empty_phrase` | leere Phrase | `""` | -| `unterminated_phrase` | schließendes Anführungszeichen fehlt | `"open source` | -| `unterminated_regex` | schließender Schrägstrich fehlt | `/abc` | -| `invalid_regex` | regulärer Ausdruck ungültig | `/a(b/` | -| `missing_operand` | Suchbegriff erwartet | `rust OR` · `AND go` | -| `unexpected_paren` | schließende Klammer ohne öffnende | `rust)` | -| `unexpected_token` | Operator an unerwarteter Stelle | | -| `unclosed_paren` | schließende Klammer fehlt | `(rust OR go` | -| `empty_group` | leere Klammern | `()` | -| `too_long` | Anfrage zu lang | | -| `too_many_terms` | zu viele Suchbegriffe | | - -Auf der Kommandozeile wird die Stelle markiert: - -```text -mdgrep: Missing closing parenthesis - (rust OR go - ^ -``` - ---- - -## 11. Fehlersuche - -| Problem | Ursache / Lösung | -|---|---| -| `bash: !…: event not found` | Suchanfrage in **einfache** Anführungszeichen setzen: `mdgrep 'rust !crypto'` | -| `go` findet zu viel | Wortbestandteile treffen auch in „Goddard" – ganze Wörter mit `/\bgo\b/` | -| `and`/`or` wirken nicht als Operator | Operatoren nur in Großbuchstaben: `AND`, `OR`, `NOT` (oder `UND`, `ODER`, `NICHT`) | -| Keine Treffer in `.txt`-Dateien | Standardmuster ist `*.md` → `-g '*.txt'` bzw. `pattern="*.txt"` | -| `ModuleNotFoundError: flask` | Web-Teil braucht `pip install "mdgrep[web] @ git+…"` | -| Widget zeigt „Die Suche ist fehlgeschlagen" | API nicht erreichbar: `endpoint` prüfen, Browser-Konsole/Netzwerk ansehen | -| Widget zeigt „Zu viele Anfragen" | Ratenbegrenzung des Proxys greift (HTTP 429) – kurz warten oder `burst` erhöhen | -| „Zeitlimit erreicht" | sehr aufwendiger regulärer Ausdruck; vereinfachen oder `time_budget` erhöhen | -| Neue Dateien werden nicht gefunden | `reload_check` gesetzt? Geprüft wird nur alle `reload_interval` Sekunden | -| Sprungmarken zeigen auf falsche Stellen | gleichen Markdown-`dialect` wie der HTML-Renderer verwenden (4.6) | -| Widget-Darstellung „verbogen" | Seiten-CSS überschreibt Elemente (z. B. `details`, `ol`, `button`); gezielt unter `.mdgrep …` zurücksetzen | - ---- - -## 12. Entwicklung & neue Versionen - -```bash -git clone https://kitux.de/forgejo/dschlueter/mdgrep.git && cd mdgrep -python -m venv .venv && .venv/bin/pip install -e ".[dev]" -.venv/bin/pytest # alle Tests -``` - -Aufbau: - -``` -src/mdgrep/ -├── query.py # Suchsprache: Tokenizer, Parser, Auswertung, Fundstellen -├── engine.py # Block, SearchEngine, SearchResult -├── snippets.py # Anzeige-Segmente, Kürzen um Treffer -├── markdown.py # Markdown → Blöcke, Markdown → Text -├── cli.py # Kommandozeile -├── web.py # Flask-Blueprint/-App, Entwicklungsserver -└── static/ # mdgrep-widget.js, mdgrep-widget.css -``` - -Neue Version veröffentlichen: - -1. Version in `pyproject.toml` **und** `src/mdgrep/__init__.py` erhöhen (bei Widget-Änderungen auch im Kopf von `mdgrep-widget.js`) -2. `CHANGELOG.md`: Abschnitt `[Unreleased]` in die neue Version umbenennen (mit Datum), „Beim Update zu tun" - ergänzen; bei Änderungen am inneren Aufbau auch `FUNKTIONSWEISE.md` aktualisieren -3. Tests laufen lassen: `.venv/bin/pytest` -4. Commit, Tag und Push: `git tag -a v0.1.2 -m "mdgrep 0.1.2" && git push origin main --tags` -5. In den nutzenden Projekten den Tag in `requirements.txt` anpassen und `pip install -r requirements.txt` - ausführen; laufende Dienste neu starten. - -Versionsregel: Änderungen an öffentlichen Funktionen, am JSON-Format oder an Widget-Optionen, die -bestehende Nutzer brechen, nur mit erhöhter Nebenversion (0.**2**.0) und Hinweis im Commit. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index d929804..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,58 +0,0 @@ -# Änderungsprotokoll - -Alle nennenswerten Änderungen an mdgrep. Format nach [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), -Versionen nach [Semantic Versioning](https://semver.org/lang/de/) (vor 1.0: Nebenversion = möglicher Bruch). - -Jede Version ist als Git-Tag verfügbar: -`pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@vX.Y.Z"` - -## [Unreleased] - -## [0.1.2] – 2026-10-15 - -### Dokumentation -- `BEDIENUNGSANLEITUNG.md` (deutsch): Installation, Suchsyntax, Kommandozeile, Python-API, Web-API, Widget, - Serverbetrieb, Voraussetzungen an die Dokumente, Rezepte, Fehlercodes, Fehlersuche, Release-Ablauf. -- `FUNKTIONSWEISE.md`: interner Aufbau, Datenfluss und Designentscheidungen. -- `CHANGELOG.md` (diese Datei). - -### Beim Update zu tun -- Nichts; nur Dokumentation, Code unverändert und voll kompatibel zu 0.1.1. Widget-Dateien unverändert - (Kopfzeile meldet weiterhin v0.1.0). - -## [0.1.1] – 2026-10-12 - -### Neu -- `split_markdown()` / `load_markdown()`: Parameter `dialect="commonmark" | "python-markdown"`, damit Listen-Blöcke - zur HTML-Struktur des verwendeten Renderers passen (Python-Markdown: Liste braucht Leerzeile davor, - Verschachtelung erst ab 4 Leerzeichen). - -### Geändert -- Deutlich schneller: der durchsuchte Text (Titel + Text) wird beim Laden einmal vorberechnet; das - Regex-Zeitlimit gilt nur noch für `/regex/`-Terme (Wörter und Phrasen laufen linear). Typische Suchen über - 26 000 Blöcke: ca. 30–140 ms statt 250–700 ms. - -### Beim Update zu tun -- Nichts; voll kompatibel zu 0.1.0. Widget-Dateien unverändert (Kopfzeile meldet weiterhin v0.1.0). - -## [0.1.0] – 2026-10-12 - -Erstveröffentlichung. - -### Neu -- Suchsprache: Wortbestandteile, `"Phrasen"`, `/reguläre Ausdrücke/`, `AND`/`OR`/`NOT` (auch `UND`/`ODER`/ - `NICHT`, `&`, `|`, `-`, `!`) mit Klammern; Groß-/Kleinschreibung egal; Fehler mit festem Code und Position; - Schutz vor katastrophalen regulären Ausdrücken durch Zeitlimits. -- `SearchEngine` über Blöcke im Speicher: Filter, eigene Prädikate, Gruppierung, Sortierung, Seiten, - Zeitbudget, automatisches Neuladen, Ausschnitte mit Hervorhebung. -- Markdown-Zerleger mit den Modi `file`, `heading`, `list`, `paragraph`; Markdown → Text. -- Kommandozeile `mdgrep` (grep-artig, JSON-Ausgabe, grep-Rückgabewerte). -- Flask-Blueprint/-App mit JSON-API und Auslieferung der Widget-Dateien; Entwicklungsserver - `python -m mdgrep.web`. -- JavaScript-Such-Widget ohne Framework: Live-Suche, URL-Zustand, Filter, „Mehr laden", Syntaxhilfe, - deutsch/englisch, Gestaltung über CSS-Variablen; `export_static()` für statische Seiten. - -[Unreleased]: https://kitux.de/forgejo/dschlueter/mdgrep/compare/v0.1.2...main -[0.1.2]: https://kitux.de/forgejo/dschlueter/mdgrep/compare/v0.1.1...v0.1.2 -[0.1.1]: https://kitux.de/forgejo/dschlueter/mdgrep/compare/v0.1.0...v0.1.1 -[0.1.0]: https://kitux.de/forgejo/dschlueter/mdgrep/src/tag/v0.1.0 diff --git a/FUNKTIONSWEISE.md b/FUNKTIONSWEISE.md deleted file mode 100644 index db3cce5..0000000 --- a/FUNKTIONSWEISE.md +++ /dev/null @@ -1,339 +0,0 @@ -# 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.2 - ---- - -## 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 TB - subgraph Anfrage - direction TB - Q["Suchtext
(rust OR go) -crypto"] --> T[Tokenizer] - T --> P[Parser] - P --> AST["Syntaxbaum
And / Or / Not / Term"] - end - subgraph Daten - direction TB - D["Markdown-Dateien
oder eigene Daten"] --> L["load_markdown()
split_markdown()"] - L --> B["Blöcke
title, text, meta"] - end - AST --> E["SearchEngine.search()"] - B --> E - E --> F["Filter"] - F --> M["Treffer prüfen
(mit Zeitbudget)"] - M --> S["sortieren, gruppieren,
Seite ausschneiden"] - S --> H["Fundstellen (Spans)
→ Ausschnitte (Segmente)"] - H --> R["SearchResult
.to_dict()"] - R --> CLI["mdgrep
(Terminal)"] - R --> API["Flask
GET /search"] - API --> 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 | diff --git a/README.md b/README.md index 75fe670..cdfacc5 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,11 @@ Boolean full-text search over Markdown (and any other text) – as a **Python li - protection against catastrophic regular expressions (time limit per search) - no search index: blocks live in memory, a few MB of text are searched in milliseconds -## Documentation - -| Document | Content | -|---|---| -| [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md) | User manual (German): installation, syntax, CLI, API, widget, operation, troubleshooting | -| [FUNKTIONSWEISE.md](FUNKTIONSWEISE.md) | How it works (German): architecture, data flow, design decisions, limits | -| [CHANGELOG.md](CHANGELOG.md) | Changes per version and what to do when updating | -| [LICENSE](LICENSE) | MIT | - ## Installation ```bash -pip install "mdgrep @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2" # library + CLI -pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.2" # + Flask API +pip install "mdgrep @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.1" # library + CLI +pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.1" # + Flask API ``` Requires Python ≥ 3.10. Dependencies: [`regex`](https://pypi.org/project/regex/) (and `flask` for `[web]`). diff --git a/pyproject.toml b/pyproject.toml index 7118644..338948f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mdgrep" -version = "0.1.2" +version = "0.1.1" description = "Boolean full-text search (substrings, phrases, regex, AND/OR/NOT, parentheses) over Markdown and other text blocks, with CLI, Flask API and a JavaScript search widget" readme = "README.md" license = "MIT" diff --git a/src/mdgrep/__init__.py b/src/mdgrep/__init__.py index 4e7000e..6f54421 100644 --- a/src/mdgrep/__init__.py +++ b/src/mdgrep/__init__.py @@ -5,7 +5,7 @@ from .markdown import load_markdown, markdown_to_text, split_markdown from .query import Query, QuerySyntaxError, QueryTimeout, compile_query from .snippets import segments -__version__ = "0.1.2" +__version__ = "0.1.1" STATIC_FILES = ("mdgrep-widget.js", "mdgrep-widget.css")