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",
|
|||
|
|
]
|