From 47797ecd29758fdcea47b5944c277803811fd950 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 9 Sep 2025 11:12:52 +0200 Subject: [PATCH] scripts/export_markdown.py aktualisiert --- scripts/export_markdown.py | 253 ++++++++++++++++++------------------- 1 file changed, 120 insertions(+), 133 deletions(-) diff --git a/scripts/export_markdown.py b/scripts/export_markdown.py index 9d42334..785a903 100644 --- a/scripts/export_markdown.py +++ b/scripts/export_markdown.py @@ -1,58 +1,43 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -Script: export_markdown.py -Version: 1.3.0 +Script: export_markdown.py — Qdrant → Markdown (Obsidian-kompatibel) +Version: 1.4.0 Datum: 2025-09-09 Kurzbeschreibung ---------------- -Exportiert Markdown-Notizen aus Qdrant in einen Obsidian-kompatiblen Vault-Ordner. -Für jede Note wird die YAML-Frontmatter + Body rekonstruiert. +---------------- +Exportiert Markdown-Notizen aus Qdrant in einen Zielordner (Vault). +Rekonstruiert YAML-Frontmatter und Body. -Body-Rekonstruktion (Priorität): -1) Aus notes.payload.fulltext, falls vorhanden (verlustfreie Rückführung). -2) Andernfalls werden alle Chunks der Note geladen und deren Textfelder - (text -> content -> raw) in stabiler Reihenfolge zusammengefügt. +Body-Rekonstruktions-Priorität (abwärtskompatibel): + 1) notes.payload.fulltext (verlustfrei, wenn beim Import gespeichert) + 2) ansonsten aus allen zugehörigen Chunks: payload.text → payload.content → payload.raw + in stabiler, sequentieller Reihenfolge (seq/chunk_index/ID-Nummer) -Wichtige Hinweise ------------------ -- Qdrant-Zugriff wird über Umgebungsvariablen konfiguriert: - * QDRANT_URL (z. B. http://127.0.0.1:6333) - * QDRANT_API_KEY (optional) - * COLLECTION_PREFIX (z. B. mindnet) - * VECTOR_DIM (wird hier nur zum Collection-Setup benötigt; Standard 384) -- Es wird **kein** --prefix Parameter erwartet; das Präfix kommt aus den Umgebungsvariablen. -- Exportiert nach --out. Unterordner gemäß payload['path'] werden automatisch angelegt. -- Standard: bestehende Dateien werden NICHT überschrieben; mit --overwrite schon. +Wichtige Fixes +-------------- +- **Pfad-Normalisierung**: erzwingt relative Pfade (führe führende '/' ab, backslashes → slashes), + damit ``--out`` nicht ignoriert wird. +- **`--prefix` (optional)**: Überschreibt COLLECTION_PREFIX; ENV bleibt Default (rückwärtskompatibel). -Aufrufparameter ---------------- ---out PATH Ziel-Ordner (Pfad zum Export-Vault) [erforderlich] ---note-id ID Nur eine einzelne Note exportieren (optional) ---overwrite Ziel-Dateien überschreiben, falls vorhanden (optional) +ENV / Qdrant +------------ +- QDRANT_URL (oder QDRANT_HOST/QDRANT_PORT) +- QDRANT_API_KEY (optional) +- COLLECTION_PREFIX (Default: mindnet) + +Aufruf +------ + python3 -m scripts.export_markdown --out ./_exportVault + python3 -m scripts.export_markdown --out ./_exportVault --note-id 20250821-foo + python3 -m scripts.export_markdown --out ./_exportVault --overwrite + python3 -m scripts.export_markdown --out ./_exportVault --prefix mindnet_dev # optional Beispiele --------- COLLECTION_PREFIX=mindnet QDRANT_URL=http://127.0.0.1:6333 \\ - python3 -m scripts.export_markdown --out ./_exportVault - -Nur eine Note: -COLLECTION_PREFIX=mindnet python3 -m scripts.export_markdown \\ - --out ./_exportVault --note-id 20250821-architektur-ki-trainerassistent-761cfe - -Mit Überschreiben: -COLLECTION_PREFIX=mindnet python3 -m scripts.export_markdown \\ - --out ./_exportVault --overwrite - -Änderungen (1.3.0) ------------------- -- Body-Rekonstruktion robust gemacht: - * nutzt 'fulltext' aus Notes-Payload, falls vorhanden - * sonst Zusammenbau aus Chunks; Fallbacks für Textfelder: 'text' -> 'content' -> 'raw' - * stabile Sortierung der Chunks: seq -> chunk_index -> Nummer in chunk_id -- Entfernt veralteten --prefix Parameter; nutzt QdrantConfig.from_env() -- Verbesserte YAML-Ausgabe (Strings bleiben Strings), saubere Trennlinie '---' + python3 -m scripts.export_markdown --out ./_exportVault --overwrite """ from __future__ import annotations @@ -61,14 +46,18 @@ import argparse import json import os import re -from typing import List, Optional, Tuple +from typing import Dict, Iterable, List, Optional, Tuple import yaml from qdrant_client.http import models as rest - -from app.core.qdrant import QdrantConfig, get_client, ensure_collections from qdrant_client import QdrantClient +from app.core.qdrant import QdrantConfig, get_client, ensure_collections + + +# ----------------------------------------------------------------------------- +# Utilities +# ----------------------------------------------------------------------------- def _names(prefix: str) -> Tuple[str, str, str]: return f"{prefix}_notes", f"{prefix}_chunks", f"{prefix}_edges" @@ -80,73 +69,81 @@ def _ensure_dir(path: str) -> None: os.makedirs(d, exist_ok=True) +def _normalize_rel_path(p: str) -> str: + """Pfad relativ halten & normalisieren (slashes, führende / entfernen).""" + p = (p or "").replace("\\", "/") + return p.lstrip("/") + + def _to_md(frontmatter: dict, body: str) -> str: fm = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip() - # Frontmatter, dann eine leere Zeile, danach der Body - return f"---\n{fm}\n---\n{body.rstrip()}\n" + return f"---\n{fm}\n---\n{(body or '').rstrip()}\n" def _scroll_all( client: QdrantClient, - collection: str, + col: str, flt: Optional[rest.Filter] = None, with_payload: bool = True, with_vectors: bool = False, limit: int = 256, -) -> List: - """Holt *alle* Punkte via Scroll (QdrantClient.scroll liefert (points, next_offset)).""" - pts_all = [] - next_offset = None +): + """Scrollt durch alle Punkte einer Collection und liefert eine Liste mit Points.""" + out = [] + next_page = None while True: - points, next_offset = client.scroll( - collection_name=collection, + pts, next_page = client.scroll( + collection_name=col, scroll_filter=flt, with_payload=with_payload, with_vectors=with_vectors, limit=limit, - offset=next_offset, + offset=next_page, ) - pts_all.extend(points or []) - if not next_offset: + if not pts: break - return pts_all + out.extend(pts) + if not next_page: + break + return out -_NUM_IN_CHUNK_ID = re.compile(r"#(?:c)?(\d+)$") - - -def _chunk_sort_key(pl: dict) -> Tuple[int, int, str]: - """ - Stabile Reihenfolge: - 1) 'seq' (falls vorhanden), - 2) 'chunk_index' (falls vorhanden), - 3) Nummer aus 'chunk_id' Suffix (#c02 -> 2), - 4) als letzter Fallback: gesamte 'chunk_id' als string. - """ - seq = pl.get("seq") - if isinstance(seq, int): - return (0, seq, "") - idx = pl.get("chunk_index") - if isinstance(idx, int): - return (1, idx, "") - cid = str(pl.get("chunk_id") or "") - m = _NUM_IN_CHUNK_ID.search(cid) - if m: +def _load_chunks_for_note(client: QdrantClient, chunks_col: str, note_id: str) -> List[dict]: + flt = rest.Filter(must=[rest.FieldCondition( + key="note_id", + match=rest.MatchValue(value=note_id), + )]) + pts = _scroll_all(client, chunks_col, flt, with_payload=True, with_vectors=False) + # Sortierung: bevorzugt seq → chunk_index → Nummer in id + def _seq(pl: dict) -> Tuple[int, int, int]: + s1 = pl.get("seq", pl.get("chunk_index", -1)) + s2 = pl.get("chunk_index", -1) + # Nummer-Anteil aus "noteid#" + s3 = 0 try: - return (2, int(m.group(1)), "") - except ValueError: + m = re.search(r"#(\\d+)$", pl.get("id") or "") + if m: + s3 = int(m.group(1)) + except Exception: pass - return (3, 0, cid) + return (int(s1) if isinstance(s1, int) else -1, int(s2) if isinstance(s2, int) else -1, s3) + + pts_sorted = sorted(pts, key=lambda p: _seq(p.payload or {})) + return [p.payload or {} for p in pts_sorted] -def _join_chunk_texts(chunks_payloads: List[dict]) -> str: - """Nimmt die sortierten Chunk-Payloads und baut den Body zusammen.""" +def _reconstruct_body(note_pl: dict, chunk_payloads: List[dict]) -> str: + # 1) Volltext vorhanden? + fulltext = note_pl.get("fulltext") + if isinstance(fulltext, str) and fulltext.strip(): + return fulltext + + # 2) Aus Chunks zusammensetzen: text → content → raw parts: List[str] = [] - for pl in chunks_payloads: - txt = pl.get("text") or pl.get("content") or pl.get("raw") or "" - if txt: - parts.append(txt.rstrip()) - # Doppelte Leerzeile zwischen Chunks – in Markdown meist ein guter Standard + for ch in chunk_payloads: + text = ch.get("text") or ch.get("content") or ch.get("raw") + if isinstance(text, str) and text.strip(): + parts.append(text.rstrip()) return ("\n\n".join(parts)).rstrip() + ("\n" if parts else "") @@ -159,87 +156,75 @@ def _export_one_note( ) -> dict: notes_col, chunks_col, _ = _names(prefix) note_id = note_pl.get("note_id") or note_pl.get("id") - path = note_pl.get("path") or f"{note_id}.md" - # Zielpfad relativ zu out_root + # Pfad robust bestimmen und relativ halten + path = note_pl.get("path") or f"{note_id}.md" + path = _normalize_rel_path(path) out_path = os.path.join(out_root, path).replace("\\", "/") # Frontmatter aus Payload zurückführen (nur bekannte Felder) - fm = {} - # Bewährte Felder zurückschreiben – unbekannte Keys nicht in YAML aufnehmen + fm: Dict[str, object] = {} for k in [ "title", "id", "type", "status", "created", "updated", "tags", "priority", "effort_min", "due", "people", "aliases", - "depends_on", "assigned_to", "lang" + "depends_on", "assigned_to", "lang", ]: - v = note_pl.get(k) if k in note_pl else note_pl.get(f"note_{k}") # Toleranz für evtl. Namensvarianten + v = note_pl.get(k) if k in note_pl else note_pl.get(f"note_{k}") if v not in (None, [], ""): fm[k] = v - # Pflichtfelder sicherstellen - fm["id"] = fm.get("id") or note_id - fm["title"] = fm.get("title") or note_pl.get("title") or note_id - fm["type"] = fm.get("type") or "concept" - fm["status"] = fm.get("status") or "draft" + # Mindestfelder + if "id" not in fm and note_id: + fm["id"] = note_id + if "title" not in fm and note_pl.get("title"): + fm["title"] = note_pl["title"] - # Body-Rekonstruktion - body = "" - fulltext = note_pl.get("fulltext") - if isinstance(fulltext, str) and fulltext.strip(): - body = fulltext - else: - # Chunks zur Note holen und sortieren - flt = rest.Filter(must=[rest.FieldCondition( - key="note_id", - match=rest.MatchValue(value=note_id) - )]) - chunk_pts = _scroll_all(client, chunks_col, flt, with_payload=True, with_vectors=False) - # Sortierung - chunk_payloads = [p.payload or {} for p in chunk_pts] - chunk_payloads.sort(key=_chunk_sort_key) - body = _join_chunk_texts(chunk_payloads) + # Body beschaffen + chunks = _load_chunks_for_note(client, chunks_col, note_id) + body = _reconstruct_body(note_pl, chunks) - # Ziel schreiben - if (not overwrite) and os.path.exists(out_path): - return {"note_id": note_id, "path": out_path, "status": "skip_exists"} + # Schreiben? + if os.path.exists(out_path) and not overwrite: + return {"note_id": note_id, "path": path, "status": "skip_exists"} _ensure_dir(out_path) with open(out_path, "w", encoding="utf-8") as f: f.write(_to_md(fm, body)) - return { - "note_id": note_id, - "path": out_path, - "status": "written", - "body_from": "fulltext" if isinstance(fulltext, str) and fulltext.strip() else "chunks", - "chunks_used": None if (isinstance(fulltext, str) and fulltext.strip()) else len(chunk_payloads), - } + return {"note_id": note_id, "path": path, "status": "written"} -def main(): +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- + +def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--out", required=True, help="Zielordner für den Export-Vault") ap.add_argument("--note-id", help="Nur eine Note exportieren (Note-ID)") ap.add_argument("--overwrite", action="store_true", help="Bestehende Dateien überschreiben") + ap.add_argument("--prefix", help="(Optional) überschreibt COLLECTION_PREFIX aus ENV") args = ap.parse_args() # Qdrant-Konfiguration cfg = QdrantConfig.from_env() + if args.prefix: + cfg.prefix = args.prefix # abwärtskompatibel: ENV bleibt Default + client = get_client(cfg) ensure_collections(client, cfg.prefix, cfg.dim) notes_col, _, _ = _names(cfg.prefix) - # Notes holen + # Notes holen (optional gefiltert) flt = None if args.note_id: flt = rest.Filter(must=[rest.FieldCondition( key="note_id", - match=rest.MatchValue(value=args.note_id) + match=rest.MatchValue(value=args.note_id), )]) note_pts = _scroll_all(client, notes_col, flt, with_payload=True, with_vectors=False) - if not note_pts: print(json.dumps({"exported": 0, "out": args.out, "message": "Keine Notes gefunden."}, ensure_ascii=False)) return @@ -253,10 +238,12 @@ def main(): res = {"note_id": pl.get("note_id") or pl.get("id"), "error": str(e)} results.append(res) - print(json.dumps({"exported": len([r for r in results if r.get('status') == 'written']), - "skipped": len([r for r in results if r.get('status') == 'skip_exists']), - "out": args.out, - "details": results}, ensure_ascii=False)) + print(json.dumps({ + "exported": len([r for r in results if r.get("status") == "written"]), + "skipped": len([r for r in results if r.get("status") == "skip_exists"]), + "out": args.out, + "details": results, + }, ensure_ascii=False)) if __name__ == "__main__":