Compare commits

..
Author SHA1 Message Date
9c6b78ec36 release: mdgrep 0.1.2 (documentation)
- version 0.1.2 in pyproject.toml and __init__.py
- CHANGELOG: [Unreleased] -> [0.1.2] with "Beim Update zu tun"
- install examples and version notes in README, BEDIENUNGSANLEITUNG,
  FUNKTIONSWEISE point to v0.1.2
- no code changes; widget files unchanged (v0.1.0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:05:29 +02:00
dschlueter
52f7951a66 docs: redraw wide diagrams so labels are readable
Forgejo scales SVG diagrams down to the content width (~900 px); the
left-to-right overview/deployment diagrams were 1650-2960 px wide, i.e.
5-9 px font. Redrawn top-to-bottom with multi-line labels (now 450-730 px,
no scaling); replaced an overlapping self-loop label by an explicit step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:55:33 +02:00
dschlueter
8bc2646dd5 docs: add FUNKTIONSWEISE.md (how mdgrep works)
Architecture and data flow (Mermaid), modules, query language internals
(tokenizer rules, grammar, term compilation), evaluation and timeouts,
highlighting and snippets, Markdown splitting and dialects, search engine
pipeline, Web API and widget, design decisions with rejected alternatives,
limits and extension ideas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:34:51 +02:00
dschlueter
aec824d8be docs: add CHANGELOG.md and documentation overview
- CHANGELOG.md (Keep a Changelog, German) for 0.1.0, 0.1.1 and unreleased
  documentation work, incl. "Beim Update zu tun"
- README: documentation table (manual, how it works, changelog, license)
- manual: release steps now include updating CHANGELOG/FUNKTIONSWEISE

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:34:51 +02:00
dschlueter
d9e495d36a docs: add "Voraussetzungen an die Dokumente" to the manual
New section 8: requirements for searching a collection (encoding, size,
split mode vs. structure, metadata via naming scheme, limits) and for
jump anchors (predictable HTML paths, three ways to ids, same Markdown
dialect, keeping source and HTML in sync, consistency check script),
plus a checklist. Following sections renumbered 9-12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:56:43 +02:00
dschlueter
ec4bfc5939 docs: add German user manual (BEDIENUNGSANLEITUNG.md)
Installation, query syntax, CLI, Python API, Web API, widget, server
operation (gunicorn, systemd, nginx), security, recipes, error codes,
troubleshooting and release workflow. Linked from README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:55:30 +02:00
dschlueter
d9f81ac918 feat: markdown dialect option, faster search (0.1.1)
- split_markdown/load_markdown: dialect="commonmark"|"python-markdown" so list
  blocks match the HTML structure of the renderer (list after paragraph needs a
  blank line; nesting needs 4 spaces in Python-Markdown)
- SearchEngine caches the searched text per block on load
- regex timeout only for /regex/ terms (words and phrases are linear);
  typical queries over 26k blocks: ~30-140 ms instead of 250-700 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:37:40 +02:00
10 changed files with 1342 additions and 33 deletions

851
BEDIENUNGSANLEITUNG.md Normal file
View file

@ -0,0 +1,851 @@
# 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<br>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
<link rel="stylesheet" href="/api/mdgrep-static/mdgrep-widget.css">
<div id="suche"></div>
<script src="/api/mdgrep-static/mdgrep-widget.js"></script>
<script>
MdGrep.mount(document.getElementById('suche'), {
endpoint: '/api/search',
labels: 'de',
filters: [
{ name: 'lang', label: 'Sprache', options: [
{ value: '', label: 'Alle' }, { value: 'de', label: 'Deutsch' }, { value: 'en', label: 'Englisch' } ] },
],
badgeFields: ['lang'],
dateField: 'date',
});
</script>
```
Ganz ohne eigenes JavaScript:
```html
<div data-mdgrep data-endpoint="/api/search" data-labels="de"
data-filters='[{"name":"kind","label":"Art","options":[{"value":"","label":"Alle"},{"value":"item","label":"Listenpunkte"}]}]'
data-badge-fields="kind"></div>
```
### 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
<form action="suche.html"><input type="search" name="q"><button>Suchen</button></form>
```
### 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** (`<dl>`) 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.

58
CHANGELOG.md Normal file
View file

@ -0,0 +1,58 @@
# Ä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

339
FUNKTIONSWEISE.md Normal file
View file

@ -0,0 +1,339 @@
# 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<br/>(rust OR go) -crypto"] --> T[Tokenizer]
T --> P[Parser]
P --> AST["Syntaxbaum<br/>And / Or / Not / Term"]
end
subgraph Daten
direction TB
D["Markdown-Dateien<br/>oder eigene Daten"] --> L["load_markdown()<br/>split_markdown()"]
L --> B["Blöcke<br/>title, text, meta"]
end
AST --> E["SearchEngine.search()"]
B --> E
E --> F["Filter"]
F --> M["Treffer prüfen<br/>(mit Zeitbudget)"]
M --> S["sortieren, gruppieren,<br/>Seite ausschneiden"]
S --> H["Fundstellen (Spans)<br/>→ Ausschnitte (Segmente)"]
H --> R["SearchResult<br/>.to_dict()"]
R --> CLI["mdgrep<br/>(Terminal)"]
R --> API["Flask<br/>GET /search"]
API --> W["Widget<br/>(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 = "<source>:<line>"`.
**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<br/>(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,<br/>Schleife beenden"]
F --> D
D -->|fertig| H["sortieren (sort_key, reverse)"]
G --> H
H --> I["gruppieren (group_by)<br/>Reihenfolge = erster Treffer je Gruppe"]
I --> J["Seite: Gruppen offset … offset+limit"]
J --> K["nur für die Seite:<br/>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, `<mark>`
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 |

View file

@ -9,11 +9,20 @@ 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.0" # library + CLI
pip install "mdgrep[web] @ git+https://kitux.de/forgejo/dschlueter/mdgrep.git@v0.1.0" # + Flask API
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
```
Requires Python ≥ 3.10. Dependencies: [`regex`](https://pypi.org/project/regex/) (and `flask` for `[web]`).
@ -97,6 +106,10 @@ engine.search("login OR sso", filters={"status": "open"})
`load_markdown()` adds `meta["section"]` (nearest heading), `meta["kind"]`
(`file`/`section`/`item`/`paragraph`) and `meta["markdown"]` (raw source).
`dialect` (for `split="list"`) makes blocks line up with the HTML of your Markdown renderer:
`"commonmark"` (default) or `"python-markdown"` (a list needs a blank line before it; nesting
needs 4 spaces of indentation). Important if you link results to element ids in rendered HTML.
### `SearchEngine(source, *, reload_check=None, reload_interval=60)`
`source` is a list of blocks or a callable returning them. With a callable plus `reload_check`

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "mdgrep"
version = "0.1.0"
version = "0.1.2"
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"

View file

@ -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.0"
__version__ = "0.1.2"
STATIC_FILES = ("mdgrep-widget.js", "mdgrep-widget.css")

View file

@ -103,12 +103,17 @@ class SearchEngine:
self._lock = threading.Lock()
self._token = reload_check() if reload_check else None
self._checked_at = time.monotonic()
self._blocks: list[Block] = list(source() if callable(source) else source)
self._set_blocks(list(source() if callable(source) else source))
def _set_blocks(self, blocks: list[Block]) -> None:
# Text that is searched (title + text), precomputed once per load.
haystacks = [f"{b.title}\n{b.text}" if b.title else b.text for b in blocks]
self._data = (blocks, haystacks)
@property
def blocks(self) -> list[Block]:
self._maybe_reload()
return self._blocks
return self._data[0]
def reload(self) -> None:
"""Reload blocks from the loader (no-op for a static block list)."""
@ -116,7 +121,7 @@ class SearchEngine:
return
blocks = list(self._loader())
with self._lock:
self._blocks = blocks
self._set_blocks(blocks)
self._token = self._reload_check() if self._reload_check else None
self._checked_at = time.monotonic()
@ -160,14 +165,19 @@ class SearchEngine:
started = time.monotonic()
q = query if isinstance(query, Query) else compile_query(query)
deadline = started + time_budget if time_budget else None
candidates = self._filter(self.blocks, filters, predicate)
self._maybe_reload()
blocks, haystacks = self._data
conditions = self._conditions(filters)
matched: list[Block] = []
truncated = False
for block in candidates:
for i, block in enumerate(blocks):
if conditions and any(block.meta.get(k) not in allowed for k, allowed in conditions):
continue
if predicate is not None and not predicate(block):
continue
try:
if q.matches(f"{block.title}\n{block.text}" if block.title else block.text,
deadline=deadline):
if q.matches(haystacks[i], deadline=deadline):
matched.append(block)
except QueryTimeout:
truncated = True
@ -214,16 +224,11 @@ class SearchEngine:
)
@staticmethod
def _filter(blocks, filters, predicate):
def _conditions(filters):
conditions = []
for key, value in (filters or {}).items():
if value is None or value == "" or value == []:
continue
allowed = set(value) if isinstance(value, (list, tuple, set, frozenset)) else {value}
conditions.append((key, allowed))
for block in blocks:
if any(block.meta.get(k) not in allowed for k, allowed in conditions):
continue
if predicate is not None and not predicate(block):
continue
yield block
return conditions

View file

@ -9,6 +9,13 @@ Split modes:
Every block gets `meta["section"]` (nearest preceding heading text), `meta["kind"]`
("file", "section", "item" or "paragraph") and `meta["markdown"]` (the raw source).
`dialect` (list mode) decides how list items are recognised, so blocks line up with the HTML
your Markdown renderer produces:
commonmark (default) a list may interrupt a paragraph; only unindented markers start
top-level items
python-markdown a list needs a blank line before it; markers indented by up to 3 spaces
still start top-level items (nesting needs 4 spaces)
The searchable `text` is plain text with Markdown syntax removed. In `file` and `heading`
mode the heading becomes the block title (and is removed from the text); list items and
paragraphs have no title.
@ -22,19 +29,22 @@ from typing import Callable, Iterable
from .engine import Block
__all__ = ["load_markdown", "split_markdown", "markdown_to_text", "slugify", "SPLIT_MODES"]
__all__ = ["load_markdown", "split_markdown", "markdown_to_text", "slugify", "SPLIT_MODES", "DIALECTS"]
SPLIT_MODES = ("file", "heading", "list", "paragraph")
DIALECTS = ("commonmark", "python-markdown")
_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
_FENCE = re.compile(r"^\s*(```|~~~)")
_LIST_ITEM = re.compile(r"^([-*+]|\d{1,9}[.)])\s+")
_LIST_ITEM_PYMD = re.compile(r"^ {0,3}([-*+]|\d{1,9}\.)\s+")
_HR = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$")
def load_markdown(paths: str | Path | Iterable[str | Path], *, split: str = "paragraph",
meta: Callable[[Path], dict] | None = None, encoding: str = "utf-8",
pattern: str = "*.md", relative_to: str | Path | None = None) -> list[Block]:
pattern: str = "*.md", relative_to: str | Path | None = None,
dialect: str = "commonmark") -> list[Block]:
"""Load Markdown files (directories are searched recursively for `pattern`).
`meta(path)` may return a dict merged into every block's meta of that file.
@ -47,7 +57,7 @@ def load_markdown(paths: str | Path | Iterable[str | Path], *, split: str = "par
text = path.read_text(encoding=encoding)
extra = meta(path) if meta else {}
source = path.resolve().relative_to(base).as_posix() if base else str(path)
blocks.extend(split_markdown(text, source=source, split=split, meta=extra))
blocks.extend(split_markdown(text, source=source, split=split, meta=extra, dialect=dialect))
return blocks
@ -64,18 +74,19 @@ def _expand(paths, pattern):
def split_markdown(text: str, *, source: str = "", split: str = "paragraph",
meta: dict | None = None) -> list[Block]:
"""Split one Markdown document into blocks (see module docstring for modes)."""
meta: dict | None = None, dialect: str = "commonmark") -> list[Block]:
"""Split one Markdown document into blocks (see module docstring for modes and dialects)."""
if split not in SPLIT_MODES:
raise ValueError(f"split must be one of {SPLIT_MODES}, got {split!r}")
if dialect not in DIALECTS:
raise ValueError(f"dialect must be one of {DIALECTS}, got {dialect!r}")
meta = meta or {}
lines = text.splitlines()
raw_blocks = {
"file": _split_file,
"heading": _split_heading,
"list": _split_list,
"paragraph": _split_paragraph,
}[split](lines)
if split == "list":
raw_blocks = _split_list(lines, dialect == "python-markdown")
else:
raw_blocks = {"file": _split_file, "heading": _split_heading,
"paragraph": _split_paragraph}[split](lines)
blocks = []
for start, end, kind, section, title, anchor in raw_blocks:
@ -127,7 +138,7 @@ def _split_heading(lines):
return out
def _split_list(lines):
def _split_list(lines, pymd=False):
out = []
section = ""
cur = None # [start, kind]
@ -139,6 +150,7 @@ def _split_list(lines):
out.append((cur[0], end, cur[1], section, "", ""))
cur = None
item_re = _LIST_ITEM_PYMD if pymd else _LIST_ITEM
prev_blank = True
for i, line, in_fence, heading in _scan(lines):
if heading:
@ -154,7 +166,9 @@ def _split_list(lines):
if in_fence:
if cur is None:
cur = [i, "paragraph"]
elif _LIST_ITEM.match(line):
elif pymd and cur is not None and cur[1] == "paragraph" and not prev_blank:
pass # Python-Markdown: no list without blank line
elif item_re.match(line):
close(last_content + 1)
cur = [i, "item"]
elif _HR.match(line):

View file

@ -133,7 +133,11 @@ class Term:
pattern: regex.Pattern = field(repr=False)
def evaluate(self, text: str, deadline: float | None) -> bool:
return self.pattern.search(text, timeout=_remaining(deadline)) is not None
# Words and phrases are escaped literals and run in linear time; only user-supplied
# regular expressions can backtrack catastrophically and need the (costly) timeout.
if self.kind == "regex":
return self.pattern.search(text, timeout=_remaining(deadline)) is not None
return self.pattern.search(text) is not None
@dataclass
@ -285,6 +289,7 @@ class Query:
if deadline is None and timeout is not None:
deadline = time.monotonic() + timeout
try:
_remaining(deadline)
return self._root.evaluate(text, deadline)
except TimeoutError as e:
raise QueryTimeout(str(e) or "Search time limit exceeded") from None
@ -296,7 +301,8 @@ class Query:
found = []
try:
for term in self.positive_terms:
for m in term.pattern.finditer(text, timeout=_remaining(deadline)):
timeout = _remaining(deadline) if term.kind == "regex" else None
for m in term.pattern.finditer(text, timeout=timeout):
if m.end() > m.start():
found.append((m.start(), m.end()))
except TimeoutError as e:

View file

@ -63,6 +63,29 @@ def test_split_list():
assert "not a heading" in blocks[6].text
def test_dialect_list_interrupting_paragraph():
doc = "**1. Trend**\n* stories: a\n* why: b\n\n- real item\n"
common = split_markdown(doc, split="list")
assert [b.meta["kind"] for b in common] == ["paragraph", "item", "item", "item"]
pymd = split_markdown(doc, split="list", dialect="python-markdown")
assert [b.meta["kind"] for b in pymd] == ["paragraph", "item"]
assert "stories: a" in pymd[0].text
def test_dialect_nesting_indent():
doc = "1. **Trend A**\n - why: x\n - how: y\n\n2. **Trend B**\n - nested: z\n"
common = split_markdown(doc, split="list")
assert [b.line for b in common] == [1, 5]
pymd = split_markdown(doc, split="list", dialect="python-markdown")
assert [b.line for b in pymd] == [1, 2, 3, 5] # 3 spaces: no nesting in Python-Markdown
assert "nested: z" in pymd[-1].text
def test_invalid_dialect():
with pytest.raises(ValueError):
split_markdown("x", split="list", dialect="gfm")
def test_split_heading():
blocks = split_markdown(DOC, split="heading")
assert [(b.title, b.anchor) for b in blocks] == [