# -*- coding: utf-8 -*- """Lizenzgebundener Workspace: Identität, Zustand, optionaler Import-Dialog. Trennt klar: - **Lizenz-Workspace** (Autotext-Kürzel/Texte, Textblöcke, workspace_backup_ts) → Cloud-Sync pro Lizenzschlüssel-Fingerprint (Supabase todo_sync Zeilen). - **Demo ohne Lizenzmaterial** → kein Cloud-Workspace (nur lokal). - **Praxis-/Empfang-/sonstige Autotext-Felder** bleiben unangetastet beim Workspace-„Verwerfen“; es werden nur die lizenzgebundenen Schlüssel geleert. Schlüsselmaterial (Priorität): ``license_key`` im Benutzerprofil, sonst persistierter Aktivierungsschlüssel (`aza_activation`). """ from __future__ import annotations import hashlib import json import os import time from datetime import datetime, timezone from typing import Any, Dict, Optional, Tuple from aza_config import get_writable_data_dir from aza_activation import load_activation_key _LICENSE_STATE_FILENAME = "workspace_license_state.json" DEMO_WORKSPACE_TAG = "__demo__" _WS_LICENSE_DIALOG_ACTIVE = False _SNAPSHOT_SUBDIR = "workspace_license_snapshots" def utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _state_path() -> str: return os.path.join(get_writable_data_dir(), _LICENSE_STATE_FILENAME) def load_workspace_license_state() -> Dict[str, Any]: try: p = _state_path() if os.path.isfile(p): with open(p, "r", encoding="utf-8") as f: d = json.load(f) if isinstance(d, dict): return d except Exception: pass return {} def save_workspace_license_state(data: Dict[str, Any]) -> None: try: p = _state_path() root = os.path.dirname(p) if root: os.makedirs(root, exist_ok=True) tmp = p + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) os.replace(tmp, p) except Exception: pass def resolve_license_material_raw() -> str: """Nicht-hash Rohmaterial für den Workspace-Fingerprint (leer = Demo).""" try: from aza_persistence import load_user_profile lk = (load_user_profile().get("license_key") or "").strip() if lk: return lk except Exception: pass try: act = load_activation_key() if act: return act.strip() except Exception: pass return "" def resolve_workspace_identity_tag() -> str: """Stabiler Workspace-Identifikator für Sync und lokale Zuordnung. 64-Zeichen-SHA256-Hex über normalisiertes Lizenz-/Aktivierung-Material, oder ``DEMO_WORKSPACE_TAG`` wenn keins vorliegt.""" raw = resolve_license_material_raw() if not raw: return DEMO_WORKSPACE_TAG norm = raw.strip().upper() if not norm: return DEMO_WORKSPACE_TAG return hashlib.sha256(norm.encode("utf-8")).hexdigest() def workspace_cloud_row_ids(workspace_tag: str) -> Tuple[Optional[int], Optional[int]]: """Stabile Supabase todo_sync IDs für Autotext / Textblöcke dieser Lizenz. Verwendet einen freien Zahlenbereich, abgeleitet vom Tag — keine Nutzung der älteren globalen Fest-IDs 3/4 mehr (Vermischung verschiedener Lizenzen). Bei Demo ohne Lizenz: ``(None, None)`` → kein Cloud-Workspace. """ if not workspace_tag or workspace_tag == DEMO_WORKSPACE_TAG: return (None, None) digest = hashlib.sha256(workspace_tag.encode("utf-8")).digest() h = int.from_bytes(digest[:7], "big") slot = h % 3_500_000 base = 20_000_000 + slot * 2 return int(base), int(base + 1) def _entry_has_nonblank_text(entries: Dict[str, Any]) -> bool: if not isinstance(entries, dict): return False for _k, v in entries.items(): if isinstance(v, str) and v.strip(): return True if isinstance(v, dict) and str(v.get("text") or "").strip(): return True return False def local_workspace_has_meaningful_data() -> bool: """True, wenn lizenzrelevante lokale Daten sinnvoll befüllt sind.""" try: from aza_persistence import load_autotext, load_textbloecke at = load_autotext() if _entry_has_nonblank_text(at.get("entries") if isinstance(at, dict) else {}): return True except Exception: pass try: from aza_persistence import load_textbloecke tb = load_textbloecke() if isinstance(tb, dict): for k, v in tb.items(): if not isinstance(k, str) or not isinstance(v, dict): continue if str(v.get("content") or "").strip(): return True name_g = str(v.get("name") or "").strip() default_nm = f"Textblock {k}" if name_g and name_g != default_nm: return True except Exception: pass return False def _snapshot_dir() -> str: base = os.path.join(get_writable_data_dir(), _SNAPSHOT_SUBDIR) os.makedirs(base, exist_ok=True) return base def backup_local_workspace_before_discard() -> Optional[str]: """Schreibt JSON-Snapshot vor Verwerfen; Rückgabe Pfad oder None.""" try: from aza_persistence import load_autotext, load_textbloecke at = load_autotext() tb = load_textbloecke() blob = { "v": 1, "ts": time.time(), "stored_tag_before": load_workspace_license_state().get( "stored_workspace_identity_tag", DEMO_WORKSPACE_TAG), "resolved_tag_now": resolve_workspace_identity_tag(), "autotext_entries": dict((at.get("entries") or {})) if isinstance(at, dict) else {}, "autotext_entry_meta": dict((at.get("entry_meta") or {})) if isinstance(at.get("entry_meta"), dict) else {}, "textbloecke": dict(tb or {}), } fname = os.path.join( _snapshot_dir(), f"pre_discard_workspace_{int(time.time())}.json", ) with open(fname, "w", encoding="utf-8") as f: json.dump(blob, f, ensure_ascii=False, indent=2) return fname except Exception: return None def clear_local_license_workspace_disk_only() -> None: """Leert nur lizenzgebundenen Workspace auf der Platte; Praxis-/UI-Felder bleiben.""" try: from aza_persistence import load_autotext, save_autotext data = dict(load_autotext()) data["entries"] = {} data["entry_meta"] = {} data["workspace_backup_ts"] = "" save_autotext(data) except Exception: pass try: from aza_persistence import save_textbloecke empty_slots = { "1": { "name": "Textblock 1", "content": "", "updated_at": utc_now_iso(), }, "2": { "name": "Textblock 2", "content": "", "updated_at": utc_now_iso(), }, } save_textbloecke(empty_slots) except Exception: pass def _apply_cleared_workspace_to_app_runtime(app: Any) -> None: try: tgt = getattr(app, "_autotext_data", None) if isinstance(tgt, dict): entries = tgt.get("entries") if isinstance(entries, dict): entries.clear() else: tgt["entries"] = {} meta = tgt.get("entry_meta") if isinstance(meta, dict): meta.clear() else: tgt["entry_meta"] = {} tgt["workspace_backup_ts"] = "" from aza_persistence import save_autotext save_autotext(tgt) 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 def reconcile_stored_workspace_identity_without_dialog() -> bool: """Wenn Wechsel ohne relevante lokale Daten: ``stored`` anheben. Gibt ``True`` zurück, wenn Hybrid-Sync sofort ohne Dialog starten soll, ``False`` wenn weiterhin ein mismatch mit relevanten Daten besteht.""" cur = resolve_workspace_identity_tag() st = dict(load_workspace_license_state()) stored = str(st.get("stored_workspace_identity_tag", DEMO_WORKSPACE_TAG)) if cur == DEMO_WORKSPACE_TAG and stored != DEMO_WORKSPACE_TAG: st["stored_workspace_identity_tag"] = DEMO_WORKSPACE_TAG save_workspace_license_state(st) return True if cur == stored: return True if not local_workspace_has_meaningful_data(): st["stored_workspace_identity_tag"] = cur save_workspace_license_state(st) return True return False def defer_workspace_initial_hybrid_sync_for_license_dialog() -> bool: """True = vor erstem Hybrid-Sync den Import-Dialog zeigen.""" return not reconcile_stored_workspace_identity_without_dialog() def _persist_stored_tag(tag: str) -> None: st = dict(load_workspace_license_state()) st["stored_workspace_identity_tag"] = tag save_workspace_license_state(st) def _run_workspace_license_dialog_then_sync(app: Any) -> None: global _WS_LICENSE_DIALOG_ACTIVE try: if reconcile_stored_workspace_identity_without_dialog(): from aza_workspace_sync import start_background_workspace_hybrid_sync start_background_workspace_hybrid_sync(app) return cur = resolve_workspace_identity_tag() stored = str( load_workspace_license_state().get( "stored_workspace_identity_tag", DEMO_WORKSPACE_TAG)) if cur == stored: from aza_workspace_sync import start_background_workspace_hybrid_sync start_background_workspace_hybrid_sync(app) return if _WS_LICENSE_DIALOG_ACTIVE: try: app.after(800, lambda: ensure_workspace_license_dialog_then_start_hybrid_sync(app)) except Exception: pass return _WS_LICENSE_DIALOG_ACTIVE = True try: from tkinter import messagebox parent = getattr(app, "tk", None) or app txt = ( "Es wurden bestehende lokale AzA-Workspace-Daten gefunden " "(Autotext-Kürzel und Textblöcke).\n\n" "Das passt möglicherweise zu einer anderen oder älteren " "Lizenzzuordnung. Diese Daten sollen nicht still mit einer " "anderen Lizenz vermischt werden.\n\n" "**Ja** — Lokale Daten in den Workspace dieser aktuellen " "Lizenz übernehmen (mit Cloud-Zusammenführung sofern online).\n\n" "**Nein** — Lokale Autotexte/Textblöcke verwerfen; vorher wird " "ein Backup geschrieben. Anschließend gilt der Workspace der " "aktuellen Lizenz (ggf. leer oder aus der Cloud)." ) yn = messagebox.askyesno( "AzA – Workspace / Lizenz", txt.replace("**", ""), parent=parent, default=messagebox.YES, ) finally: _WS_LICENSE_DIALOG_ACTIVE = False if yn: _persist_stored_tag(cur) else: backup_local_workspace_before_discard() clear_local_license_workspace_disk_only() _apply_cleared_workspace_to_app_runtime(app) _persist_stored_tag(cur) from aza_workspace_sync import start_background_workspace_hybrid_sync start_background_workspace_hybrid_sync(app) except Exception: try: from aza_workspace_sync import start_background_workspace_hybrid_sync start_background_workspace_hybrid_sync(app) except Exception: pass def _run_deferred_workspace_license_flow(app: Any) -> None: try: setattr(app, "_workspace_license_defer_after_id", None) except Exception: pass _run_workspace_license_dialog_then_sync(app) def ensure_workspace_license_dialog_then_start_hybrid_sync(app: Any) -> None: """Startpunkt beim Office-Shell-Aufbau und nach Lizenzänderung.""" if defer_workspace_initial_hybrid_sync_for_license_dialog(): try: old = getattr(app, "_workspace_license_defer_after_id", None) if old is not None: try: app.after_cancel(old) except Exception: pass app._workspace_license_defer_after_id = app.after( 450, lambda: _run_deferred_workspace_license_flow(app)) except Exception: _run_deferred_workspace_license_flow(app) return try: from aza_workspace_sync import start_background_workspace_hybrid_sync start_background_workspace_hybrid_sync(app) except Exception: pass __all__ = [ "DEMO_WORKSPACE_TAG", "backup_local_workspace_before_discard", "clear_local_license_workspace_disk_only", "defer_workspace_initial_hybrid_sync_for_license_dialog", "ensure_workspace_license_dialog_then_start_hybrid_sync", "local_workspace_has_meaningful_data", "load_workspace_license_state", "reconcile_stored_workspace_identity_without_dialog", "resolve_license_material_raw", "resolve_workspace_identity_tag", "save_workspace_license_state", "workspace_cloud_row_ids", ]