# app/core/note_payload.py # ----------------------------------------------------------------------------- # Name: note_payload.py # Version: 1.2.0 (2025-09-08) # Zweck: Erzeugt den Qdrant-Payload für Notes, inkl. deterministischer # Hash-Bildung zur Idempotenz-Erkennung. # # Neu in 1.2.0: # - Konfigurierbare Hash-Strategie via Umgebungsvariable MINDNET_HASH_MODE # * 'body' (Default, rückwärtskompatibel): nur Body geht in den Hash # * 'body+frontmatter' : Body + Frontmatter gehen in den Hash # * 'frontmatter' : nur Frontmatter geht in den Hash # - Kanonische Serialisierung des Frontmatter (sortierte Keys, stabile JSON-Encodierung) # # Aufrufparameter / Steuerung: # - Über Umgebungsvariable: # export MINDNET_HASH_MODE=body+frontmatter # oder direkt am Befehl: # MINDNET_HASH_MODE=frontmatter python3 -m scripts.import_markdown --vault ./vault --apply # # Hinweise: # - Diese Datei ist rückwärtskompatibel: Wenn die Variable nicht gesetzt ist # oder ein unbekannter Wert verwendet wird, fällt die Logik auf 'body' zurück. # - Die Datei-Zeitstempel (mtime/ctime) werden NICHT verwendet. # # Lizenz: MIT # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import os from typing import Any, Dict, Optional, Tuple # -----------------------------------------------------------------------------# # Dienstfunktionen # -----------------------------------------------------------------------------# def sha256_text(s: str) -> str: """Bildet SHA-256 über den gegebenen Unicode-String (UTF-8).""" return hashlib.sha256(s.encode("utf-8")).hexdigest() def canonicalize_frontmatter(fm: Dict[str, Any]) -> str: """ Serialisiert das Frontmatter deterministisch: - JSON mit sortierten Keys - Keine überflüssigen Whitespaces (kompakte Separatoren) - UTF-8, keine ASCII-Escapes Achtung: Datumswerte müssen (wie im Projekt vereinbart) Strings sein. """ return json.dumps( fm, ensure_ascii=False, sort_keys=True, separators=(",", ":") ) def get_hash_mode_from_env() -> str: """ Liest die Hash-Strategie aus der Umgebungsvariable MINDNET_HASH_MODE. Erlaubte Werte: - 'body' (Default) - 'body+frontmatter' - 'frontmatter' Unbekannte Werte -> 'body'. """ val = (os.environ.get("MINDNET_HASH_MODE") or "").strip().lower() if val in ("body", "body+frontmatter", "frontmatter"): return val return "body" def compute_hash(body: str, frontmatter: Dict[str, Any], mode: Optional[str] = None) -> str: """ Berechnet den Hash gemäß der gewünschten Strategie. Args: body: Markdown-Body (ohne Frontmatter), bereits als Text frontmatter: geparstes Frontmatter-Objekt (Dict) mode: 'body' | 'body+frontmatter' | 'frontmatter' | None (= aus ENV) Returns: Hex-String (sha256) """ strategy = (mode or get_hash_mode_from_env()).lower() # Kanonische Strings bilden body_str = (body or "").strip() fm_str = canonicalize_frontmatter(frontmatter or {}) if strategy == "frontmatter": return sha256_text(fm_str) if strategy == "body+frontmatter": # Trennmarker, um Kollisionen (z.B. 'ab'+'c' vs 'a'+'bc') auszuschließen combo = body_str + "\n\n---\n\n" + fm_str return sha256_text(combo) # Default / 'body' return sha256_text(body_str) # -----------------------------------------------------------------------------# # Hauptfunktion für Note-Payload # -----------------------------------------------------------------------------# def make_note_payload(parsed: Dict[str, Any], vault_root: Optional[str] = None) -> Dict[str, Any]: """ Baut den Payload für eine Note auf Basis der geparsten Markdown-Datei. Erwartete Struktur von `parsed` (wie vom Parser geliefert): { "frontmatter": { "id": "...", # note_id (String, Pflicht im Schema) "title": "...", # Titel (String) "type": "...", # Notiztyp (String) "status": "...", # Status (String) "created": "...", # ISO-String, Pflicht im Schema "updated": "...", # ISO-String (empfohlen) "tags": [...], # optional ... }, "body": "..." # Markdown-Inhalt ohne Frontmatter } Rückgabe-Payload (Beispielauszug, kompatibel mit mindnet_notes Schema): { "note_id": "...", "title": "...", "type": "...", "status": "...", "created": "...", "updated": "...", "path": "...", # falls vom Parser geliefert "tags": [...], # optional "hash_fulltext": "sha256...", ... (weitere, projektdefinierte Felder) } Hash-Bildung: - Gesteuert über MINDNET_HASH_MODE (s. Kopf dieses Moduls). - Datei-Zeitstempel werden NICHT verwendet. Rückwärtskompatibilität: - Standard bleibt 'body' (nur Body beeinflusst den Hash). """ fm: Dict[str, Any] = dict(parsed.get("frontmatter") or {}) body: str = parsed.get("body") or "" # Hash nach konfigurierter Strategie berechnen hash_fulltext = compute_hash(body=body, frontmatter=fm, mode=None) # Basis-Payload zusammenstellen payload: Dict[str, Any] = { "note_id": fm.get("id") or fm.get("note_id"), "title": fm.get("title"), "type": fm.get("type"), "status": fm.get("status"), "created": fm.get("created"), "updated": fm.get("updated"), "path": fm.get("path"), # optional, falls der Parser path im FM ablegt "tags": fm.get("tags"), # optional "hash_fulltext": hash_fulltext, } # Weitere projekt-/parser-spezifische Felder durchreichen (falls vorhanden) # Wichtig: keine nicht-deterministischen Felder in den Hash aufnehmen! passthrough_keys = [ "area", "project", "source", "lang", "slug", # ... hier ggf. weitere bekannte, harmlose FM-Felder zulassen ] for k in passthrough_keys: if k in fm: payload[k] = fm[k] return payload # -----------------------------------------------------------------------------# # Optional: kleines Self-Test-Snippet (nur lokal ausführen) # -----------------------------------------------------------------------------# if __name__ == "__main__": demo_fm = { "id": "demo-123", "title": "Demo", "type": "note", "status": "active", "created": "2025-09-08T10:00:00+00:00", "updated": "2025-09-08T10:00:00+00:00", "tags": ["demo", "test"] } demo_body = "# Überschrift\n\nText." for m in ("body", "body+frontmatter", "frontmatter"): os.environ["MINDNET_HASH_MODE"] = m h = compute_hash(demo_body, demo_fm) print(f"{m:>18}: {h}")