784 lines
26 KiB
Python
784 lines
26 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Server- und Client-Sync für Textblöcke, Korrekturen und Autotext (pro practice_id).
|
|
|
|
Parallele Supabase-Workspace-Sync (aza_workspace_sync) bleibt unverändert.
|
|
Dieses Modul nutzt dieselbe API wie KI-Guthaben (stripe_webhook.sqlite + X-Practice-Id).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import shutil
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from fastapi import Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from aza_security import require_api_token
|
|
|
|
from aza_ai_budget import resolve_license_for_empfang
|
|
|
|
SYNC_ITEM_TYPES = ("textblock", "correction", "autotext")
|
|
|
|
_PUSH_DELAY_SEC = 0.8
|
|
_push_lock = threading.Lock()
|
|
_push_timers: Dict[str, threading.Timer] = {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Logging (keine Inhalte)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _client_debug_log(line: str) -> None:
|
|
try:
|
|
from aza_config import get_writable_data_dir
|
|
|
|
path = Path(get_writable_data_dir()) / "client_debug.log"
|
|
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
with open(path, "a", encoding="utf-8") as f:
|
|
f.write(f"{ts} {line}\n")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _log_sync(event: str, **fields: Any) -> None:
|
|
parts = [str(event)]
|
|
for k, v in fields.items():
|
|
kl = str(k).lower()
|
|
if any(x in kl for x in ("content", "text", "body", "trigger", "title", "token", "secret")):
|
|
continue
|
|
parts.append(f"{k}={v}")
|
|
_client_debug_log(" ".join(parts))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Server DB
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def ensure_sync_items_schema(con: sqlite3.Connection) -> None:
|
|
con.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS synced_user_items (
|
|
id TEXT PRIMARY KEY,
|
|
practice_id TEXT NOT NULL,
|
|
item_type TEXT NOT NULL,
|
|
title TEXT,
|
|
trigger_text TEXT,
|
|
content TEXT NOT NULL,
|
|
sort_order INTEGER DEFAULT 0,
|
|
is_active INTEGER DEFAULT 1,
|
|
created_at INTEGER,
|
|
updated_at INTEGER,
|
|
created_by TEXT,
|
|
updated_by TEXT,
|
|
source_device TEXT
|
|
)
|
|
"""
|
|
)
|
|
con.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_sync_items_practice_type "
|
|
"ON synced_user_items(practice_id, item_type, is_active, sort_order)"
|
|
)
|
|
con.commit()
|
|
|
|
|
|
def _now_ts() -> int:
|
|
return int(time.time())
|
|
|
|
|
|
def _resolve_practice_id_for_request(
|
|
con: sqlite3.Connection,
|
|
request: Request,
|
|
) -> str:
|
|
hdr = (request.headers.get("X-Practice-Id") or "").strip()
|
|
device_id = (request.headers.get("X-Device-Id") or "").strip() or None
|
|
lic = resolve_license_for_empfang(
|
|
con,
|
|
x_device_id=device_id,
|
|
session_practice_id=hdr or None,
|
|
)
|
|
if not lic:
|
|
raise HTTPException(status_code=403, detail="No license mapping for practice")
|
|
pid = (lic.practice_id or "").strip()
|
|
if not pid:
|
|
raise HTTPException(status_code=403, detail="practice_id missing on license")
|
|
if hdr and pid != hdr:
|
|
raise HTTPException(status_code=403, detail="practice_id mismatch")
|
|
return pid
|
|
|
|
|
|
def _row_to_item(row: tuple) -> Dict[str, Any]:
|
|
return {
|
|
"id": row[0],
|
|
"practice_id": row[1],
|
|
"item_type": row[2],
|
|
"title": row[3] or "",
|
|
"trigger": row[4] or "",
|
|
"content": row[5] or "",
|
|
"sort_order": int(row[6] or 0),
|
|
"is_active": bool(row[7]),
|
|
"created_at": int(row[8] or 0),
|
|
"updated_at": int(row[9] or 0),
|
|
"created_by": row[10] or "",
|
|
"updated_by": row[11] or "",
|
|
"source_device": row[12] or "",
|
|
}
|
|
|
|
|
|
def list_sync_items(
|
|
con: sqlite3.Connection,
|
|
practice_id: str,
|
|
item_type: str,
|
|
*,
|
|
active_only: bool = True,
|
|
) -> List[Dict[str, Any]]:
|
|
it = (item_type or "").strip().lower()
|
|
if it not in SYNC_ITEM_TYPES:
|
|
raise HTTPException(status_code=400, detail="invalid item_type")
|
|
q = """
|
|
SELECT id, practice_id, item_type, title, trigger_text, content,
|
|
sort_order, is_active, created_at, updated_at,
|
|
created_by, updated_by, source_device
|
|
FROM synced_user_items
|
|
WHERE practice_id = ? AND item_type = ?
|
|
"""
|
|
params: List[Any] = [practice_id, it]
|
|
if active_only:
|
|
q += " AND is_active = 1"
|
|
q += " ORDER BY sort_order ASC, updated_at DESC"
|
|
rows = con.execute(q, params).fetchall()
|
|
return [_row_to_item(r) for r in rows]
|
|
|
|
|
|
def upsert_sync_item(
|
|
con: sqlite3.Connection,
|
|
practice_id: str,
|
|
payload: Dict[str, Any],
|
|
*,
|
|
source_device: str = "",
|
|
) -> Dict[str, Any]:
|
|
it = str(payload.get("item_type") or "").strip().lower()
|
|
if it not in SYNC_ITEM_TYPES:
|
|
raise HTTPException(status_code=400, detail="invalid item_type")
|
|
item_id = str(payload.get("id") or "").strip() or str(uuid.uuid4())
|
|
now = _now_ts()
|
|
existing = con.execute(
|
|
"SELECT practice_id, created_at FROM synced_user_items WHERE id = ?",
|
|
(item_id,),
|
|
).fetchone()
|
|
if existing and str(existing[0]) != practice_id:
|
|
raise HTTPException(status_code=403, detail="item belongs to another practice")
|
|
created_at = int(existing[1]) if existing else now
|
|
title = str(payload.get("title") or "")[:500]
|
|
trigger = str(payload.get("trigger") or "")[:500]
|
|
content = str(payload.get("content") or "")
|
|
sort_order = int(payload.get("sort_order") or 0)
|
|
is_active = 1 if payload.get("is_active", True) else 0
|
|
con.execute(
|
|
"""
|
|
INSERT INTO synced_user_items (
|
|
id, practice_id, item_type, title, trigger_text, content,
|
|
sort_order, is_active, created_at, updated_at,
|
|
created_by, updated_by, source_device
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
practice_id=excluded.practice_id,
|
|
item_type=excluded.item_type,
|
|
title=excluded.title,
|
|
trigger_text=excluded.trigger_text,
|
|
content=excluded.content,
|
|
sort_order=excluded.sort_order,
|
|
is_active=excluded.is_active,
|
|
updated_at=excluded.updated_at,
|
|
updated_by=excluded.updated_by,
|
|
source_device=excluded.source_device
|
|
""",
|
|
(
|
|
item_id,
|
|
practice_id,
|
|
it,
|
|
title,
|
|
trigger,
|
|
content,
|
|
sort_order,
|
|
is_active,
|
|
created_at,
|
|
now,
|
|
str(payload.get("created_by") or "")[:120],
|
|
str(payload.get("updated_by") or "")[:120],
|
|
(source_device or "")[:64],
|
|
),
|
|
)
|
|
con.commit()
|
|
row = con.execute(
|
|
"""
|
|
SELECT id, practice_id, item_type, title, trigger_text, content,
|
|
sort_order, is_active, created_at, updated_at,
|
|
created_by, updated_by, source_device
|
|
FROM synced_user_items WHERE id = ?
|
|
""",
|
|
(item_id,),
|
|
).fetchone()
|
|
return _row_to_item(row) if row else {"id": item_id}
|
|
|
|
|
|
def soft_delete_sync_item(
|
|
con: sqlite3.Connection,
|
|
practice_id: str,
|
|
item_id: str,
|
|
) -> bool:
|
|
cur = con.execute(
|
|
"""
|
|
UPDATE synced_user_items
|
|
SET is_active = 0, updated_at = ?
|
|
WHERE id = ? AND practice_id = ?
|
|
""",
|
|
(_now_ts(), item_id, practice_id),
|
|
)
|
|
con.commit()
|
|
return cur.rowcount > 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API models + routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class SyncItemIn(BaseModel):
|
|
id: Optional[str] = None
|
|
item_type: str = Field(..., min_length=1)
|
|
title: Optional[str] = ""
|
|
trigger: Optional[str] = ""
|
|
content: str = ""
|
|
sort_order: int = 0
|
|
is_active: bool = True
|
|
created_by: Optional[str] = ""
|
|
updated_by: Optional[str] = ""
|
|
|
|
|
|
class SyncItemBulkIn(BaseModel):
|
|
items: List[SyncItemIn] = Field(default_factory=list)
|
|
|
|
|
|
def register_sync_items_routes(app, *, stripe_db_path_fn) -> None:
|
|
"""Registriert /v1/sync/items* auf der FastAPI-App."""
|
|
|
|
@app.get("/v1/sync/items", dependencies=[Depends(require_api_token)])
|
|
def sync_items_list(
|
|
request: Request,
|
|
type: str = Query(..., alias="type"),
|
|
):
|
|
db_path = stripe_db_path_fn()
|
|
if not db_path.exists():
|
|
raise HTTPException(status_code=503, detail="database missing")
|
|
with sqlite3.connect(db_path) as con:
|
|
ensure_sync_items_schema(con)
|
|
pid = _resolve_practice_id_for_request(con, request)
|
|
items = list_sync_items(con, pid, type)
|
|
return {"ok": True, "practice_id": pid, "item_type": type, "items": items, "count": len(items)}
|
|
|
|
@app.post("/v1/sync/items", dependencies=[Depends(require_api_token)])
|
|
def sync_items_upsert(request: Request, body: SyncItemIn):
|
|
db_path = stripe_db_path_fn()
|
|
if not db_path.exists():
|
|
raise HTTPException(status_code=503, detail="database missing")
|
|
dev = (request.headers.get("X-Device-Id") or "").strip()
|
|
with sqlite3.connect(db_path) as con:
|
|
ensure_sync_items_schema(con)
|
|
pid = _resolve_practice_id_for_request(con, request)
|
|
item = upsert_sync_item(
|
|
con,
|
|
pid,
|
|
body.model_dump(),
|
|
source_device=dev,
|
|
)
|
|
return {"ok": True, "item": item}
|
|
|
|
@app.post("/v1/sync/items/bulk", dependencies=[Depends(require_api_token)])
|
|
def sync_items_bulk(request: Request, body: SyncItemBulkIn):
|
|
db_path = stripe_db_path_fn()
|
|
if not db_path.exists():
|
|
raise HTTPException(status_code=503, detail="database missing")
|
|
dev = (request.headers.get("X-Device-Id") or "").strip()
|
|
saved = []
|
|
with sqlite3.connect(db_path) as con:
|
|
ensure_sync_items_schema(con)
|
|
pid = _resolve_practice_id_for_request(con, request)
|
|
for raw in body.items:
|
|
saved.append(
|
|
upsert_sync_item(
|
|
con,
|
|
pid,
|
|
raw.model_dump(),
|
|
source_device=dev,
|
|
)
|
|
)
|
|
return {"ok": True, "count": len(saved)}
|
|
|
|
@app.put("/v1/sync/items/{item_id}", dependencies=[Depends(require_api_token)])
|
|
def sync_items_put(item_id: str, request: Request, body: SyncItemIn):
|
|
db_path = stripe_db_path_fn()
|
|
payload = body.model_dump()
|
|
payload["id"] = item_id
|
|
dev = (request.headers.get("X-Device-Id") or "").strip()
|
|
with sqlite3.connect(db_path) as con:
|
|
ensure_sync_items_schema(con)
|
|
pid = _resolve_practice_id_for_request(con, request)
|
|
item = upsert_sync_item(con, pid, payload, source_device=dev)
|
|
return {"ok": True, "item": item}
|
|
|
|
@app.delete("/v1/sync/items/{item_id}", dependencies=[Depends(require_api_token)])
|
|
def sync_items_delete(item_id: str, request: Request):
|
|
db_path = stripe_db_path_fn()
|
|
with sqlite3.connect(db_path) as con:
|
|
ensure_sync_items_schema(con)
|
|
pid = _resolve_practice_id_for_request(con, request)
|
|
ok = soft_delete_sync_item(con, pid, item_id)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="not found")
|
|
return {"ok": True, "id": item_id}
|
|
|
|
@app.get("/v1/textblocks", dependencies=[Depends(require_api_token)])
|
|
def textblocks_list(request: Request):
|
|
return sync_items_list(request, type="textblock")
|
|
|
|
@app.post("/v1/textblocks", dependencies=[Depends(require_api_token)])
|
|
def textblocks_post(request: Request, body: SyncItemIn):
|
|
body.item_type = "textblock"
|
|
return sync_items_upsert(request, body)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client: local <-> server mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stable_id(prefix: str, *parts: str) -> str:
|
|
raw = "|".join([prefix] + [str(p) for p in parts])
|
|
return f"{prefix}_{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:24]}"
|
|
|
|
|
|
def local_textblocks_to_items(tb: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
items = []
|
|
for sk in sorted(tb.keys(), key=lambda x: int(str(x)) if str(x).isdigit() else 0):
|
|
if not str(sk).isdigit():
|
|
continue
|
|
slot = tb[sk]
|
|
if not isinstance(slot, dict):
|
|
continue
|
|
items.append(
|
|
{
|
|
"id": _stable_id("tb", sk),
|
|
"item_type": "textblock",
|
|
"title": (slot.get("name") or "").strip() or f"Textblock {sk}",
|
|
"trigger": "",
|
|
"content": str(slot.get("content") or ""),
|
|
"sort_order": int(sk),
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def items_to_local_textblocks(items: List[Dict[str, Any]]) -> Dict[str, Dict[str, str]]:
|
|
out: Dict[str, Dict[str, str]] = {}
|
|
for it in sorted(items, key=lambda x: int(x.get("sort_order") or 0)):
|
|
sk = str(int(it.get("sort_order") or len(out) + 1))
|
|
out[sk] = {
|
|
"name": (it.get("title") or "").strip() or f"Textblock {sk}",
|
|
"content": str(it.get("content") or ""),
|
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
if len(out) < 2:
|
|
out.setdefault("1", {"name": "Textblock 1", "content": "", "updated_at": ""})
|
|
out.setdefault("2", {"name": "Textblock 2", "content": "", "updated_at": ""})
|
|
return out
|
|
|
|
|
|
def local_korrekturen_to_items(k: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
items = []
|
|
order = 0
|
|
for cat, mapping in (k or {}).items():
|
|
if not isinstance(mapping, dict):
|
|
continue
|
|
for falsch, richtig in mapping.items():
|
|
order += 1
|
|
items.append(
|
|
{
|
|
"id": _stable_id("corr", cat, falsch),
|
|
"item_type": "correction",
|
|
"title": str(cat),
|
|
"trigger": str(falsch),
|
|
"content": str(richtig),
|
|
"sort_order": order,
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def items_to_local_korrekturen(items: List[Dict[str, Any]]) -> Dict[str, Dict[str, str]]:
|
|
out: Dict[str, Dict[str, str]] = {}
|
|
for it in items:
|
|
cat = (it.get("title") or "allgemein").strip() or "allgemein"
|
|
falsch = str(it.get("trigger") or "")
|
|
richtig = str(it.get("content") or "")
|
|
if not falsch:
|
|
continue
|
|
out.setdefault(cat, {})[falsch] = richtig
|
|
if not out:
|
|
try:
|
|
from aza_config import _DEFAULT_KORREKTUREN
|
|
|
|
return {c: dict(m) for c, m in _DEFAULT_KORREKTUREN.items()}
|
|
except Exception:
|
|
return {}
|
|
return out
|
|
|
|
|
|
def local_autotext_to_items(at: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
entries = at.get("entries") if isinstance(at.get("entries"), dict) else {}
|
|
items = []
|
|
order = 0
|
|
for abbr, text in entries.items():
|
|
if not isinstance(abbr, str) or not abbr.strip():
|
|
continue
|
|
order += 1
|
|
val = text if isinstance(text, str) else str(text)
|
|
items.append(
|
|
{
|
|
"id": _stable_id("at", abbr.strip()),
|
|
"item_type": "autotext",
|
|
"title": abbr.strip(),
|
|
"trigger": abbr.strip(),
|
|
"content": val,
|
|
"sort_order": order,
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def items_to_local_autotext_entries(items: List[Dict[str, Any]]) -> Dict[str, str]:
|
|
out: Dict[str, str] = {}
|
|
for it in items:
|
|
tr = (it.get("trigger") or it.get("title") or "").strip()
|
|
if tr:
|
|
out[tr] = str(it.get("content") or "")
|
|
return out
|
|
|
|
|
|
def _backup_local_sync_files() -> None:
|
|
try:
|
|
from aza_config import (
|
|
AUTOTEXT_CONFIG_FILENAME,
|
|
KORREKTUREN_CONFIG_FILENAME,
|
|
TEXTBLOECKE_CONFIG_FILENAME,
|
|
get_writable_data_dir,
|
|
)
|
|
|
|
base = Path(get_writable_data_dir())
|
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
dest = base / f".aza_sync_local_backup_{stamp}"
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
for name in (
|
|
TEXTBLOECKE_CONFIG_FILENAME,
|
|
KORREKTUREN_CONFIG_FILENAME,
|
|
AUTOTEXT_CONFIG_FILENAME,
|
|
):
|
|
src = base / name
|
|
if src.is_file():
|
|
shutil.copy2(src, dest / name)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _http_get_json(url: str, headers: Dict[str, str], timeout: Tuple[float, float]) -> Tuple[int, Any]:
|
|
import requests
|
|
|
|
r = requests.get(url, headers=headers, timeout=timeout)
|
|
try:
|
|
return r.status_code, r.json()
|
|
except Exception:
|
|
return r.status_code, {}
|
|
|
|
|
|
def _http_post_json(url: str, headers: Dict[str, str], body: Any, timeout: Tuple[float, float]) -> Tuple[int, Any]:
|
|
import requests
|
|
|
|
r = requests.post(url, headers=headers, json=body, timeout=timeout)
|
|
try:
|
|
return r.status_code, r.json()
|
|
except Exception:
|
|
return r.status_code, {}
|
|
|
|
|
|
def _sync_headers(app) -> Optional[Dict[str, str]]:
|
|
try:
|
|
bu = app.get_backend_url()
|
|
tok = app.get_backend_token()
|
|
except Exception:
|
|
return None
|
|
practice_id = ""
|
|
try:
|
|
practice_id = (app.get_practice_id() or "").strip()
|
|
except Exception:
|
|
pass
|
|
if not bu or not tok or not practice_id:
|
|
return None
|
|
try:
|
|
from basis14 import _get_or_create_device_id
|
|
except Exception:
|
|
_get_or_create_device_id = lambda: "" # type: ignore
|
|
headers = {"X-API-Token": tok, "X-Practice-Id": practice_id}
|
|
did = _get_or_create_device_id()
|
|
if did:
|
|
headers["X-Device-Id"] = did
|
|
return headers
|
|
|
|
|
|
def _fetch_server_items(app, item_type: str) -> Optional[List[Dict[str, Any]]]:
|
|
headers = _sync_headers(app)
|
|
if not headers:
|
|
return None
|
|
try:
|
|
bu = app.get_backend_url().rstrip("/")
|
|
except Exception:
|
|
return None
|
|
url = f"{bu}/v1/sync/items?type={item_type}"
|
|
code, data = _http_get_json(url, headers, (3, 12))
|
|
if code != 200 or not isinstance(data, dict) or not data.get("ok"):
|
|
return None
|
|
items = data.get("items")
|
|
return items if isinstance(items, list) else []
|
|
|
|
|
|
def _upload_items_bulk(app, items: List[Dict[str, Any]]) -> bool:
|
|
if not items:
|
|
return True
|
|
headers = _sync_headers(app)
|
|
if not headers:
|
|
return False
|
|
try:
|
|
bu = app.get_backend_url().rstrip("/")
|
|
except Exception:
|
|
return False
|
|
url = f"{bu}/v1/sync/items/bulk"
|
|
code, data = _http_post_json(url, headers, {"items": items}, (3, 20))
|
|
return code == 200 and isinstance(data, dict) and data.get("ok")
|
|
|
|
|
|
def _apply_server_items_to_local(app, item_type: str, items: List[Dict[str, Any]]) -> None:
|
|
from aza_persistence import (
|
|
load_autotext,
|
|
load_korrekturen,
|
|
load_textbloecke,
|
|
save_autotext,
|
|
save_korrekturen,
|
|
save_textbloecke,
|
|
)
|
|
|
|
if item_type == "textblock":
|
|
merged = items_to_local_textblocks(items)
|
|
save_textbloecke(merged)
|
|
shell = getattr(app, "_aza_office_v1", None)
|
|
if shell is not None and hasattr(shell, "refresh_sidebar_textbloecke_section"):
|
|
try:
|
|
app.after(0, shell.refresh_sidebar_textbloecke_section)
|
|
except Exception:
|
|
shell.refresh_sidebar_textbloecke_section()
|
|
if hasattr(app, "_textbloecke_data"):
|
|
app._textbloecke_data = merged
|
|
return
|
|
|
|
if item_type == "correction":
|
|
merged = items_to_local_korrekturen(items)
|
|
save_korrekturen(merged)
|
|
return
|
|
|
|
if item_type == "autotext":
|
|
at = load_autotext()
|
|
entries = items_to_local_autotext_entries(items)
|
|
if isinstance(at.get("entries"), dict):
|
|
at["entries"].clear()
|
|
at["entries"].update(entries)
|
|
else:
|
|
at["entries"] = dict(entries)
|
|
save_autotext(at)
|
|
tgt = getattr(app, "_autotext_data", None)
|
|
if isinstance(tgt, dict):
|
|
te = tgt.get("entries")
|
|
if isinstance(te, dict):
|
|
te.clear()
|
|
te.update(entries)
|
|
else:
|
|
tgt["entries"] = dict(entries)
|
|
|
|
|
|
def _local_items_for_type(item_type: str) -> List[Dict[str, Any]]:
|
|
from aza_persistence import load_autotext, load_korrekturen, load_textbloecke
|
|
|
|
if item_type == "textblock":
|
|
return local_textblocks_to_items(load_textbloecke())
|
|
if item_type == "correction":
|
|
return local_korrekturen_to_items(load_korrekturen())
|
|
if item_type == "autotext":
|
|
return local_autotext_to_items(load_autotext())
|
|
return []
|
|
|
|
|
|
def _local_has_meaningful_data(item_type: str) -> bool:
|
|
items = _local_items_for_type(item_type)
|
|
if item_type == "textblock":
|
|
for it in items:
|
|
if (it.get("content") or "").strip() or (
|
|
(it.get("title") or "").strip()
|
|
and it.get("title") != f"Textblock {it.get('sort_order')}"
|
|
):
|
|
return True
|
|
return False
|
|
if item_type == "correction":
|
|
return len(items) > 0
|
|
if item_type == "autotext":
|
|
return len(items) > 0
|
|
return False
|
|
|
|
|
|
def sync_practice_items_for_type(app, item_type: str) -> None:
|
|
_log_sync("SYNC_ITEMS_START", type=item_type)
|
|
server_items = _fetch_server_items(app, item_type)
|
|
if server_items is None:
|
|
_log_sync("SYNC_ITEMS_FALLBACK_LOCAL", type=item_type, reason="server_unreachable")
|
|
return
|
|
if server_items:
|
|
_backup_local_sync_files()
|
|
_apply_server_items_to_local(app, item_type, server_items)
|
|
_log_sync("SYNC_ITEMS_OK", type=item_type, count=len(server_items), strategy="server_wins")
|
|
return
|
|
if _local_has_meaningful_data(item_type):
|
|
local_items = _local_items_for_type(item_type)
|
|
if _upload_items_bulk(app, local_items):
|
|
_log_sync("SYNC_ITEMS_UPLOAD_LOCAL", type=item_type, count=len(local_items))
|
|
else:
|
|
_log_sync("SYNC_ITEMS_FALLBACK_LOCAL", type=item_type, reason="upload_failed")
|
|
return
|
|
_log_sync("SYNC_ITEMS_OK", type=item_type, count=0, strategy="empty")
|
|
|
|
|
|
def _sync_worker(app) -> None:
|
|
time.sleep(2.0)
|
|
try:
|
|
from basis14 import _has_remote_backend
|
|
except Exception:
|
|
def _has_remote_backend() -> bool: # type: ignore
|
|
return False
|
|
if not _has_remote_backend():
|
|
return
|
|
if not (app.get_practice_id() or "").strip():
|
|
_log_sync("SYNC_ITEMS_FALLBACK_LOCAL", type="all", reason="no_practice_id")
|
|
return
|
|
for it in SYNC_ITEM_TYPES:
|
|
try:
|
|
sync_practice_items_for_type(app, it)
|
|
except Exception as exc:
|
|
_log_sync(
|
|
"SYNC_ITEMS_FALLBACK_LOCAL",
|
|
type=it,
|
|
reason=exc.__class__.__name__,
|
|
)
|
|
|
|
|
|
def start_background_practice_items_sync(app) -> None:
|
|
try:
|
|
threading.Thread(target=_sync_worker, args=(app,), daemon=True).start()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def schedule_practice_items_push(item_type: str) -> None:
|
|
key = (item_type or "").strip().lower()
|
|
if key not in SYNC_ITEM_TYPES:
|
|
return
|
|
|
|
def push() -> None:
|
|
try:
|
|
import basis14 as b14
|
|
|
|
app = None
|
|
for w in list(getattr(b14, "_window_registry", []) or []):
|
|
if hasattr(w, "get_practice_id"):
|
|
app = w
|
|
break
|
|
if app is None:
|
|
return
|
|
items = _local_items_for_type(key)
|
|
if items and _upload_items_bulk(app, items):
|
|
_log_sync("SYNC_ITEMS_PUSH_OK", type=key, count=len(items))
|
|
except Exception:
|
|
pass
|
|
|
|
def arm() -> None:
|
|
with _push_lock:
|
|
old = _push_timers.get(key)
|
|
if old is not None:
|
|
try:
|
|
old.cancel()
|
|
except Exception:
|
|
pass
|
|
t = threading.Timer(
|
|
_PUSH_DELAY_SEC,
|
|
lambda: threading.Thread(target=push, daemon=True).start(),
|
|
)
|
|
t.daemon = True
|
|
_push_timers[key] = t
|
|
t.start()
|
|
|
|
arm()
|
|
|
|
|
|
def install_persistence_sync_hooks() -> None:
|
|
"""Nach lokalem Speichern debounced Upload (ohne Autotext-Engine zu ändern)."""
|
|
try:
|
|
import aza_persistence as p
|
|
|
|
if getattr(p, "_aza_practice_sync_hooks", False):
|
|
return
|
|
orig_tb = p.save_textbloecke
|
|
orig_k = p.save_korrekturen
|
|
orig_at = p.save_autotext
|
|
|
|
def save_textbloecke_wrapped(data, _o=orig_tb):
|
|
_o(data)
|
|
schedule_practice_items_push("textblock")
|
|
|
|
def save_korrekturen_wrapped(data, _o=orig_k):
|
|
_o(data)
|
|
schedule_practice_items_push("correction")
|
|
|
|
def save_autotext_wrapped(data, _o=orig_at):
|
|
_o(data)
|
|
schedule_practice_items_push("autotext")
|
|
|
|
p.save_textbloecke = save_textbloecke_wrapped # type: ignore
|
|
p.save_korrekturen = save_korrekturen_wrapped # type: ignore
|
|
p.save_autotext = save_autotext_wrapped # type: ignore
|
|
p._aza_practice_sync_hooks = True
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
__all__ = [
|
|
"ensure_sync_items_schema",
|
|
"register_sync_items_routes",
|
|
"start_background_practice_items_sync",
|
|
"schedule_practice_items_push",
|
|
"install_persistence_sync_hooks",
|
|
"SYNC_ITEM_TYPES",
|
|
]
|