406 lines
13 KiB
Python
406 lines
13 KiB
Python
# -*- 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",
|
||
]
|