# -*- coding: utf-8 -*- """Server- und Client-Sync für Textblöcke, Korrekturen und Autotext (pro practice_id). Parallele Supabase-Workspace-Sync (aza_workspace_sync) bleibt unverändert. Dieses Modul nutzt dieselbe API wie KI-Guthaben (stripe_webhook.sqlite + X-Practice-Id). """ from __future__ import annotations import hashlib import json import shutil import sqlite3 import threading import time import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from fastapi import Depends, HTTPException, Query, Request from pydantic import BaseModel, Field from aza_security import require_api_token from aza_ai_budget import resolve_license_for_empfang SYNC_ITEM_TYPES = ("textblock", "correction", "autotext") _PUSH_DELAY_SEC = 0.8 _push_lock = threading.Lock() _push_timers: Dict[str, threading.Timer] = {} # --------------------------------------------------------------------------- # Logging (keine Inhalte) # --------------------------------------------------------------------------- def _client_debug_log(line: str) -> None: try: from aza_config import get_writable_data_dir path = Path(get_writable_data_dir()) / "client_debug.log" ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") with open(path, "a", encoding="utf-8") as f: f.write(f"{ts} {line}\n") except Exception: pass def _log_sync(event: str, **fields: Any) -> None: parts = [str(event)] for k, v in fields.items(): kl = str(k).lower() if any(x in kl for x in ("content", "text", "body", "trigger", "title", "token", "secret")): continue parts.append(f"{k}={v}") _client_debug_log(" ".join(parts)) # --------------------------------------------------------------------------- # Server DB # --------------------------------------------------------------------------- def ensure_sync_items_schema(con: sqlite3.Connection) -> None: con.execute( """ CREATE TABLE IF NOT EXISTS synced_user_items ( id TEXT PRIMARY KEY, practice_id TEXT NOT NULL, item_type TEXT NOT NULL, title TEXT, trigger_text TEXT, content TEXT NOT NULL, sort_order INTEGER DEFAULT 0, is_active INTEGER DEFAULT 1, created_at INTEGER, updated_at INTEGER, created_by TEXT, updated_by TEXT, source_device TEXT ) """ ) con.execute( "CREATE INDEX IF NOT EXISTS idx_sync_items_practice_type " "ON synced_user_items(practice_id, item_type, is_active, sort_order)" ) con.commit() def _now_ts() -> int: return int(time.time()) def _resolve_practice_id_for_request( con: sqlite3.Connection, request: Request, ) -> str: hdr = (request.headers.get("X-Practice-Id") or "").strip() device_id = (request.headers.get("X-Device-Id") or "").strip() or None lic = resolve_license_for_empfang( con, x_device_id=device_id, session_practice_id=hdr or None, ) if not lic: raise HTTPException(status_code=403, detail="No license mapping for practice") pid = (lic.practice_id or "").strip() if not pid: raise HTTPException(status_code=403, detail="practice_id missing on license") if hdr and pid != hdr: raise HTTPException(status_code=403, detail="practice_id mismatch") return pid def _row_to_item(row: tuple) -> Dict[str, Any]: return { "id": row[0], "practice_id": row[1], "item_type": row[2], "title": row[3] or "", "trigger": row[4] or "", "content": row[5] or "", "sort_order": int(row[6] or 0), "is_active": bool(row[7]), "created_at": int(row[8] or 0), "updated_at": int(row[9] or 0), "created_by": row[10] or "", "updated_by": row[11] or "", "source_device": row[12] or "", } def list_sync_items( con: sqlite3.Connection, practice_id: str, item_type: str, *, active_only: bool = True, ) -> List[Dict[str, Any]]: it = (item_type or "").strip().lower() if it not in SYNC_ITEM_TYPES: raise HTTPException(status_code=400, detail="invalid item_type") q = """ SELECT id, practice_id, item_type, title, trigger_text, content, sort_order, is_active, created_at, updated_at, created_by, updated_by, source_device FROM synced_user_items WHERE practice_id = ? AND item_type = ? """ params: List[Any] = [practice_id, it] if active_only: q += " AND is_active = 1" q += " ORDER BY sort_order ASC, updated_at DESC" rows = con.execute(q, params).fetchall() return [_row_to_item(r) for r in rows] def upsert_sync_item( con: sqlite3.Connection, practice_id: str, payload: Dict[str, Any], *, source_device: str = "", ) -> Dict[str, Any]: it = str(payload.get("item_type") or "").strip().lower() if it not in SYNC_ITEM_TYPES: raise HTTPException(status_code=400, detail="invalid item_type") item_id = str(payload.get("id") or "").strip() or str(uuid.uuid4()) now = _now_ts() existing = con.execute( "SELECT practice_id, created_at FROM synced_user_items WHERE id = ?", (item_id,), ).fetchone() if existing and str(existing[0]) != practice_id: raise HTTPException(status_code=403, detail="item belongs to another practice") created_at = int(existing[1]) if existing else now title = str(payload.get("title") or "")[:500] trigger = str(payload.get("trigger") or "")[:500] content = str(payload.get("content") or "") sort_order = int(payload.get("sort_order") or 0) is_active = 1 if payload.get("is_active", True) else 0 con.execute( """ INSERT INTO synced_user_items ( id, practice_id, item_type, title, trigger_text, content, sort_order, is_active, created_at, updated_at, created_by, updated_by, source_device ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET practice_id=excluded.practice_id, item_type=excluded.item_type, title=excluded.title, trigger_text=excluded.trigger_text, content=excluded.content, sort_order=excluded.sort_order, is_active=excluded.is_active, updated_at=excluded.updated_at, updated_by=excluded.updated_by, source_device=excluded.source_device """, ( item_id, practice_id, it, title, trigger, content, sort_order, is_active, created_at, now, str(payload.get("created_by") or "")[:120], str(payload.get("updated_by") or "")[:120], (source_device or "")[:64], ), ) con.commit() row = con.execute( """ SELECT id, practice_id, item_type, title, trigger_text, content, sort_order, is_active, created_at, updated_at, created_by, updated_by, source_device FROM synced_user_items WHERE id = ? """, (item_id,), ).fetchone() return _row_to_item(row) if row else {"id": item_id} def soft_delete_sync_item( con: sqlite3.Connection, practice_id: str, item_id: str, ) -> bool: cur = con.execute( """ UPDATE synced_user_items SET is_active = 0, updated_at = ? WHERE id = ? AND practice_id = ? """, (_now_ts(), item_id, practice_id), ) con.commit() return cur.rowcount > 0 # --------------------------------------------------------------------------- # API models + routes # --------------------------------------------------------------------------- class SyncItemIn(BaseModel): id: Optional[str] = None item_type: str = Field(..., min_length=1) title: Optional[str] = "" trigger: Optional[str] = "" content: str = "" sort_order: int = 0 is_active: bool = True created_by: Optional[str] = "" updated_by: Optional[str] = "" class SyncItemBulkIn(BaseModel): items: List[SyncItemIn] = Field(default_factory=list) def register_sync_items_routes(app, *, stripe_db_path_fn) -> None: """Registriert /v1/sync/items* auf der FastAPI-App.""" @app.get("/v1/sync/items", dependencies=[Depends(require_api_token)]) def sync_items_list( request: Request, type: str = Query(..., alias="type"), ): db_path = stripe_db_path_fn() if not db_path.exists(): raise HTTPException(status_code=503, detail="database missing") with sqlite3.connect(db_path) as con: ensure_sync_items_schema(con) pid = _resolve_practice_id_for_request(con, request) items = list_sync_items(con, pid, type) return {"ok": True, "practice_id": pid, "item_type": type, "items": items, "count": len(items)} @app.post("/v1/sync/items", dependencies=[Depends(require_api_token)]) def sync_items_upsert(request: Request, body: SyncItemIn): db_path = stripe_db_path_fn() if not db_path.exists(): raise HTTPException(status_code=503, detail="database missing") dev = (request.headers.get("X-Device-Id") or "").strip() with sqlite3.connect(db_path) as con: ensure_sync_items_schema(con) pid = _resolve_practice_id_for_request(con, request) item = upsert_sync_item( con, pid, body.model_dump(), source_device=dev, ) return {"ok": True, "item": item} @app.post("/v1/sync/items/bulk", dependencies=[Depends(require_api_token)]) def sync_items_bulk(request: Request, body: SyncItemBulkIn): db_path = stripe_db_path_fn() if not db_path.exists(): raise HTTPException(status_code=503, detail="database missing") dev = (request.headers.get("X-Device-Id") or "").strip() saved = [] with sqlite3.connect(db_path) as con: ensure_sync_items_schema(con) pid = _resolve_practice_id_for_request(con, request) for raw in body.items: saved.append( upsert_sync_item( con, pid, raw.model_dump(), source_device=dev, ) ) return {"ok": True, "count": len(saved)} @app.put("/v1/sync/items/{item_id}", dependencies=[Depends(require_api_token)]) def sync_items_put(item_id: str, request: Request, body: SyncItemIn): db_path = stripe_db_path_fn() payload = body.model_dump() payload["id"] = item_id dev = (request.headers.get("X-Device-Id") or "").strip() with sqlite3.connect(db_path) as con: ensure_sync_items_schema(con) pid = _resolve_practice_id_for_request(con, request) item = upsert_sync_item(con, pid, payload, source_device=dev) return {"ok": True, "item": item} @app.delete("/v1/sync/items/{item_id}", dependencies=[Depends(require_api_token)]) def sync_items_delete(item_id: str, request: Request): db_path = stripe_db_path_fn() with sqlite3.connect(db_path) as con: ensure_sync_items_schema(con) pid = _resolve_practice_id_for_request(con, request) ok = soft_delete_sync_item(con, pid, item_id) if not ok: raise HTTPException(status_code=404, detail="not found") return {"ok": True, "id": item_id} @app.get("/v1/textblocks", dependencies=[Depends(require_api_token)]) def textblocks_list(request: Request): return sync_items_list(request, type="textblock") @app.post("/v1/textblocks", dependencies=[Depends(require_api_token)]) def textblocks_post(request: Request, body: SyncItemIn): body.item_type = "textblock" return sync_items_upsert(request, body) # --------------------------------------------------------------------------- # Client: local <-> server mapping # --------------------------------------------------------------------------- def _stable_id(prefix: str, *parts: str) -> str: raw = "|".join([prefix] + [str(p) for p in parts]) return f"{prefix}_{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:24]}" def local_textblocks_to_items(tb: Dict[str, Any]) -> List[Dict[str, Any]]: items = [] for sk in sorted(tb.keys(), key=lambda x: int(str(x)) if str(x).isdigit() else 0): if not str(sk).isdigit(): continue slot = tb[sk] if not isinstance(slot, dict): continue items.append( { "id": _stable_id("tb", sk), "item_type": "textblock", "title": (slot.get("name") or "").strip() or f"Textblock {sk}", "trigger": "", "content": str(slot.get("content") or ""), "sort_order": int(sk), } ) return items def items_to_local_textblocks(items: List[Dict[str, Any]]) -> Dict[str, Dict[str, str]]: out: Dict[str, Dict[str, str]] = {} for it in sorted(items, key=lambda x: int(x.get("sort_order") or 0)): sk = str(int(it.get("sort_order") or len(out) + 1)) out[sk] = { "name": (it.get("title") or "").strip() or f"Textblock {sk}", "content": str(it.get("content") or ""), "updated_at": datetime.now(timezone.utc).isoformat(), } if len(out) < 2: out.setdefault("1", {"name": "Textblock 1", "content": "", "updated_at": ""}) out.setdefault("2", {"name": "Textblock 2", "content": "", "updated_at": ""}) return out def local_korrekturen_to_items(k: Dict[str, Any]) -> List[Dict[str, Any]]: items = [] order = 0 for cat, mapping in (k or {}).items(): if not isinstance(mapping, dict): continue for falsch, richtig in mapping.items(): order += 1 items.append( { "id": _stable_id("corr", cat, falsch), "item_type": "correction", "title": str(cat), "trigger": str(falsch), "content": str(richtig), "sort_order": order, } ) return items def items_to_local_korrekturen(items: List[Dict[str, Any]]) -> Dict[str, Dict[str, str]]: out: Dict[str, Dict[str, str]] = {} for it in items: cat = (it.get("title") or "allgemein").strip() or "allgemein" falsch = str(it.get("trigger") or "") richtig = str(it.get("content") or "") if not falsch: continue out.setdefault(cat, {})[falsch] = richtig if not out: try: from aza_config import _DEFAULT_KORREKTUREN return {c: dict(m) for c, m in _DEFAULT_KORREKTUREN.items()} except Exception: return {} return out def local_autotext_to_items(at: Dict[str, Any]) -> List[Dict[str, Any]]: entries = at.get("entries") if isinstance(at.get("entries"), dict) else {} items = [] order = 0 for abbr, text in entries.items(): if not isinstance(abbr, str) or not abbr.strip(): continue order += 1 val = text if isinstance(text, str) else str(text) items.append( { "id": _stable_id("at", abbr.strip()), "item_type": "autotext", "title": abbr.strip(), "trigger": abbr.strip(), "content": val, "sort_order": order, } ) return items def items_to_local_autotext_entries(items: List[Dict[str, Any]]) -> Dict[str, str]: out: Dict[str, str] = {} for it in items: tr = (it.get("trigger") or it.get("title") or "").strip() if tr: out[tr] = str(it.get("content") or "") return out def _backup_local_sync_files() -> None: try: from aza_config import ( AUTOTEXT_CONFIG_FILENAME, KORREKTUREN_CONFIG_FILENAME, TEXTBLOECKE_CONFIG_FILENAME, get_writable_data_dir, ) base = Path(get_writable_data_dir()) stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") dest = base / f".aza_sync_local_backup_{stamp}" dest.mkdir(parents=True, exist_ok=True) for name in ( TEXTBLOECKE_CONFIG_FILENAME, KORREKTUREN_CONFIG_FILENAME, AUTOTEXT_CONFIG_FILENAME, ): src = base / name if src.is_file(): shutil.copy2(src, dest / name) except Exception: pass def _http_get_json(url: str, headers: Dict[str, str], timeout: Tuple[float, float]) -> Tuple[int, Any]: import requests r = requests.get(url, headers=headers, timeout=timeout) try: return r.status_code, r.json() except Exception: return r.status_code, {} def _http_post_json(url: str, headers: Dict[str, str], body: Any, timeout: Tuple[float, float]) -> Tuple[int, Any]: import requests r = requests.post(url, headers=headers, json=body, timeout=timeout) try: return r.status_code, r.json() except Exception: return r.status_code, {} def _sync_headers(app) -> Optional[Dict[str, str]]: try: bu = app.get_backend_url() tok = app.get_backend_token() except Exception: return None practice_id = "" try: practice_id = (app.get_practice_id() or "").strip() except Exception: pass if not bu or not tok or not practice_id: return None try: from basis14 import _get_or_create_device_id except Exception: _get_or_create_device_id = lambda: "" # type: ignore headers = {"X-API-Token": tok, "X-Practice-Id": practice_id} did = _get_or_create_device_id() if did: headers["X-Device-Id"] = did return headers def _fetch_server_items(app, item_type: str) -> Optional[List[Dict[str, Any]]]: headers = _sync_headers(app) if not headers: return None try: bu = app.get_backend_url().rstrip("/") except Exception: return None url = f"{bu}/v1/sync/items?type={item_type}" code, data = _http_get_json(url, headers, (3, 12)) if code != 200 or not isinstance(data, dict) or not data.get("ok"): return None items = data.get("items") return items if isinstance(items, list) else [] def _upload_items_bulk(app, items: List[Dict[str, Any]]) -> bool: if not items: return True headers = _sync_headers(app) if not headers: return False try: bu = app.get_backend_url().rstrip("/") except Exception: return False url = f"{bu}/v1/sync/items/bulk" code, data = _http_post_json(url, headers, {"items": items}, (3, 20)) return code == 200 and isinstance(data, dict) and data.get("ok") def _apply_server_items_to_local(app, item_type: str, items: List[Dict[str, Any]]) -> None: from aza_persistence import ( load_autotext, load_korrekturen, load_textbloecke, save_autotext, save_korrekturen, save_textbloecke, ) if item_type == "textblock": merged = items_to_local_textblocks(items) save_textbloecke(merged) shell = getattr(app, "_aza_office_v1", None) if shell is not None and hasattr(shell, "refresh_sidebar_textbloecke_section"): try: app.after(0, shell.refresh_sidebar_textbloecke_section) except Exception: shell.refresh_sidebar_textbloecke_section() if hasattr(app, "_textbloecke_data"): app._textbloecke_data = merged return if item_type == "correction": merged = items_to_local_korrekturen(items) save_korrekturen(merged) return if item_type == "autotext": at = load_autotext() entries = items_to_local_autotext_entries(items) if isinstance(at.get("entries"), dict): at["entries"].clear() at["entries"].update(entries) else: at["entries"] = dict(entries) save_autotext(at) tgt = getattr(app, "_autotext_data", None) if isinstance(tgt, dict): te = tgt.get("entries") if isinstance(te, dict): te.clear() te.update(entries) else: tgt["entries"] = dict(entries) def _local_items_for_type(item_type: str) -> List[Dict[str, Any]]: from aza_persistence import load_autotext, load_korrekturen, load_textbloecke if item_type == "textblock": return local_textblocks_to_items(load_textbloecke()) if item_type == "correction": return local_korrekturen_to_items(load_korrekturen()) if item_type == "autotext": return local_autotext_to_items(load_autotext()) return [] def _local_has_meaningful_data(item_type: str) -> bool: items = _local_items_for_type(item_type) if item_type == "textblock": for it in items: if (it.get("content") or "").strip() or ( (it.get("title") or "").strip() and it.get("title") != f"Textblock {it.get('sort_order')}" ): return True return False if item_type == "correction": return len(items) > 0 if item_type == "autotext": return len(items) > 0 return False def sync_practice_items_for_type(app, item_type: str) -> None: _log_sync("SYNC_ITEMS_START", type=item_type) server_items = _fetch_server_items(app, item_type) if server_items is None: _log_sync("SYNC_ITEMS_FALLBACK_LOCAL", type=item_type, reason="server_unreachable") return if server_items: _backup_local_sync_files() _apply_server_items_to_local(app, item_type, server_items) _log_sync("SYNC_ITEMS_OK", type=item_type, count=len(server_items), strategy="server_wins") return if _local_has_meaningful_data(item_type): local_items = _local_items_for_type(item_type) if _upload_items_bulk(app, local_items): _log_sync("SYNC_ITEMS_UPLOAD_LOCAL", type=item_type, count=len(local_items)) else: _log_sync("SYNC_ITEMS_FALLBACK_LOCAL", type=item_type, reason="upload_failed") return _log_sync("SYNC_ITEMS_OK", type=item_type, count=0, strategy="empty") def _sync_worker(app) -> None: time.sleep(2.0) try: from basis14 import _has_remote_backend except Exception: def _has_remote_backend() -> bool: # type: ignore return False if not _has_remote_backend(): return if not (app.get_practice_id() or "").strip(): _log_sync("SYNC_ITEMS_FALLBACK_LOCAL", type="all", reason="no_practice_id") return for it in SYNC_ITEM_TYPES: try: sync_practice_items_for_type(app, it) except Exception as exc: _log_sync( "SYNC_ITEMS_FALLBACK_LOCAL", type=it, reason=exc.__class__.__name__, ) def start_background_practice_items_sync(app) -> None: try: threading.Thread(target=_sync_worker, args=(app,), daemon=True).start() except Exception: pass def schedule_practice_items_push(item_type: str) -> None: key = (item_type or "").strip().lower() if key not in SYNC_ITEM_TYPES: return def push() -> None: try: import basis14 as b14 app = None for w in list(getattr(b14, "_window_registry", []) or []): if hasattr(w, "get_practice_id"): app = w break if app is None: return items = _local_items_for_type(key) if items and _upload_items_bulk(app, items): _log_sync("SYNC_ITEMS_PUSH_OK", type=key, count=len(items)) except Exception: pass def arm() -> None: with _push_lock: old = _push_timers.get(key) if old is not None: try: old.cancel() except Exception: pass t = threading.Timer( _PUSH_DELAY_SEC, lambda: threading.Thread(target=push, daemon=True).start(), ) t.daemon = True _push_timers[key] = t t.start() arm() def install_persistence_sync_hooks() -> None: """Nach lokalem Speichern debounced Upload (ohne Autotext-Engine zu ändern).""" try: import aza_persistence as p if getattr(p, "_aza_practice_sync_hooks", False): return orig_tb = p.save_textbloecke orig_k = p.save_korrekturen orig_at = p.save_autotext def save_textbloecke_wrapped(data, _o=orig_tb): _o(data) schedule_practice_items_push("textblock") def save_korrekturen_wrapped(data, _o=orig_k): _o(data) schedule_practice_items_push("correction") def save_autotext_wrapped(data, _o=orig_at): _o(data) schedule_practice_items_push("autotext") p.save_textbloecke = save_textbloecke_wrapped # type: ignore p.save_korrekturen = save_korrekturen_wrapped # type: ignore p.save_autotext = save_autotext_wrapped # type: ignore p._aza_practice_sync_hooks = True except Exception: pass __all__ = [ "ensure_sync_items_schema", "register_sync_items_routes", "start_background_practice_items_sync", "schedule_practice_items_push", "install_persistence_sync_hooks", "SYNC_ITEM_TYPES", ]