# -*- coding: utf-8 -*- """Hybride Persistenz: lokale JSON-Dateien als Hauptquelle, Supabase todo_sync nur als Backup. Die globale Autotext-Expansion liest weiterhin über load_autotext() vom Datenträger – keine Serverabhängigkeit im Hook. Synchronisation: nach Start kurz zusammenführen + im Hintergrund Backup pushen. **Lizenz-Workspace**: Zeilen in ``todo_sync`` sind pro Lizenz-Fingerprint vergeben (siehe ``aza_workspace_license.workspace_cloud_row_ids``). Die älteren festen IDs ``3``/``4`` werden nicht mehr für neuen Traffic verwendet (keine Vermischung zwischen Lizenzen). Konflikte je Kürzel/Textblock über entry_meta.updated_at / slot.updated_at; ohne Zeitstempel gilt bei Text-Unterschied Offline-Primat (lokaler Wert bleibt). Gleicher Text → Vereinigung ohne Verlust. **Nicht** Bestandteil dieses Workspace-Backup: Praxis-/Chat-spezifische Daten, Empfangspräferenzen usw.; diese bleiben rein lokal bzw. folgen anderen Pfaden. """ from __future__ import annotations import json import threading import time import urllib.error import urllib.request from datetime import datetime, timezone from typing import Any, Dict, Optional, Tuple try: from aza_workspace_license import ( DEMO_WORKSPACE_TAG, resolve_workspace_identity_tag, workspace_cloud_row_ids, ) except Exception: # pragma: no cover — Falls Modul beim Pack fehlt, defensiv ausweichen def resolve_workspace_identity_tag() -> str: # type: ignore return "" DEMO_WORKSPACE_TAG = "__demo__" def workspace_cloud_row_ids(_workspace_tag: str) -> Tuple[Optional[int], Optional[int]]: # type: ignore return None, None try: from aza_config import _SUPABASE_ANON_KEY, _SUPABASE_URL except Exception: # pragma: no cover _SUPABASE_URL = "" _SUPABASE_ANON_KEY = "" # Historische globale Rows (ältere Builds): nicht mehr aktiv nutzen. WORKSPACE_AUTOTEXT_SYNC_ID = 3 WORKSPACE_TEXTBLOECKE_SYNC_ID = 4 def _effective_workspace_backup_row_ids() -> Tuple[Optional[int], Optional[int]]: tag = resolve_workspace_identity_tag() if not tag or tag == DEMO_WORKSPACE_TAG: return None, None return workspace_cloud_row_ids(tag) _SYNC_PUSH_DELAY_SEC = 0.65 _push_timer_lock = threading.Lock() _push_timer_state: Optional[threading.Timer] = None def utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat() def parse_iso_ts(s: Optional[str]) -> Optional[datetime]: if not s or not isinstance(s, str): return None try: return datetime.fromisoformat(s.replace("Z", "+00:00")) except Exception: return None def normalize_autotext_entries(entries: Any) -> Dict[str, str]: """Nur String-Werte – kompatibel mit globalem Listener.""" out: Dict[str, str] = {} if not isinstance(entries, dict): return out for k, v in entries.items(): if not isinstance(k, str) or not k.strip(): continue if isinstance(v, str): out[k] = v elif isinstance(v, dict) and "text" in v: out[k] = str(v.get("text") or "") return out def _workspace_tag_backup_meta() -> Dict[str, Any]: try: t = resolve_workspace_identity_tag() if t and t != DEMO_WORKSPACE_TAG: return {"workspace_identity_tag_sha256_prefix": t[:16]} except Exception: pass return {} def cloud_push_workspace_row(row_id: int, data: Any) -> bool: if not (_SUPABASE_URL and _SUPABASE_ANON_KEY): return False payload = json.dumps({"id": row_id, "data": data}).encode("utf-8") req = urllib.request.Request( f"{_SUPABASE_URL}/rest/v1/todo_sync?id=eq.{row_id}", data=payload, method="PATCH", headers={ "apikey": _SUPABASE_ANON_KEY, "Authorization": f"Bearer {_SUPABASE_ANON_KEY}", "Content-Type": "application/json", }, ) try: urllib.request.urlopen(req, timeout=14) return True except Exception: try: req2 = urllib.request.Request( f"{_SUPABASE_URL}/rest/v1/todo_sync", data=payload, method="POST", headers={ "apikey": _SUPABASE_ANON_KEY, "Authorization": f"Bearer {_SUPABASE_ANON_KEY}", "Content-Type": "application/json", "Prefer": "return=minimal", }, ) urllib.request.urlopen(req2, timeout=14) return True except Exception: return False def cloud_pull_workspace_row(row_id: int) -> Optional[Any]: if not (_SUPABASE_URL and _SUPABASE_ANON_KEY): return None req = urllib.request.Request( f"{_SUPABASE_URL}/rest/v1/todo_sync?id=eq.{row_id}&select=data", headers={ "apikey": _SUPABASE_ANON_KEY, "Authorization": f"Bearer {_SUPABASE_ANON_KEY}", }, ) try: resp = urllib.request.urlopen(req, timeout=14) rows = json.loads(resp.read().decode("utf-8")) if rows and isinstance(rows, list) and rows: return rows[0].get("data") except (urllib.error.URLError, OSError, ValueError, json.JSONDecodeError): pass return None def _prefer_nonempty_iso(local_iso: Optional[str], remote_iso: Optional[str]) -> str: if local_iso and remote_iso: ld = parse_iso_ts(local_iso) rd = parse_iso_ts(remote_iso) if ld and rd: return remote_iso if rd >= ld else local_iso if rd: return remote_iso if ld: return local_iso return local_iso or utc_now_iso() return remote_iso or local_iso or utc_now_iso() def merge_autotext_fragments( local: Dict[str, Any], remote: Optional[Dict[str, Any]], ) -> Tuple[Dict[str, str], Dict[str, str], bool]: """entries, entry_meta, enabled (global-Schalter wie in load_autotext).""" loc_ent = normalize_autotext_entries(local.get("entries")) loc_meta_raw = local.get("entry_meta") or {} loc_meta = dict(loc_meta_raw) if isinstance(loc_meta_raw, dict) else {} if not isinstance(remote, dict): return dict(loc_ent), dict(loc_meta), bool(local.get("enabled", True)) rem_ent = normalize_autotext_entries(remote.get("entries")) rem_meta_raw = remote.get("entry_meta") or {} rem_meta = dict(rem_meta_raw) if isinstance(rem_meta_raw, dict) else {} out_ent: Dict[str, str] = {} out_meta: Dict[str, str] = {} ls_snap = parse_iso_ts(remote.get("workspace_backup_ts")) lw_snap = parse_iso_ts(local.get("workspace_backup_ts")) remote_snapshot_newer = ls_snap is not None and (lw_snap is None or ls_snap > lw_snap) keys = sorted(set(loc_ent.keys()) | set(rem_ent.keys())) for abbr in keys: lt = loc_ent.get(abbr) rt = rem_ent.get(abbr) l_iso = loc_meta.get(abbr) r_iso = rem_meta.get(abbr) l_dt = parse_iso_ts(l_iso) r_dt = parse_iso_ts(r_iso) if lt is None: out_ent[abbr] = str(rt or "") out_meta[abbr] = r_iso or utc_now_iso() continue if rt is None: out_ent[abbr] = str(lt or "") out_meta[abbr] = l_iso or utc_now_iso() continue s_l = str(lt or "") s_r = str(rt or "") if s_l == s_r: out_ent[abbr] = s_l out_meta[abbr] = _prefer_nonempty_iso(l_iso, r_iso) continue if remote_snapshot_newer and (r_iso or not l_iso): if r_dt is not None and (l_dt is None or r_dt >= l_dt): out_ent[abbr] = s_r out_meta[abbr] = r_iso or utc_now_iso() continue if r_dt is not None and l_dt is not None: if r_dt > l_dt: out_ent[abbr] = s_r out_meta[abbr] = r_iso or utc_now_iso() else: out_ent[abbr] = s_l out_meta[abbr] = l_iso or utc_now_iso() continue if r_dt is not None and l_dt is None: out_ent[abbr] = s_r out_meta[abbr] = r_iso or utc_now_iso() continue out_ent[abbr] = s_l out_meta[abbr] = l_iso or utc_now_iso() merged_global_enabled = bool(local.get("enabled", True)) r_en = remote.get("enabled") if remote_snapshot_newer and isinstance(r_en, bool): merged_global_enabled = r_en return out_ent, out_meta, merged_global_enabled def merge_textbloecke_dict( local: Dict[str, Any], remote: Optional[Dict[str, Any]], ) -> Dict[str, Dict[str, str]]: """Je Slot: neueres updated_at gewinnt; sonst lokaler Inhalt (Offline-Primat).""" loc = strip_internal_textbloecke_meta(dict(local or {})) rem_blocks: Dict[str, Any] = {} if isinstance(remote, dict): inner = remote.get("blocks") if isinstance(inner, dict): rem_blocks = dict(inner) keys = sorted(set(loc.keys()) | set(rem_blocks.keys()), key=lambda x: int(str(x))) out: Dict[str, Dict[str, str]] = {} for k in keys: if not str(k).isdigit(): continue a = dict(loc.get(k) or {}) b = dict(rem_blocks.get(k) or {}) if not a: out[str(k)] = { "name": str(b.get("name") or "").strip() or f"Textblock {k}", "content": str(b.get("content") or ""), "updated_at": str(b.get("updated_at") or utc_now_iso()), } continue if not b: out[str(k)] = { "name": str(a.get("name") or "").strip() or f"Textblock {k}", "content": str(a.get("content") or ""), "updated_at": str(a.get("updated_at") or utc_now_iso()), } continue na = str(a.get("name") or "").strip() or f"Textblock {k}" nb = str(b.get("name") or "").strip() or f"Textblock {k}" ca = str(a.get("content") or "") cb = str(b.get("content") or "") if na == nb and ca == cb: out[str(k)] = { "name": na, "content": ca, "updated_at": _prefer_nonempty_iso( str(a.get("updated_at")), str(b.get("updated_at"))), } continue ta = parse_iso_ts(a.get("updated_at")) tb_dt = parse_iso_ts(b.get("updated_at")) if tb_dt is not None and (ta is None or tb_dt > ta): src = b elif ta is not None and (tb_dt is None or ta >= tb_dt): src = a else: src = a out[str(k)] = { "name": str(src.get("name") or "").strip() or f"Textblock {k}", "content": str(src.get("content") or ""), "updated_at": str(src.get("updated_at") or utc_now_iso()), } slots = sorted(out.keys(), key=int) if len(slots) < 2: return { "1": {"name": "Textblock 1", "content": "", "updated_at": utc_now_iso()}, "2": {"name": "Textblock 2", "content": "", "updated_at": utc_now_iso()}, } return out def _later_workspace_ts(local_ts: Optional[str], remote_ts: Optional[str]) -> str: lt, rt = parse_iso_ts(local_ts), parse_iso_ts(remote_ts) if lt and rt: return remote_ts if rt >= lt else (local_ts or remote_ts or utc_now_iso()) return remote_ts or local_ts or utc_now_iso() def autotext_backup_payload( enabled: bool, entries: Dict[str, Any], entry_meta: Dict[str, Any], ) -> Dict[str, Any]: out = { "v": 1, "enabled": enabled, "entries": normalize_autotext_entries(entries), "entry_meta": dict(entry_meta or {}), "workspace_backup_ts": utc_now_iso(), } meta = _workspace_tag_backup_meta() if meta: out.update(meta) return out def textbloecke_backup_payload(blocks: Dict[str, Any]) -> Dict[str, Any]: out = { "v": 1, "blocks": dict(blocks or {}), "workspace_backup_ts": utc_now_iso(), } meta = _workspace_tag_backup_meta() if meta: out.update(meta) return out def schedule_workspace_cloud_push(delay: Optional[float] = None) -> None: """Nach lokalem Speichern: debounced Autotext+Textblöcke ins Backup.""" global _push_timer_state delay_sec = delay if delay is not None else _SYNC_PUSH_DELAY_SEC def push_both() -> None: try: id_at, id_tb = _effective_workspace_backup_row_ids() if id_at is None or id_tb is None: return from aza_persistence import load_autotext at = load_autotext() snap = autotext_backup_payload( bool(at.get("enabled", True)), at.get("entries") or {}, at.get("entry_meta") or {}, ) cloud_push_workspace_row(id_at, snap) from aza_persistence import load_textbloecke tb_clean = strip_internal_textbloecke_meta(load_textbloecke()) cloud_push_workspace_row( id_tb, textbloecke_backup_payload(tb_clean), ) at["workspace_backup_ts"] = snap["workspace_backup_ts"] from aza_persistence import save_autotext save_autotext(at) except Exception: pass def arm() -> None: global _push_timer_state with _push_timer_lock: if _push_timer_state is not None: try: _push_timer_state.cancel() except Exception: pass t = threading.Timer( delay_sec, lambda: threading.Thread(target=push_both, daemon=True).start(), ) t.daemon = True _push_timer_state = t t.start() try: arm() except Exception: pass def strip_internal_textbloecke_meta(d: Dict[str, Any]) -> Dict[str, Any]: return { sk: sv for sk, sv in dict(d).items() if not str(sk).startswith("__") } def start_background_workspace_hybrid_sync(app) -> None: def job(): time.sleep(1.8) row_at, row_tb = _effective_workspace_backup_row_ids() try: from aza_persistence import ( load_autotext, load_textbloecke, save_autotext, save_textbloecke, ) local_at = load_autotext() rat: Optional[Any] = None rtl: Optional[Any] = None if row_at is not None: rat = cloud_pull_workspace_row(row_at) if row_tb is not None: rtl = cloud_pull_workspace_row(row_tb) merged_e, merged_m, merged_en = merge_autotext_fragments( local_at, rat if isinstance(rat, dict) else None, ) merged_at_data = dict(local_at) merged_at_data["entries"] = merged_e merged_at_data["entry_meta"] = merged_m merged_at_data["enabled"] = merged_en rts = rat.get("workspace_backup_ts") if isinstance(rat, dict) else None merged_at_data["workspace_backup_ts"] = _later_workspace_ts( local_at.get("workspace_backup_ts"), rts if isinstance(rts, str) else None, ) ltbl = load_textbloecke() remote_wrap = rtl if isinstance(rtl, dict) else None merged_tb = merge_textbloecke_dict(ltbl, remote_wrap) def apply_ui(): try: tgt = getattr(app, "_autotext_data", None) merged = merged_at_data if isinstance(tgt, dict): en_m = normalize_autotext_entries(merged.get("entries")) tgt["enabled"] = bool(merged.get("enabled", True)) te = tgt.get("entries") if isinstance(te, dict): te.clear() te.update(en_m) else: tgt["entries"] = dict(en_m) em_tgt = tgt.get("entry_meta") em_src = merged.get("entry_meta") or {} if isinstance(em_tgt, dict): em_tgt.clear() if isinstance(em_src, dict): em_tgt.update(em_src) else: tgt["entry_meta"] = dict(em_src) if isinstance(em_src, dict) else {} wts = merged.get("workspace_backup_ts") if isinstance(wts, str): tgt["workspace_backup_ts"] = wts save_autotext(tgt) else: setattr(app, "_autotext_data", merged) save_autotext(merged) save_textbloecke(strip_internal_textbloecke_meta(merged_tb)) shell = getattr(app, "_aza_office_v1", None) if shell is not None and hasattr( shell, "refresh_sidebar_textbloecke_section"): shell.refresh_sidebar_textbloecke_section() except Exception: pass try: app.after(0, apply_ui) except Exception: apply_ui() time.sleep(0.5) snap_a = autotext_backup_payload( bool(merged_at_data.get("enabled", True)), merged_at_data.get("entries") or {}, merged_at_data.get("entry_meta") or {}, ) if row_at is not None and row_tb is not None: cloud_push_workspace_row(row_at, snap_a) cloud_push_workspace_row( row_tb, textbloecke_backup_payload( strip_internal_textbloecke_meta(merged_tb)), ) except Exception: pass try: threading.Thread(target=job, daemon=True).start() except Exception: pass def touch_autotext_entry_meta(data: dict, abbrev: str) -> None: if not isinstance(data, dict) or not abbrev: return em = data.setdefault("entry_meta", {}) if isinstance(em, dict): em[abbrev.strip()] = utc_now_iso() def prune_autotext_meta(data: dict, valid_abbrevs: set) -> None: em = data.get("entry_meta") if not isinstance(em, dict): data["entry_meta"] = {} return for k in list(em.keys()): if k not in valid_abbrevs: try: del em[k] except Exception: pass __all__ = [ "WORKSPACE_AUTOTEXT_SYNC_ID", "WORKSPACE_TEXTBLOECKE_SYNC_ID", "utc_now_iso", "normalize_autotext_entries", "schedule_workspace_cloud_push", "start_background_workspace_hybrid_sync", "touch_autotext_entry_meta", "prune_autotext_meta", "autotext_backup_payload", "textbloecke_backup_payload", "merge_autotext_fragments", "merge_textbloecke_dict", ]