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