14565 lines
620 KiB
Plaintext
14565 lines
620 KiB
Plaintext
# -*- coding: utf-8 -*-
|
||
"""
|
||
KG-Diktat Desktop (Aufnahme -> Transkription -> Krankengeschichte)
|
||
===============================================================
|
||
Modulare Architektur Hauptdatei mit Imports aus:
|
||
aza_config, aza_prompts, aza_ui_helpers, aza_persistence, aza_audio,
|
||
aza_todo_mixin, aza_text_windows_mixin, aza_diktat_mixin,
|
||
aza_settings_mixin, aza_ordner_mixin, aza_arbeitsplan_mixin
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import json
|
||
import sys
|
||
import time
|
||
import uuid
|
||
import webbrowser
|
||
import subprocess
|
||
import traceback
|
||
import hashlib
|
||
import unicodedata
|
||
import platform
|
||
import bcrypt
|
||
import pyotp
|
||
import qrcode
|
||
from PIL import ImageTk
|
||
from aza_totp import (
|
||
is_2fa_enabled, is_2fa_required, generate_totp_secret,
|
||
generate_backup_codes, hash_backup_code, get_provisioning_uri,
|
||
verify_totp, verify_backup_code, encrypt_secret, decrypt_secret,
|
||
is_rate_limited,
|
||
)
|
||
from aza_consent import (
|
||
has_valid_consent, record_consent, record_revoke,
|
||
get_consent_status, export_consent_log,
|
||
)
|
||
from aza_audit_log import log_event
|
||
from aza_activation import check_app_access, validate_key, save_activation_key, load_activation_key
|
||
from security_vault import store_api_key, has_vault_key, get_masked_key
|
||
from difflib import SequenceMatcher
|
||
import threading
|
||
import tempfile
|
||
import wave
|
||
from datetime import date, datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Optional, Tuple
|
||
from urllib.parse import urlparse
|
||
import tkinter as tk
|
||
import tkinter.font as tkfont
|
||
from tkinter import ttk, messagebox, simpledialog
|
||
from tkinter.scrolledtext import ScrolledText
|
||
|
||
from dotenv import load_dotenv
|
||
from openai import OpenAI
|
||
import requests
|
||
|
||
# Windows: für Einfügen in externes Fenster (Word, Notepad etc.)
|
||
import ctypes
|
||
from ctypes import wintypes
|
||
if sys.platform == "win32":
|
||
try:
|
||
_user32 = ctypes.windll.user32
|
||
except Exception:
|
||
_user32 = None
|
||
else:
|
||
_user32 = None
|
||
|
||
# Globaler Autotext in Windows (Word, Editor, überall): pynput für Tastatur-Hook
|
||
try:
|
||
from pynput.keyboard import Controller as KbdController, Key, KeyCode, Listener as KbdListener
|
||
_HAS_PYNPUT = True
|
||
except ImportError:
|
||
_HAS_PYNPUT = False
|
||
|
||
try:
|
||
from pynput.mouse import Button as MouseButton, Listener as MouseListener
|
||
_HAS_PYNPUT_MOUSE = True
|
||
except ImportError:
|
||
_HAS_PYNPUT_MOUSE = False
|
||
|
||
# Modul-Imports
|
||
from aza_config import *
|
||
from aza_config import (_ALL_WINDOWS, _SOAP_SECTIONS, _SOAP_LABELS,
|
||
NUM_SOAP_PRESETS, NUM_BRIEF_PRESETS)
|
||
from aza_prompts import *
|
||
from aza_persistence import *
|
||
from aza_persistence import _win_clipboard_set, _win_clipboard_get
|
||
from aza_ui_helpers import *
|
||
from aza_audio import AudioRecorder, split_audio_into_chunks, persist_audio_safe, get_audio_backup_dir, cleanup_old_audio_backups, check_microphone, invalidate_mic_cache
|
||
from aza_todo_mixin import TodoMixin
|
||
from aza_text_windows_mixin import TextWindowsMixin
|
||
from aza_diktat_mixin import AzaDiktatMixin
|
||
from aza_settings_mixin import AzaSettingsMixin
|
||
from aza_med_validator import validate_medication_name, suggest_medication_candidate, get_medication_facts
|
||
from aza_ordner_mixin import AzaOrdnerMixin
|
||
from aza_arbeitsplan_mixin import AzaArbeitsplanMixin
|
||
from aza_notizen_mixin import AzaNotizenMixin
|
||
from desktop_backend_autostart import start_backend, get_backend_error
|
||
from desktop_update_check import prompt_update_if_available
|
||
from openai_runtime_config import (
|
||
get_openai_api_key,
|
||
has_openai_api_key,
|
||
ensure_runtime_config_template_exists,
|
||
get_runtime_env_file_path,
|
||
open_runtime_config_in_editor,
|
||
)
|
||
from aza_launcher import AzaLauncher, should_skip_launcher
|
||
from aza_office_shell_v1 import apply_office_shell_v1
|
||
|
||
|
||
|
||
def resolve_license_url() -> str:
|
||
env_url = (os.getenv("AZA_LICENSE_URL") or "").strip()
|
||
if env_url:
|
||
return env_url
|
||
|
||
try:
|
||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "license_url.txt")
|
||
with open(path, "r", encoding="utf-8-sig") as f:
|
||
first_line = (f.readline() or "").strip()
|
||
if first_line:
|
||
return first_line
|
||
except Exception:
|
||
pass
|
||
|
||
return "http://127.0.0.1:9000"
|
||
|
||
|
||
AZA_LICENSE_URL = resolve_license_url()
|
||
LICENSE_TOKEN_FILE = "license_token.txt"
|
||
DEMO_USAGE_FILE = "demo_usage.json"
|
||
DEMO_MAX_DICTATIONS = 3
|
||
|
||
|
||
def _empfang_identity_key(label: str) -> str:
|
||
"""Gleicher Namens-Vergleich wie Backend empfang_routes._norm_name (Konversation/Verlauf)."""
|
||
t = (label or "").strip().lower()
|
||
t = unicodedata.normalize("NFKD", t)
|
||
return "".join(ch for ch in t if unicodedata.combining(ch) == "")
|
||
|
||
|
||
def load_license_token():
|
||
try:
|
||
path = os.path.join(get_writable_data_dir(), LICENSE_TOKEN_FILE)
|
||
with open(path, "r", encoding="utf-8-sig") as f:
|
||
token = (f.read() or "").strip()
|
||
return token if token else None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def save_license_token(token: str):
|
||
path = os.path.join(get_writable_data_dir(), LICENSE_TOKEN_FILE)
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write((token or "").strip())
|
||
|
||
|
||
def _license_cache_path() -> Path:
|
||
return Path(get_writable_data_dir()) / "license_status_cache.json"
|
||
|
||
|
||
def _load_license_cache() -> dict | None:
|
||
p = _license_cache_path()
|
||
if not p.exists():
|
||
return None
|
||
try:
|
||
data = json.loads(p.read_text(encoding="utf-8"))
|
||
if not isinstance(data, dict):
|
||
return None
|
||
return data
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _save_license_cache(payload: dict) -> None:
|
||
p = _license_cache_path()
|
||
try:
|
||
p.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||
except Exception:
|
||
# Cache failure must never break app
|
||
pass
|
||
|
||
|
||
def _cache_is_fresh(cache: dict, max_age_seconds: int = 24 * 60 * 60) -> bool:
|
||
ts = cache.get("cached_at")
|
||
if not isinstance(ts, (int, float)):
|
||
return False
|
||
return (time.time() - float(ts)) <= max_age_seconds
|
||
|
||
|
||
def _cache_license_valid(cache: dict) -> bool:
|
||
# Cache payload expects: {"valid": bool, "valid_until": int|None, "cached_at": epoch}
|
||
if cache.get("valid") is not True:
|
||
return False
|
||
vu = cache.get("valid_until")
|
||
if not isinstance(vu, (int, float)):
|
||
return False
|
||
return int(vu) > int(time.time())
|
||
|
||
|
||
def _format_valid_until_ts(ts: Optional[int]) -> str:
|
||
if not isinstance(ts, (int, float)):
|
||
return "n/a"
|
||
try:
|
||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(ts)))
|
||
except Exception:
|
||
return str(ts)
|
||
|
||
|
||
def _device_id_path() -> Path:
|
||
return Path(get_writable_data_dir()) / "device_id.txt"
|
||
|
||
|
||
_REG_KEY = r"Software\AZA Desktop"
|
||
_REG_VAL = "DeviceId"
|
||
|
||
|
||
def _reg_read_device_id() -> str:
|
||
try:
|
||
import winreg
|
||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _REG_KEY) as k:
|
||
val, _ = winreg.QueryValueEx(k, _REG_VAL)
|
||
return str(val).strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _reg_write_device_id(device_id: str):
|
||
try:
|
||
import winreg
|
||
k = winreg.CreateKey(winreg.HKEY_CURRENT_USER, _REG_KEY)
|
||
winreg.SetValueEx(k, _REG_VAL, 0, winreg.REG_SZ, device_id)
|
||
winreg.CloseKey(k)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _get_hardware_fingerprint() -> str:
|
||
"""Deterministic hardware fingerprint that survives any reinstall.
|
||
|
||
Uses BIOS UUID + disk serial. Tries wmic first, then PowerShell
|
||
(wmic is deprecated on Windows 11 22H2+).
|
||
"""
|
||
parts = []
|
||
_CNW = 0x08000000
|
||
|
||
bios = ""
|
||
for cmd in (
|
||
["wmic", "csproduct", "get", "uuid"],
|
||
["powershell", "-NoProfile", "-Command",
|
||
"(Get-CimInstance Win32_ComputerSystemProduct).UUID"],
|
||
):
|
||
if bios:
|
||
break
|
||
try:
|
||
r = subprocess.run(cmd, capture_output=True, text=True,
|
||
timeout=8, creationflags=_CNW)
|
||
for ln in r.stdout.strip().splitlines():
|
||
ln = ln.strip()
|
||
if ln and ln.upper() != "UUID" and len(ln) > 8:
|
||
bios = ln.upper()
|
||
break
|
||
except Exception:
|
||
pass
|
||
if bios:
|
||
parts.append(f"bios:{bios}")
|
||
|
||
disk = ""
|
||
for cmd in (
|
||
["wmic", "diskdrive", "get", "serialnumber"],
|
||
["powershell", "-NoProfile", "-Command",
|
||
"(Get-Disk | Select-Object -First 1).SerialNumber"],
|
||
):
|
||
if disk:
|
||
break
|
||
try:
|
||
r = subprocess.run(cmd, capture_output=True, text=True,
|
||
timeout=8, creationflags=_CNW)
|
||
for ln in r.stdout.strip().splitlines():
|
||
ln = ln.strip()
|
||
if ln and ln.lower() not in ("serialnumber", "") and len(ln) > 3:
|
||
disk = ln.strip()
|
||
break
|
||
except Exception:
|
||
pass
|
||
if disk:
|
||
parts.append(f"disk:{disk}")
|
||
|
||
parts.append(f"arch:{platform.machine()}")
|
||
if len(parts) >= 2:
|
||
raw = "|".join(sorted(parts))
|
||
fp = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
|
||
print(f"[DEVICE] fingerprint OK (parts={len(parts)})")
|
||
return fp
|
||
print(f"[DEVICE] fingerprint INCOMPLETE (parts={len(parts)})")
|
||
return ""
|
||
|
||
|
||
def _get_or_create_device_id() -> str:
|
||
"""Stable device id that survives reinstalls.
|
||
|
||
Lookup: file -> registry -> hardware fingerprint -> random fallback.
|
||
Stores in all locations for redundancy.
|
||
"""
|
||
p = _device_id_path()
|
||
|
||
if p.exists():
|
||
try:
|
||
v = p.read_text(encoding="utf-8").strip()
|
||
if v:
|
||
_reg_write_device_id(v)
|
||
print(f"[DEVICE] id from FILE: {v[:20]}...")
|
||
return v
|
||
except Exception:
|
||
pass
|
||
|
||
v = _reg_read_device_id()
|
||
if v:
|
||
print(f"[DEVICE] id from REGISTRY: {v[:20]}...")
|
||
try:
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
p.write_text(v, encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
return v
|
||
|
||
hw = _get_hardware_fingerprint()
|
||
if hw:
|
||
v = f"aza-hw-{hw}"
|
||
print(f"[DEVICE] id from HARDWARE fingerprint: {v[:20]}...")
|
||
else:
|
||
v = f"aza-{platform.system()}-{platform.machine()}-{uuid.uuid4()}"
|
||
print(f"[DEVICE] id from RANDOM fallback: {v[:20]}...")
|
||
|
||
try:
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
p.write_text(v, encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
_reg_write_device_id(v)
|
||
return v
|
||
|
||
|
||
def open_billing_portal(backend_url: str, api_token: str) -> bool:
|
||
"""
|
||
Fetches a Stripe Billing Portal URL from backend and opens it in the default browser.
|
||
Returns True on success, False otherwise.
|
||
"""
|
||
if not backend_url or not api_token:
|
||
print("[BILLING] missing backend_url or api_token")
|
||
return False
|
||
|
||
url = backend_url.rstrip("/") + "/stripe/billing_portal_url"
|
||
|
||
# Add required headers
|
||
headers = {"X-API-Token": api_token}
|
||
|
||
# If you have device id helper, include it (safe)
|
||
try:
|
||
device_id = _get_or_create_device_id()
|
||
headers["X-Device-Id"] = device_id
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
resp = requests.get(url, headers=headers, timeout=10)
|
||
if resp.status_code in (401, 403):
|
||
print("[BILLING] unauthorized")
|
||
return False
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
portal_url = data.get("url")
|
||
if not portal_url:
|
||
print("[BILLING] no url in response")
|
||
return False
|
||
webbrowser.open(portal_url)
|
||
print("[BILLING] opened portal in browser")
|
||
return True
|
||
except Exception:
|
||
print("[BILLING] failed to open portal")
|
||
return False
|
||
|
||
|
||
class KGDesktopApp(tk.Tk, TodoMixin, TextWindowsMixin, AzaDiktatMixin, AzaSettingsMixin, AzaOrdnerMixin, AzaArbeitsplanMixin, AzaNotizenMixin):
|
||
def __init__(self, start_module: str = "kg"):
|
||
super().__init__()
|
||
self._start_module = start_module
|
||
self._return_to_launcher = False
|
||
self.check_license_status()
|
||
if self.license_mode == "demo":
|
||
_has_remote = False
|
||
try:
|
||
_burl = self.get_backend_url()
|
||
if _burl and not any(h in _burl for h in ("127.0.0.1", "localhost", "0.0.0.0")):
|
||
_has_remote = True
|
||
except Exception:
|
||
pass
|
||
if _has_remote:
|
||
print("[LICENSE] Remote-Backend konfiguriert – Aktivierungsschluessel-Fallback NICHT angewendet. Backend-Lizenzstatus ist fuehrend.")
|
||
if getattr(self, "_pending_device_limit_msg", None):
|
||
self.after(500, self._show_device_limit_notice)
|
||
else:
|
||
self.after(500, self._show_license_required_notice)
|
||
else:
|
||
try:
|
||
from aza_activation import load_activation_key, validate_key
|
||
_ak = load_activation_key()
|
||
if _ak:
|
||
_v, _exp, _ = validate_key(_ak)
|
||
if _v:
|
||
self.license_mode = "active"
|
||
print(f"[LICENSE] mode=ACTIVE reason=activation_key_fallback valid_until={_exp}")
|
||
except Exception:
|
||
pass
|
||
print(f"Lizenzmodus: {'VOLL' if self.license_mode == 'active' else 'DEMO'}")
|
||
try:
|
||
from aza_version import APP_VERSION as _title_ver
|
||
except Exception:
|
||
_title_ver = ""
|
||
_title_suffix = f" (v{_title_ver})" if _title_ver else ""
|
||
self.title(f"AzA Office{_title_suffix}")
|
||
|
||
# AZA-Icon fuer Titelleiste (AppUserModelID bereits im __main__ gesetzt)
|
||
self._aza_icon_path = None
|
||
for _ico_base in [
|
||
os.path.dirname(os.path.abspath(__file__)),
|
||
getattr(sys, "_MEIPASS", ""),
|
||
os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else "",
|
||
]:
|
||
if not _ico_base:
|
||
continue
|
||
_ico_p = os.path.join(_ico_base, "logo.ico")
|
||
if os.path.isfile(_ico_p):
|
||
try:
|
||
self.iconbitmap(_ico_p)
|
||
self._aza_icon_path = _ico_p
|
||
break
|
||
except Exception:
|
||
pass
|
||
if not self._aza_icon_path:
|
||
print("[AZA] logo.ico nicht gefunden")
|
||
|
||
MAIN_MIN_W, MAIN_MIN_H = 820, 650
|
||
self.minsize(MAIN_MIN_W, MAIN_MIN_H)
|
||
|
||
saved = load_window_geometry()
|
||
self._saved_sash = None
|
||
self._saved_sash_transcript = None
|
||
if saved:
|
||
w = max(MAIN_MIN_W, saved[0])
|
||
h = max(MAIN_MIN_H, saved[1])
|
||
if len(saved) >= 5 and saved[4] is not None:
|
||
self._saved_sash = saved[4]
|
||
if len(saved) >= 6 and saved[5] is not None:
|
||
self._saved_sash_transcript = saved[5]
|
||
else:
|
||
w = DEFAULT_WINDOW_WIDTH
|
||
h = DEFAULT_WINDOW_HEIGHT
|
||
self.geometry(f"{w}x{h}")
|
||
self._initial_w = w
|
||
self._initial_h = h
|
||
self._center_done = False
|
||
|
||
# Fenster immer im Vordergrund
|
||
self.attributes("-topmost", True)
|
||
|
||
self._last_external_hwnd = None
|
||
self.bind("<FocusOut>", self._on_focus_out_for_external_paste)
|
||
|
||
self._geometry_save_after_id = None
|
||
self._tiling_active = False
|
||
self.bind("<Configure>", self._on_configure)
|
||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
|
||
# Benutzerprofil laden (optional, nie blockierend fuer Lizenz)
|
||
self._user_profile = load_user_profile()
|
||
if not self._user_profile.get("name"):
|
||
self._user_profile["name"] = "Benutzer"
|
||
save_user_profile(self._user_profile)
|
||
|
||
_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
||
if os.path.isfile(_env_path):
|
||
load_dotenv(dotenv_path=_env_path, override=True)
|
||
else:
|
||
load_dotenv(override=True)
|
||
api_key = get_openai_api_key() or ""
|
||
self.client = OpenAI(api_key=api_key) if api_key else None
|
||
self.api_key_present = bool(api_key) or _has_remote_backend()
|
||
|
||
self.recorder = AudioRecorder()
|
||
self.is_recording = False
|
||
self._recording_mode = "new"
|
||
self.last_wav_path = None
|
||
self._last_brief_text = ""
|
||
self._last_rezept_text = ""
|
||
self._last_kogu_text = ""
|
||
|
||
self._timer_sec = 0
|
||
self._timer_running = False
|
||
self._phase = ""
|
||
|
||
self._autotext_data = load_autotext()
|
||
self._autotext_data.setdefault("autoOpenNews", False)
|
||
self._autotext_data.setdefault("autoOpenEvents", True)
|
||
self._autotext_data.setdefault("newsTemplate", "all")
|
||
self._autotext_data.setdefault("newsSelectedSpecialties", [])
|
||
self._autotext_data.setdefault("newsSelectedRegions", ["CH", "EU"])
|
||
self._autotext_data.setdefault("newsSort", "newest")
|
||
self._autotext_data.setdefault("eventsSelectedSpecialties", ["general-medicine"])
|
||
self._autotext_data.setdefault("eventsSelectedRegions", ["CH", "EU"])
|
||
self._autotext_data.setdefault("eventsTemplate", "general_ch_eu")
|
||
self._autotext_data.setdefault("eventsSort", "soonest")
|
||
self._autotext_data.setdefault("eventsMonthsAhead", 13)
|
||
self._autotext_data.setdefault("selectedLanguage", "system")
|
||
self._autotext_data.setdefault("user_specialty_default", "")
|
||
self._autotext_data.setdefault("user_specialties_selected", [])
|
||
self._autotext_data.setdefault("ui_font_delta", -1)
|
||
self._autotext_data.setdefault("notizen_open_on_start", True)
|
||
self._autotext_data.setdefault("audio_notiz_autostart", True)
|
||
self._autotext_data.setdefault("kommentare_auto_open", False)
|
||
self._autotext_data.setdefault("offer_autotext_background_close", True)
|
||
self._ensure_user_specialty_preferences()
|
||
|
||
uid = self._user_profile.get("name", "default")
|
||
if not has_valid_consent(uid):
|
||
self._show_consent_dialog()
|
||
|
||
if not self._autotext_data.get("eventsSelectedSpecialties"):
|
||
self._autotext_data["eventsSelectedSpecialties"] = [self._autotext_data.get("user_specialty_default", "dermatology")]
|
||
if not self._autotext_data.get("newsSelectedSpecialties"):
|
||
self._autotext_data["newsSelectedSpecialties"] = [self._autotext_data.get("user_specialty_default", "dermatology")]
|
||
# ====== AUTOTEXT FROZEN — NICHT ANFASSEN ======
|
||
# Diese drei Variablen + _periodic_focus_check + _run_global_autotext_listener
|
||
# bilden den stabilisierten globalen Autotext. Funktioniert in Word, PowerShell,
|
||
# Notepad und allen externen Apps. Aenderungen NUR bei reproduzierbarem Bug.
|
||
# ==================================================
|
||
self._autotext_global_buffer = []
|
||
self._autotext_injecting = [False]
|
||
self._autotext_focus_in_app = [False]
|
||
self._autotext_in_app_widgets = set()
|
||
self._autotext_global_paused = False
|
||
self._autotext_background_mode = False
|
||
self._autotext_bg_win = None
|
||
self._autotext_force_quit = False
|
||
self._shutdown_in_progress = False
|
||
self._shutdown_done = False
|
||
self._autotext_hook_stop_event = threading.Event()
|
||
self._autotext_kbd_listener_lock = threading.Lock()
|
||
self._autotext_active_kbd_listener = None
|
||
self._autotext_mouse_listener_lock = threading.Lock()
|
||
self._autotext_active_mouse_listener = None
|
||
self._global_autotext_engine_state = "running"
|
||
self._autotext_global_listener_thread_started = False
|
||
self._one_click_paste_until = 0.0
|
||
if _HAS_PYNPUT and sys.platform == "win32":
|
||
self._autotext_global_listener_thread_started = True
|
||
threading.Thread(target=self._run_global_autotext_listener, daemon=True).start()
|
||
self._lifecycle_shutdown_debug("autotext listener thread spawn (single per instance)")
|
||
if sys.platform == "win32":
|
||
threading.Thread(target=self._run_global_right_click_paste_listener, daemon=True).start()
|
||
|
||
# FOKUS FUER GLOBALEN AUTOTEXT: weiterhin PID des Vordergrundfensters,
|
||
# aber Unterdrückung nur, wenn das Fokus-Widget einen In-App-Autotext-
|
||
# Binding aufweist. Sonst würde pynput im Empfang-/anderen Tk-Feldern
|
||
# still ausgeschaltet, obwohl dort kein <KeyRelease>-Handler existiert.
|
||
def _periodic_focus_check():
|
||
if getattr(self, "_shutdown_in_progress", False):
|
||
return
|
||
try:
|
||
self._sync_autotext_focus_for_global()
|
||
finally:
|
||
try:
|
||
if not getattr(self, "_shutdown_in_progress", False):
|
||
self.after(250, _periodic_focus_check)
|
||
except tk.TclError:
|
||
pass
|
||
|
||
self.after(400, _periodic_focus_check)
|
||
|
||
self.configure(bg="#B9ECFA")
|
||
style = ttk.Style(self)
|
||
try:
|
||
style.theme_use("clam")
|
||
except tk.TclError:
|
||
pass
|
||
style.configure("TFrame", background="#B9ECFA")
|
||
style.configure("TPanedwindow", background="#B9ECFA")
|
||
try:
|
||
style.configure("TPanedwindow.Sash", background="#B9ECFA", width=8)
|
||
except tk.TclError:
|
||
pass
|
||
style.configure("TLabel", background="#B9ECFA", foreground="#1a4d6d")
|
||
style.configure("TopBar.TFrame", background="#B9ECFA")
|
||
style.configure("StatusBar.TFrame", background="#B9ECFA")
|
||
style.configure("TranscriptBar.TFrame", background="#B9ECFA")
|
||
style.configure(
|
||
"TButton",
|
||
background="#7EC8E3",
|
||
foreground="#1a4d6d",
|
||
padding=(10, 6),
|
||
borderwidth=0,
|
||
)
|
||
style.map("TButton", background=[("active", "#5AB9E8"), ("pressed", "#4AA5D4")])
|
||
style.configure(
|
||
"Primary.TButton",
|
||
background="#2196F3",
|
||
foreground="white",
|
||
padding=(12, 16),
|
||
)
|
||
style.map("Primary.TButton", background=[("active", "#1E88D4"), ("pressed", "#1976D2")])
|
||
|
||
self._build_ui()
|
||
self._backend_autostart_attempted = False
|
||
|
||
try:
|
||
self.attributes("-alpha", load_opacity())
|
||
except Exception:
|
||
pass
|
||
self.after(50, self._ensure_centered)
|
||
self.after(100, self._restore_sash)
|
||
self.after(1500, self._check_old_kg_entries)
|
||
self.after(2500, self._auto_start_backend_if_needed)
|
||
# Automatische Aktualisierung der Token-Nutzung beim Start (nach 3 Sekunden)
|
||
self.after(3000, lambda: threading.Thread(target=self._fetch_and_update_usage, daemon=True).start())
|
||
if self._autotext_data.get("empfang_was_open", False):
|
||
self.after(1000, self._send_to_empfang)
|
||
|
||
self._empfang_last_seen_ids = set()
|
||
self.after(5000, self._empfang_background_poll)
|
||
|
||
self.after(3000, self._provision_empfang_account)
|
||
|
||
self._audio_notiz_autostart_attempted = False
|
||
|
||
# Dev-Status-Fenster wird in der neuen Desktop-Huelle nicht
|
||
# mehr automatisch beim Start geoeffnet. Manueller Aufruf
|
||
# ueber den Status-Button bleibt verfuegbar.
|
||
# self.after(1200, self._open_dev_status_window)
|
||
|
||
self._window_registry.add(self)
|
||
|
||
# AzA Office Huelle V1.0 auf das fertig aufgebaute Hauptfenster
|
||
# anwenden. Versteckt das alte Topbar-/Modulspalten-Layout und
|
||
# baut die schmale Empfang-Stil-Oberflaeche auf, ohne dass die
|
||
# produktive Logik (Aufnahme, KG, SOAP, Dokumente, Profil,
|
||
# Aktivierung) angefasst wird.
|
||
try:
|
||
apply_office_shell_v1(self)
|
||
except Exception as _shell_exc:
|
||
print(f"[AZA] AzA Office Huelle V1.0 nicht installiert: {_shell_exc}")
|
||
|
||
self.after(500, self._dispatch_start_module)
|
||
|
||
if self._autotext_data.get("kommentare_auto_open", False):
|
||
self.after(1000, self._open_kommentare_fenster)
|
||
|
||
if not self.api_key_present and not _has_remote_backend():
|
||
try:
|
||
_show_openai_key_setup_dialog()
|
||
if has_openai_api_key():
|
||
api_key = get_openai_api_key()
|
||
self.client = OpenAI(api_key=api_key) if api_key else None
|
||
self.api_key_present = True
|
||
except Exception:
|
||
pass
|
||
|
||
self.after(2000, self._check_microphone_at_startup)
|
||
|
||
def _dispatch_start_module(self):
|
||
"""Öffnet das per Launcher gewählte Modul."""
|
||
mod = self._start_module
|
||
if not mod or mod == "kg":
|
||
return
|
||
try:
|
||
dispatch = {
|
||
"ki": lambda: None,
|
||
"notizen": lambda: self._start_audio_notiz_addon(silent=False),
|
||
"translator": self._open_uebersetzer,
|
||
"medwork_chat": self._open_docapp,
|
||
"praxis_chat": self._send_to_empfang,
|
||
}
|
||
handler = dispatch.get(mod)
|
||
if handler is None:
|
||
messagebox.showinfo(
|
||
"Modul nicht verfügbar",
|
||
f"Das Modul '{mod}' ist noch nicht vollständig integriert.\n"
|
||
"Sie können es manuell über die Seitenleiste starten.",
|
||
)
|
||
return
|
||
if mod in ("notizen", "translator", "medwork_chat"):
|
||
if not self._check_ai_consent():
|
||
return
|
||
if mod in ("translator", "notizen", "medwork_chat", "praxis_chat"):
|
||
self.iconify()
|
||
handler()
|
||
except Exception as e:
|
||
messagebox.showerror("Modul-Start", f"Fehler beim Öffnen von '{mod}':\n{e}")
|
||
|
||
_LOGIN_INTERVAL_DAYS = 7
|
||
|
||
def _login_needed(self) -> bool:
|
||
"""True wenn der letzte Login länger als _LOGIN_INTERVAL_DAYS her ist."""
|
||
last_ts = self._user_profile.get("last_login_ts")
|
||
if not isinstance(last_ts, (int, float)):
|
||
return True
|
||
elapsed = time.time() - float(last_ts)
|
||
return elapsed > (self._LOGIN_INTERVAL_DAYS * 86400)
|
||
|
||
def _record_login(self):
|
||
self._user_profile["last_login_ts"] = int(time.time())
|
||
save_user_profile(self._user_profile)
|
||
|
||
def check_license_status(self):
|
||
self.license_mode = "demo"
|
||
license_mode = "DEMO"
|
||
license_reason = "unknown"
|
||
valid_until = None
|
||
cache = _load_license_cache()
|
||
if cache and _cache_is_fresh(cache) and _cache_license_valid(cache):
|
||
license_mode = "ACTIVE"
|
||
license_reason = "offline_cache"
|
||
valid_until = cache.get("valid_until")
|
||
self.license_mode = "active"
|
||
print(f"[LICENSE] mode={license_mode} reason={license_reason} valid_until={_format_valid_until_ts(valid_until)}")
|
||
return
|
||
|
||
try:
|
||
backend_url = self.get_backend_url()
|
||
api_token = self.get_backend_token()
|
||
print(f"[LICENSE] status_url={backend_url}/license/status")
|
||
response = None
|
||
status_code = None
|
||
last_exc = None
|
||
device_id = _get_or_create_device_id()
|
||
_dev_name = f"{platform.node() or 'unknown'}"
|
||
try:
|
||
from aza_version import APP_VERSION as _app_ver
|
||
except Exception:
|
||
_app_ver = ""
|
||
headers = {
|
||
"X-API-Token": api_token,
|
||
"X-Device-Id": device_id,
|
||
"X-Device-Name": _dev_name,
|
||
"X-App-Version": _app_ver,
|
||
"X-Device-Fingerprint": _get_hardware_fingerprint(),
|
||
}
|
||
_lic_params: dict = {}
|
||
try:
|
||
_lk = (load_user_profile().get("license_key") or "").strip()
|
||
if _lk:
|
||
_lic_params["license_key"] = _lk
|
||
print(f"[LICENSE] license_key=***{_lk[-4:]}")
|
||
except Exception:
|
||
pass
|
||
for attempt in range(1, 7):
|
||
try:
|
||
response = requests.get(
|
||
f"{backend_url}/license/status",
|
||
headers=headers,
|
||
params=_lic_params,
|
||
timeout=5,
|
||
)
|
||
status_code = response.status_code
|
||
response.raise_for_status()
|
||
print(f"[LICENSE] status_code={status_code} attempt={attempt}/6")
|
||
break
|
||
except requests.HTTPError as http_exc:
|
||
last_exc = http_exc
|
||
print(f"[LICENSE] status_code={status_code} attempt={attempt}/6")
|
||
break
|
||
except requests.RequestException as req_exc:
|
||
last_exc = req_exc
|
||
print(f"[LICENSE] retry={attempt}/6 reason={req_exc}")
|
||
if attempt < 6:
|
||
time.sleep(1.0)
|
||
if isinstance(last_exc, requests.HTTPError):
|
||
if status_code in (401, 403):
|
||
license_mode = "DEMO"
|
||
license_reason = "unauthorized"
|
||
else:
|
||
if cache and _cache_license_valid(cache):
|
||
license_mode = "ACTIVE"
|
||
license_reason = "offline_cache"
|
||
valid_until = cache.get("valid_until")
|
||
else:
|
||
license_mode = "DEMO"
|
||
license_reason = "no_backend"
|
||
valid_until = cache.get("valid_until") if isinstance(cache, dict) else None
|
||
self.license_mode = "active" if license_mode == "ACTIVE" else "demo"
|
||
print(f"[LICENSE] mode={license_mode} reason={license_reason} valid_until={_format_valid_until_ts(valid_until)}")
|
||
return
|
||
if response is None:
|
||
raise RuntimeError(last_exc or "license status request failed")
|
||
|
||
try:
|
||
data = response.json()
|
||
except Exception:
|
||
data = {}
|
||
|
||
resp_valid = bool(data.get("valid")) if isinstance(data, dict) else False
|
||
resp_valid_until = data.get("valid_until") if isinstance(data, dict) else None
|
||
payload = {"valid": resp_valid, "valid_until": resp_valid_until, "cached_at": time.time()}
|
||
_save_license_cache(payload)
|
||
_srv_pid = (data.get("practice_id") or "").strip() if isinstance(data, dict) else ""
|
||
if _srv_pid and _srv_pid != self.get_practice_id():
|
||
self._user_profile["practice_id"] = _srv_pid
|
||
save_user_profile(self._user_profile)
|
||
print(f"[LICENSE] practice_id aus Status gespeichert: {_srv_pid[:8]}...")
|
||
now = int(time.time())
|
||
valid_until = resp_valid_until if isinstance(resp_valid_until, (int, float)) else None
|
||
|
||
_device_reason = data.get("reason", "") if isinstance(data, dict) else ""
|
||
_device_allowed = data.get("device_allowed", True) if isinstance(data, dict) else True
|
||
_allowed_devs = data.get("allowed_devices", 0) if isinstance(data, dict) else 0
|
||
_used_devs = data.get("used_devices", 0) if isinstance(data, dict) else 0
|
||
_license_active = data.get("license_active", False) if isinstance(data, dict) else False
|
||
|
||
if _device_reason == "license_key_required":
|
||
license_mode = "DEMO"
|
||
license_reason = "license_key_required"
|
||
self._pending_device_limit_msg = (
|
||
"Lizenzschluessel erforderlich.\n\n"
|
||
"Bitte tragen Sie Ihren Lizenzschluessel\n"
|
||
"im Aktivierungsdialog ein (Taste \U0001f511)."
|
||
)
|
||
print("[LICENSE] license_key_required: multiple licenses, no key provided")
|
||
elif _device_reason == "device_limit_reached" and not _device_allowed:
|
||
license_mode = "DEMO"
|
||
license_reason = "device_limit_reached"
|
||
valid_until = resp_valid_until if isinstance(resp_valid_until, (int, float)) else None
|
||
self._pending_device_limit_msg = (
|
||
f"Geraete-Limit erreicht.\n\n"
|
||
f"Ihre Lizenz erlaubt {_allowed_devs} Geraete,\n"
|
||
f"{_used_devs} sind bereits registriert.\n\n"
|
||
f"Bitte deaktivieren Sie ein anderes Geraet\n"
|
||
f"oder erwerben Sie eine weitere Lizenz."
|
||
)
|
||
print(f"[LICENSE] device_limit_reached: {_used_devs}/{_allowed_devs}")
|
||
elif (resp_valid or _license_active) and isinstance(resp_valid_until, (int, float)) and int(resp_valid_until) > now:
|
||
license_mode = "ACTIVE"
|
||
license_reason = "online"
|
||
valid_until = int(resp_valid_until)
|
||
elif isinstance(resp_valid_until, (int, float)) and int(resp_valid_until) <= now:
|
||
license_mode = "DEMO"
|
||
license_reason = "expired"
|
||
valid_until = int(resp_valid_until)
|
||
else:
|
||
license_mode = "DEMO"
|
||
license_reason = "not_valid"
|
||
valid_until = resp_valid_until if isinstance(resp_valid_until, (int, float)) else None
|
||
except Exception as e:
|
||
print(f"[LICENSE] exception={e}")
|
||
if cache and _cache_license_valid(cache):
|
||
license_mode = "ACTIVE"
|
||
license_reason = "offline_cache"
|
||
valid_until = cache.get("valid_until")
|
||
else:
|
||
license_mode = "DEMO"
|
||
license_reason = "no_backend"
|
||
valid_until = cache.get("valid_until") if isinstance(cache, dict) else None
|
||
|
||
self.license_mode = "active" if license_mode == "ACTIVE" else "demo"
|
||
self._license_reason = license_reason
|
||
print(f"[LICENSE] mode={license_mode} reason={license_reason} valid_until={_format_valid_until_ts(valid_until)}")
|
||
|
||
def _update_license_indicator(self):
|
||
btn = getattr(self, "_btn_activation", None)
|
||
if not btn:
|
||
return
|
||
try:
|
||
if self.license_mode == "active":
|
||
btn.itemconfig(btn._bg_id, fill="#c8f0c8", outline="#88c888")
|
||
elif getattr(self, "_license_reason", "") in ("device_limit_reached", "expired"):
|
||
btn.itemconfig(btn._bg_id, fill="#f8d0d0", outline="#d88888")
|
||
else:
|
||
btn.itemconfig(btn._bg_id, fill="#BAE8F8", outline="#BAE8F8")
|
||
except Exception:
|
||
pass
|
||
|
||
def _build_ui(self):
|
||
try:
|
||
def_font = tkfont.nametofont("TkDefaultFont")
|
||
font_size = max(10, def_font.actual()["size"]) # Mindestens Gröe 10 für bessere Lesbarkeit
|
||
self._text_font = (def_font.actual()["family"], font_size)
|
||
except Exception:
|
||
self._text_font = ("Segoe UI", 10)
|
||
|
||
# Liste aller skalierbaren Widgets (Buttons, Labels, Text-Widgets)
|
||
self._scalable_widgets = []
|
||
self._scalable_text_widgets = []
|
||
|
||
top = ttk.Frame(self, padding=10, style="TopBar.TFrame")
|
||
top.pack(fill="x")
|
||
|
||
self.model_var = tk.StringVar(value=load_saved_model())
|
||
|
||
self._btn_row_left = ttk.Frame(top)
|
||
self._btn_row_left.pack(side="left")
|
||
|
||
self.btn_record = RoundedButton(
|
||
self._btn_row_left, "⏺ Start", command=self.toggle_record,
|
||
bg="#5B8DB3", fg="white", active_bg="#4A7A9E", width=100, height=40,
|
||
canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_record.lock_color = True
|
||
self.btn_record.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self.btn_record)
|
||
add_tooltip(self.btn_record, "Aufnahme starten / stoppen")
|
||
|
||
self.btn_record_append = RoundedButton(
|
||
self._btn_row_left, "⏺ Korrigieren", command=self._toggle_record_append,
|
||
bg="#5B8DB3", fg="white", active_bg="#4A7A9E",
|
||
width=110, height=32, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_record_append.lock_color = True
|
||
self.btn_record_append.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self.btn_record_append)
|
||
add_tooltip(self.btn_record_append, "Zusätzliches Diktat anhängen")
|
||
|
||
self._btn_audio_import = RoundedButton(
|
||
self._btn_row_left, "Audio importieren", command=self._import_and_transcribe_audio,
|
||
bg="#D4A8E0", fg="#3d1a4d", active_bg="#C490D0",
|
||
width=120, height=32, canvas_bg="#D4A8E0",
|
||
)
|
||
self._btn_audio_import.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_audio_import)
|
||
add_tooltip(self._btn_audio_import, "Audiodatei auswählen und transkribieren (z.B. nach fehlgeschlagenem Versuch)")
|
||
|
||
self._btn_diktat_top = RoundedButton(
|
||
self._btn_row_left, "Diktat", command=self.open_diktat_window,
|
||
bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0",
|
||
width=72, height=32, canvas_bg="#95D6ED",
|
||
)
|
||
self._btn_diktat_top.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_diktat_top)
|
||
add_tooltip(self._btn_diktat_top, "Diktatfenster öffnen")
|
||
|
||
self._btn_notizen_top = RoundedButton(
|
||
self._btn_row_left, "Notizen", command=self.open_notizen_window,
|
||
bg="#A8D8B9", fg="#1a4d3d", active_bg="#8CC8A5",
|
||
width=76, height=32, canvas_bg="#A8D8B9",
|
||
)
|
||
self._btn_notizen_top.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_notizen_top)
|
||
add_tooltip(self._btn_notizen_top, "Projekt-Notizen anzeigen")
|
||
|
||
self.btn_new = RoundedButton(
|
||
self._btn_row_left, "Neu", command=self._new_session,
|
||
width=54, height=32, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_new.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self.btn_new)
|
||
add_tooltip(self.btn_new, "Neue Sitzung beginnen")
|
||
|
||
self._btn_minimize = RoundedButton(
|
||
self._btn_row_left, "−", command=self._toggle_minimize,
|
||
width=32, height=32, canvas_bg="#B9ECFA",
|
||
)
|
||
self._btn_minimize.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_minimize)
|
||
add_tooltip(self._btn_minimize, "Kompaktansicht")
|
||
|
||
self._btn_tile_windows = RoundedButton(
|
||
self._btn_row_left, "⊞", command=self.arrange_windows_top,
|
||
width=32, height=32, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8",
|
||
)
|
||
self._btn_tile_windows.pack(side="left", padx=(3, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_tile_windows)
|
||
add_tooltip(self._btn_tile_windows, "Fenster anordnen")
|
||
|
||
self._btn_reset_pos = RoundedButton(
|
||
self._btn_row_left, "↺", command=self._reset_window_positions,
|
||
width=32, height=32, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8",
|
||
)
|
||
self._btn_reset_pos.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_reset_pos)
|
||
add_tooltip(self._btn_reset_pos, "Fensterpositionen zurücksetzen")
|
||
|
||
self._btn_profile = RoundedButton(
|
||
self._btn_row_left, "👤", command=self._show_profile_editor,
|
||
width=32, height=32, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8",
|
||
)
|
||
self._btn_profile.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_profile)
|
||
add_tooltip(self._btn_profile, "Profil bearbeiten")
|
||
|
||
self._btn_activation = RoundedButton(
|
||
self._btn_row_left, "🔑", command=self._show_activation_dialog,
|
||
width=32, height=32, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8",
|
||
)
|
||
self._btn_activation.pack(side="left", padx=(6, 0), anchor="n")
|
||
self._scalable_widgets.append(self._btn_activation)
|
||
add_tooltip(self._btn_activation, "Aktivierung verwalten")
|
||
self.after(200, self._update_license_indicator)
|
||
|
||
self._top_right = ttk.Frame(top, style="TopBar.TFrame")
|
||
self._top_right.pack(side="right", anchor="n")
|
||
|
||
# Token-Anzeige (zeigt echten OpenAI-Verbrauch)
|
||
token_data = load_token_usage()
|
||
used_dollars = token_data.get("used_dollars", 0)
|
||
budget_dollars = token_data.get("budget_dollars", 50) # Standard: $50
|
||
|
||
if budget_dollars > 0:
|
||
remaining_percent = int(((budget_dollars - used_dollars) / budget_dollars * 100))
|
||
remaining_percent = max(0, min(100, remaining_percent))
|
||
else:
|
||
remaining_percent = 100
|
||
|
||
self._token_label = ttk.Label(
|
||
self._top_right,
|
||
text=f" {remaining_percent}%",
|
||
font=("Segoe UI", 9),
|
||
foreground="#BD4500" if remaining_percent < 20 else ("#FF8C00" if remaining_percent < 50 else "#1a4d6d"),
|
||
cursor="hand2"
|
||
)
|
||
self._token_label.pack(side="left", padx=(0, 8))
|
||
remaining = max(0, budget_dollars - used_dollars)
|
||
tooltip_text = f"Guthaben: {remaining_percent}% (${remaining:.2f} von ${budget_dollars:.2f})\n\n"
|
||
tooltip_text += f"Verbraucht: ${used_dollars:.2f}\n\n"
|
||
tooltip_text += "100% = Volles Guthaben\n0% = Guthaben aufgebraucht\n\n"
|
||
tooltip_text += "Klick: Aktuellen Stand laden\nRechtsklick: Guthaben einstellen"
|
||
add_tooltip(self._token_label, tooltip_text)
|
||
|
||
# Token-Label anklickbar machen
|
||
def refresh_usage(e=None):
|
||
self.set_status("Guthaben wird aktualisiert …")
|
||
threading.Thread(target=self._fetch_and_update_usage, daemon=True).start()
|
||
|
||
def open_budget(e=None):
|
||
self._open_budget_settings()
|
||
|
||
self._token_label.bind("<Button-1>", refresh_usage)
|
||
self._token_label.bind("<Button-3>", open_budget)
|
||
|
||
self._backend_status_var = tk.StringVar(value="Verbindung wird geprüft …")
|
||
self._backend_status_label = tk.Label(
|
||
self._top_right,
|
||
textvariable=self._backend_status_var,
|
||
bg="#B9ECFA",
|
||
fg="#BD4500",
|
||
font=("Segoe UI", 9, "bold"),
|
||
)
|
||
self._backend_status_label.pack(side="left", padx=(0, 6))
|
||
|
||
self._btn_backend_start = RoundedButton(
|
||
self._top_right, "Verbinden", command=self._start_backend_from_ui,
|
||
width=78, height=24, canvas_bg="#B9ECFA",
|
||
bg="#E1F6FC", fg="#1a4d6d", active_bg="#B8E8F4",
|
||
)
|
||
self._btn_backend_start.pack(side="left", padx=(0, 8), anchor="center")
|
||
self._scalable_widgets.append(self._btn_backend_start)
|
||
add_tooltip(
|
||
self._btn_backend_start,
|
||
"Lokale Serververbindung manuell starten",
|
||
)
|
||
|
||
# KEINE REGLER MEHR - Fixierte optimale Gröen (Schrift: 60%, Buttons: 140%)
|
||
# Wende fixierte Werte direkt an
|
||
self._apply_font_scale_global(FIXED_FONT_SCALE)
|
||
self._apply_button_scale_global(FIXED_BUTTON_SCALE)
|
||
|
||
# Transparenz-Regler
|
||
self._opacity_var_main = tk.DoubleVar(value=round(load_opacity() * 100))
|
||
|
||
def on_opacity_main(val):
|
||
try:
|
||
alpha = float(val) / 100.0
|
||
alpha = max(MIN_OPACITY, min(1.0, alpha))
|
||
self.attributes("-alpha", alpha)
|
||
save_opacity(alpha)
|
||
except Exception:
|
||
pass
|
||
|
||
opacity_lbl_half = tk.Label(self._top_right, text="◐", font=("Segoe UI Symbol", 14),
|
||
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
|
||
opacity_lbl_half.pack(side="left", padx=(0, 1))
|
||
opacity_lbl_half.bind("<Button-1>", lambda e: (self._opacity_var_main.set(round(MIN_OPACITY * 100)),
|
||
on_opacity_main(str(MIN_OPACITY * 100))))
|
||
opacity_lbl_half.bind("<Enter>", lambda e: opacity_lbl_half.configure(fg="#1a4d6d"))
|
||
opacity_lbl_half.bind("<Leave>", lambda e: opacity_lbl_half.configure(fg="#7AAFC8"))
|
||
add_tooltip(opacity_lbl_half, f"Fenster durchsichtig ({int(MIN_OPACITY*100)}%)")
|
||
|
||
try:
|
||
s = ttk.Style(self)
|
||
s.configure("MainOpacity.Horizontal.TScale", troughcolor="#c8ecf8", background="#5B8DB3")
|
||
except Exception:
|
||
pass
|
||
self._opacity_scale_main = ttk.Scale(
|
||
self._top_right,
|
||
from_=40, to=100,
|
||
variable=self._opacity_var_main,
|
||
orient="horizontal",
|
||
length=50,
|
||
command=on_opacity_main,
|
||
style="MainOpacity.Horizontal.TScale",
|
||
)
|
||
self._opacity_scale_main.pack(side="left")
|
||
add_tooltip(self._opacity_scale_main, "Fenster-Transparenz\nLinks = durchsichtig, Rechts = undurchsichtig")
|
||
|
||
opacity_lbl_sun = tk.Label(self._top_right, text="☀", font=("Segoe UI Symbol", 14),
|
||
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
|
||
opacity_lbl_sun.pack(side="left", padx=(1, 8))
|
||
opacity_lbl_sun.bind("<Button-1>", lambda e: (self._opacity_var_main.set(100),
|
||
on_opacity_main("100")))
|
||
opacity_lbl_sun.bind("<Enter>", lambda e: opacity_lbl_sun.configure(fg="#1a4d6d"))
|
||
opacity_lbl_sun.bind("<Leave>", lambda e: opacity_lbl_sun.configure(fg="#7AAFC8"))
|
||
add_tooltip(opacity_lbl_sun, "Fenster undurchsichtig (100%)")
|
||
|
||
# Transparenz-Elemente in der rechten Leiste ganz links platzieren
|
||
try:
|
||
self._token_label.pack_forget()
|
||
self._token_label.pack(side="left", padx=(8, 8))
|
||
except Exception:
|
||
pass
|
||
|
||
self._btn_launcher = RoundedButton(
|
||
self._top_right, "\u2190 Startseite", command=self._go_to_launcher,
|
||
bg="#EBEDF0", fg="#6B7280", active_bg="#D1D5DB", width=90, height=26, canvas_bg="#B9ECFA",
|
||
)
|
||
self._btn_launcher.pack(side="left", anchor="center", padx=(0, 6), pady=(0, 0))
|
||
self._scalable_widgets.append(self._btn_launcher)
|
||
add_tooltip(self._btn_launcher, "Zur\u00fcck zur Modulauswahl (Startseite)")
|
||
|
||
self._settings_photo = None
|
||
settings_icon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets", "zahnrad.png")
|
||
try:
|
||
if os.path.isfile(settings_icon_path):
|
||
from PIL import Image
|
||
try:
|
||
resample = Image.Resampling.LANCZOS
|
||
except Exception:
|
||
resample = Image.LANCZOS
|
||
img = Image.open(settings_icon_path).convert("RGBA")
|
||
|
||
# Icon visuell an UI angleichen: Hintergrund = Fensterblau, Zahnrad = dunkelblau.
|
||
bg_rgb = (0xB9, 0xEC, 0xFA)
|
||
gear_rgb = (0x1A, 0x4D, 0x6D)
|
||
px = img.load()
|
||
w, h = img.size
|
||
for yy in range(h):
|
||
for xx in range(w):
|
||
r, g, b, a = px[xx, yy]
|
||
if a == 0:
|
||
continue
|
||
lum = (r + g + b) / 3.0
|
||
spread = max(r, g, b) - min(r, g, b)
|
||
if lum >= 170 and spread <= 45:
|
||
px[xx, yy] = (bg_rgb[0], bg_rgb[1], bg_rgb[2], a)
|
||
elif lum <= 130:
|
||
px[xx, yy] = (gear_rgb[0], gear_rgb[1], gear_rgb[2], a)
|
||
else:
|
||
t = (lum - 130.0) / 40.0
|
||
t = max(0.0, min(1.0, t))
|
||
rr = int(gear_rgb[0] + (bg_rgb[0] - gear_rgb[0]) * t)
|
||
gg = int(gear_rgb[1] + (bg_rgb[1] - gear_rgb[1]) * t)
|
||
bb = int(gear_rgb[2] + (bg_rgb[2] - gear_rgb[2]) * t)
|
||
px[xx, yy] = (rr, gg, bb, a)
|
||
|
||
# +20% gegenüber bisheriger Darstellung (22 -> 26)
|
||
img = img.resize((26, 26), resample)
|
||
self._settings_photo = ImageTk.PhotoImage(img)
|
||
except Exception as e:
|
||
try:
|
||
print(f"[SETTINGS_ICON] Ladefehler: {e}")
|
||
except Exception:
|
||
pass
|
||
|
||
if self._settings_photo is not None:
|
||
self.btn_settings = tk.Label(
|
||
self._top_right,
|
||
image=self._settings_photo,
|
||
bg="#B9ECFA",
|
||
bd=0,
|
||
highlightthickness=0,
|
||
cursor="hand2",
|
||
)
|
||
self.btn_settings.bind("<Button-1>", lambda e: self._open_settings())
|
||
self.btn_settings.bind("<Enter>", lambda e: self.btn_settings.configure(bg="#B9ECFA"))
|
||
self.btn_settings.bind("<Leave>", lambda e: self.btn_settings.configure(bg="#B9ECFA"))
|
||
self.btn_settings.pack(side="left", anchor="center", padx=(6, 0), pady=(0, 0))
|
||
else:
|
||
self.btn_settings = RoundedButton(
|
||
self._top_right, "⚙", command=self._open_settings, width=36, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_settings.pack(side="left", anchor="center", padx=(6, 0), pady=(0, 0))
|
||
self._scalable_widgets.append(self.btn_settings)
|
||
add_tooltip(self.btn_settings, "Einstellungen öffnen")
|
||
|
||
self._btn_systemstatus = RoundedButton(
|
||
self._top_right, "Status", command=self._open_systemstatus,
|
||
bg="#EBEDF0", fg="#6B7280", active_bg="#D1D5DB", width=52, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self._btn_systemstatus.pack(side="left", anchor="center", padx=(6, 0), pady=(0, 0))
|
||
self._scalable_widgets.append(self._btn_systemstatus)
|
||
add_tooltip(self._btn_systemstatus, "Systemstatus und Diagnose")
|
||
|
||
# Kommentare-Button: gepackt in right_top (neben KG erstellen) weiter unten
|
||
|
||
self.btn_billing = RoundedButton(
|
||
self._top_right, "Abonnement", command=self._open_billing_portal_from_ui,
|
||
bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0", width=100, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_billing.pack(side="left", anchor="center", padx=(6, 0), pady=(0, 0))
|
||
self._scalable_widgets.append(self.btn_billing)
|
||
add_tooltip(self.btn_billing, "Abonnement und Rechnungen verwalten")
|
||
|
||
self._status_row = ttk.Frame(self, padding=(16, 4), style="StatusBar.TFrame")
|
||
self._status_row.pack(fill="x")
|
||
self.status_var = tk.StringVar(value="Bereit.")
|
||
self.lbl_status = tk.Label(
|
||
self._status_row, textvariable=self.status_var, fg="#BD4500", bg="#B9ECFA", font=self._text_font
|
||
)
|
||
self.lbl_status.pack(side="left")
|
||
self._apply_status_color()
|
||
|
||
self._build_info_str = self._make_build_info_string()
|
||
self._lbl_build = tk.Label(
|
||
self._status_row, text=self._build_info_str,
|
||
font=("Segoe UI", 7), fg="#8899AA", bg="#B9ECFA",
|
||
cursor="hand2",
|
||
)
|
||
self._lbl_build.pack(side="right", padx=(8, 4))
|
||
self._lbl_build.bind("<Button-1>", lambda e: self._show_about_dialog())
|
||
add_tooltip(self._lbl_build, "Klicken für Build-Details (Über AZA)")
|
||
|
||
self.paned = ttk.PanedWindow(self, orient="horizontal")
|
||
self.paned.pack(fill="both", expand=True, padx=10, pady=(0, 10))
|
||
self.paned.bind("<Configure>", self._on_paned_configure)
|
||
self._minimized = False
|
||
self._aza_minimize = self._toggle_minimize
|
||
self._aza_is_minimized = lambda: self._minimized
|
||
self._aza_windows = set()
|
||
self._window_registry = set()
|
||
self._btn_brief_mini = None
|
||
|
||
left = ttk.Frame(self.paned, padding=10)
|
||
right = ttk.Frame(self.paned, padding=10)
|
||
self.paned.add(left, weight=1)
|
||
self.paned.add(right, weight=1)
|
||
|
||
left_top = ttk.Frame(left)
|
||
left_top.pack(fill="x", pady=(0, 4))
|
||
self.btn_copy_transcript = RoundedButton(
|
||
left_top, "Transkript kopieren", command=self.copy_transcript,
|
||
width=160, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_copy_transcript.pack(side="left", padx=(4, 0))
|
||
|
||
self._rclick_paste_var = tk.BooleanVar(
|
||
value=bool((self._autotext_data or {}).get("global_right_click_paste", True))
|
||
)
|
||
self._rclick_cb = tk.Checkbutton(
|
||
left_top, text="Rechtsklick = Einfügen",
|
||
variable=self._rclick_paste_var,
|
||
command=self._toggle_rclick_paste,
|
||
bg="#B9ECFA", activebackground="#B9ECFA",
|
||
font=("Segoe UI", 8),
|
||
anchor="w",
|
||
)
|
||
self._rclick_cb.pack(side="left", padx=(10, 0))
|
||
|
||
# Vertikales PanedWindow für Transkript (verstellbare Höhe)
|
||
self.paned_transcript = ttk.PanedWindow(left, orient="vertical")
|
||
self.paned_transcript.pack(fill="both", expand=True)
|
||
self.paned_transcript.bind("<Configure>", self._on_paned_configure)
|
||
|
||
# Sash (Trennbalken) sichtbar machen
|
||
style = ttk.Style()
|
||
style.configure("Sash", sashthickness=8, background="#7EC8E3")
|
||
|
||
top_left = ttk.Frame(self.paned_transcript)
|
||
bottom_left = ttk.Frame(self.paned_transcript, style="TranscriptBar.TFrame")
|
||
self.paned_transcript.add(top_left, weight=1)
|
||
self.paned_transcript.add(bottom_left, weight=0)
|
||
|
||
# Frame für Label + Schriftgröen-Spinbox
|
||
transcript_header = ttk.Frame(top_left)
|
||
transcript_header.pack(fill="x", anchor="w")
|
||
|
||
self._transcript_collapsed = False
|
||
self._transcript_toggle_label = tk.Label(
|
||
transcript_header, text="\u25BC Transkript:",
|
||
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d", cursor="hand2")
|
||
self._transcript_toggle_label.pack(side="left")
|
||
self._transcript_toggle_label.bind("<Button-1>", self._toggle_transcript_collapse)
|
||
|
||
self._transcript_frame = ttk.Frame(top_left)
|
||
self._transcript_frame.pack(fill="both", expand=True)
|
||
self.txt_transcript = ScrolledText(
|
||
self._transcript_frame, wrap="word", font=self._text_font, bg="#e1f6fc"
|
||
)
|
||
self.txt_transcript.pack(fill="both", expand=True)
|
||
self._bind_textblock_pending(self.txt_transcript)
|
||
|
||
# Schriftgröen-Spinbox für Transkript
|
||
add_text_font_size_control(transcript_header, self.txt_transcript, initial_size=10, bg_color="#B9ECFA", save_key="main_transcript")
|
||
|
||
# --- Buttons in bottom_left (unterhalb des Trennbalkens) ---
|
||
trans_btn_row = ttk.Frame(bottom_left, padding=(0, 4, 0, 0))
|
||
trans_btn_row.pack(fill="x")
|
||
RoundedButton(
|
||
trans_btn_row, "Ordner", command=self.open_ordner_window,
|
||
bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0", width=180, height=38, canvas_bg="#B9ECFA",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# Anchor für Add-on (immer vorhanden, bleibt links)
|
||
self._addon_anchor = ttk.Frame(bottom_left)
|
||
self._addon_anchor.pack(fill="x")
|
||
|
||
# Add-on-Container (Übersetzer etc.) ein-/ausblendbar über Einstellungen
|
||
self._addon_container = ttk.Frame(bottom_left)
|
||
addon_inner = ttk.Frame(self._addon_container)
|
||
addon_inner.pack(fill="x")
|
||
|
||
# Add-on Header mit Toggle-Pfeil
|
||
addon_header_frame = ttk.Frame(addon_inner)
|
||
addon_header_frame.pack(fill="x", pady=(0, 4))
|
||
|
||
# Toggle-Pfeil und Label
|
||
self._addon_collapsed = False
|
||
self._addon_toggle_label = tk.Label(addon_header_frame, text="\u25BC Weitere Module",
|
||
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d",
|
||
cursor="hand2")
|
||
self._addon_toggle_label.pack(anchor="center")
|
||
self._addon_toggle_label.bind("<Button-1>", self._toggle_addon_collapse)
|
||
|
||
# Container für die Buttons (zum Ein-/Ausblenden)
|
||
self._addon_buttons_container = ttk.Frame(addon_inner)
|
||
self._addon_buttons_container.pack(fill="x")
|
||
|
||
# Speichere Button-Rows für späteres Ein-/Ausblenden
|
||
self._addon_button_rows = {}
|
||
|
||
uebersetzer_row = ttk.Frame(self._addon_buttons_container, padding=(0, 0, 0, 0))
|
||
self._addon_button_rows["uebersetzer"] = uebersetzer_row
|
||
uebersetzer_row.pack(fill="x")
|
||
RoundedButton(
|
||
uebersetzer_row, "Übersetzen", command=self._open_uebersetzer,
|
||
bg="#A8B8C0", fg="#1a4d6d", active_bg="#98A8B0", width=180, height=38, canvas_bg="#A8B8C0",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# E-Mail Button
|
||
email_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["email"] = email_row
|
||
email_row.pack(fill="x")
|
||
RoundedButton(
|
||
email_row, "E-Mail", command=self._open_email,
|
||
bg="#B8C8D0", fg="#1a4d6d", active_bg="#A8B8C0", width=180, height=38, canvas_bg="#B8C8D0",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# Autotext Button
|
||
autotext_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["autotext"] = autotext_row
|
||
autotext_row.pack(fill="x")
|
||
RoundedButton(
|
||
autotext_row, "Autotext", command=self._open_autotext_dialog,
|
||
bg="#CADFE8", fg="#1a4d6d", active_bg="#BAD0E0", width=180, height=38, canvas_bg="#CADFE8",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# WhatsApp Button
|
||
whatsapp_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["whatsapp"] = whatsapp_row
|
||
whatsapp_row.pack(fill="x")
|
||
RoundedButton(
|
||
whatsapp_row, "WhatsApp", command=self._open_whatsapp,
|
||
bg="#D8E8F0", fg="#1a4d6d", active_bg="#C8D8E8", width=180, height=38, canvas_bg="#D8E8F0",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# DocApp Button
|
||
docapp_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["docapp"] = docapp_row
|
||
docapp_row.pack(fill="x")
|
||
RoundedButton(
|
||
docapp_row, "MedWork", command=self._open_docapp,
|
||
bg="#E8F4F8", fg="#1a4d6d", active_bg="#D8E8F0", width=180, height=38, canvas_bg="#E8F4F8",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# To-do Button
|
||
todo_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["todo"] = todo_row
|
||
todo_row.pack(fill="x")
|
||
RoundedButton(
|
||
todo_row, "To-do", command=self._open_todo_window,
|
||
bg="#F0F8FB", fg="#1a4d6d", active_bg="#E0F0F5", width=180, height=38, canvas_bg="#F0F8FB",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# Arbeitsplan Button
|
||
arbeitsplan_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["arbeitsplan"] = arbeitsplan_row
|
||
arbeitsplan_row.pack(fill="x")
|
||
RoundedButton(
|
||
arbeitsplan_row, "Arbeitsplan", command=self._open_arbeitsplan_window,
|
||
bg="#F8FCFE", fg="#1a4d6d", active_bg="#EAF4F8", width=180, height=38, canvas_bg="#F8FCFE",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# Macro Button
|
||
macro_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["macro"] = macro_row
|
||
macro_row.pack(fill="x")
|
||
RoundedButton(
|
||
macro_row, "Makro starten", command=self._open_macro,
|
||
bg="#EEF7FB", fg="#1a4d6d", active_bg="#DDECF4", width=180, height=38, canvas_bg="#EEF7FB",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
kongress_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["kongresse"] = kongress_row
|
||
kongress_row.pack(fill="x")
|
||
RoundedButton(
|
||
kongress_row, "Kongresse", command=self._open_kongress_window,
|
||
bg="#e3ecf4", fg="#1e4060", active_bg="#d4e0ee", width=180, height=38, canvas_bg="#EEF7FB",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
news_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["news"] = news_row
|
||
news_row.pack(fill="x")
|
||
RoundedButton(
|
||
news_row, "News", command=self._open_news_window,
|
||
bg="#e6f0e8", fg="#1a5a3a", active_bg="#d6e8da", width=180, height=38, canvas_bg="#EEF7FB",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
empfang_row = ttk.Frame(self._addon_buttons_container, padding=(0, 4, 0, 0))
|
||
self._addon_button_rows["empfang"] = empfang_row
|
||
empfang_row.pack(fill="x")
|
||
RoundedButton(
|
||
empfang_row, "An Empfang senden", command=self._send_to_empfang,
|
||
bg="#f0e6f6", fg="#4a1a6d", active_bg="#e4d4f0", width=180, height=38, canvas_bg="#f0e6f6",
|
||
radius=0,
|
||
).pack(anchor="center")
|
||
|
||
# Initiales Ein-/Ausblenden der Buttons basierend auf Einstellungen
|
||
self._update_addon_buttons_visibility()
|
||
|
||
self._addon_container.pack(fill="x", before=self._addon_anchor)
|
||
if not self._autotext_data.get("addon_visible", True):
|
||
self._addon_container.pack_forget()
|
||
self._addon_spacer_frame = self._addon_anchor # Referenz für Textblöcke (pack before)
|
||
|
||
right_top = ttk.Frame(right)
|
||
right_top.pack(fill="x", pady=(0, 4))
|
||
self.btn_make_kg = RoundedButton(
|
||
right_top, "KG erstellen", command=self.make_kg_from_text,
|
||
width=90, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_make_kg.pack(side="left", padx=(4, 0))
|
||
self.btn_copy = RoundedButton(
|
||
right_top, "KG kopieren", command=self.copy_output,
|
||
width=80, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_copy.pack(side="left", padx=(4, 0))
|
||
self.btn_macro1 = RoundedButton(
|
||
right_top, "Macro 1", command=self._run_macro1,
|
||
width=78, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self.btn_macro1.pack(side="left", padx=(4, 0))
|
||
self.btn_macro1.bind("<Button-3>", lambda e: self._record_macro1())
|
||
|
||
self._btn_kommentare = RoundedButton(
|
||
right_top, "Kommentare", command=self._open_kommentare_fenster,
|
||
bg="#27AE60", fg="white", active_bg="#1E8449", width=95, height=28, canvas_bg="#B9ECFA",
|
||
)
|
||
self._btn_kommentare.pack(side="left", padx=(12, 0))
|
||
self._scalable_widgets.append(self._btn_kommentare)
|
||
add_tooltip(self._btn_kommentare, "Medizinische Kurzkommentare zum KG-Inhalt")
|
||
|
||
_chk_col = tk.Frame(right_top, bg="#B9ECFA")
|
||
_chk_col.pack(side="left", padx=(6, 0))
|
||
|
||
self._kommentare_auto_var = tk.BooleanVar(value=self._autotext_data.get("kommentare_auto_open", False))
|
||
self._chk_kommentare_auto = tk.Checkbutton(
|
||
_chk_col, text="Kommentare anzeigen", variable=self._kommentare_auto_var,
|
||
font=("Segoe UI", 8), bg="#B9ECFA", fg="#333", activebackground="#B9ECFA",
|
||
selectcolor="#B9ECFA", command=self._toggle_kommentare_auto,
|
||
)
|
||
self._chk_kommentare_auto.pack(anchor="w")
|
||
add_tooltip(self._chk_kommentare_auto, "Kommentare-Fenster nach KG-Erstellung automatisch anzeigen")
|
||
|
||
self._empfang_auto_var = tk.BooleanVar(value=self._autotext_data.get("empfang_auto_open", False))
|
||
self._chk_empfang_auto = tk.Checkbutton(
|
||
_chk_col, text="Empfang", variable=self._empfang_auto_var,
|
||
font=("Segoe UI", 8), bg="#B9ECFA", fg="#333", activebackground="#B9ECFA",
|
||
selectcolor="#B9ECFA", command=self._toggle_empfang_auto,
|
||
)
|
||
self._chk_empfang_auto.pack(anchor="w")
|
||
add_tooltip(self._chk_empfang_auto, "Empfang-Fenster nach KG-Erstellung automatisch öffnen")
|
||
|
||
# Vertikales PanedWindow für KG (verstellbare Höhe)
|
||
self.paned_kg = ttk.PanedWindow(right, orient="vertical")
|
||
self.paned_kg.pack(fill="both", expand=True)
|
||
self.paned_kg.bind("<Configure>", self._on_paned_configure)
|
||
|
||
top_right = ttk.Frame(self.paned_kg)
|
||
bottom_right = ttk.Frame(self.paned_kg)
|
||
self.paned_kg.add(top_right, weight=1)
|
||
self.paned_kg.add(bottom_right, weight=0)
|
||
|
||
# Frame für Label + Schriftgröen-Spinbox
|
||
kg_header = ttk.Frame(top_right)
|
||
kg_header.pack(fill="x", anchor="w")
|
||
|
||
self._kg_collapsed = False
|
||
self._kg_toggle_label = tk.Label(
|
||
kg_header, text="\u25BC Krankengeschichte:",
|
||
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d", cursor="hand2")
|
||
self._kg_toggle_label.pack(side="left")
|
||
self._kg_toggle_label.bind("<Button-1>", self._toggle_kg_collapse)
|
||
|
||
self._kg_frame = ttk.Frame(top_right)
|
||
self._kg_frame.pack(fill="both", expand=True)
|
||
self.txt_output = ScrolledText(
|
||
self._kg_frame, wrap="word", height=22, font=self._text_font, bg="#F5FCFF"
|
||
)
|
||
self.txt_output.pack(fill="both", expand=True)
|
||
self._bind_textblock_pending(self.txt_output)
|
||
self._bind_kg_section_copy(self.txt_output)
|
||
|
||
# Schriftgröen-Spinbox für KG
|
||
add_text_font_size_control(kg_header, self.txt_output, initial_size=10, bg_color="#B9ECFA", save_key="main_kg")
|
||
|
||
# SOAP-Abschnitts-Steuerung (A±, S±, O±, B±, D±, T±, P±) einklappbar
|
||
_SOAP_BG = "#B9ECFA"
|
||
soap_toggle_frame = tk.Frame(bottom_right, bg=_SOAP_BG)
|
||
soap_toggle_frame.pack(fill="x")
|
||
|
||
self._soap_collapsed = self._autotext_data.get("soap_collapsed", False)
|
||
self._soap_toggle_label = tk.Label(
|
||
soap_toggle_frame,
|
||
text="\u25B6 SOAP:" if self._soap_collapsed else "\u25BC SOAP:",
|
||
font=("Segoe UI", 10), bg=_SOAP_BG, fg="#1a4d6d", cursor="hand2")
|
||
self._soap_toggle_label.pack(anchor="w")
|
||
self._soap_toggle_label.bind("<Button-1>", self._toggle_soap_collapse)
|
||
|
||
soap_ctrl_bar = tk.Frame(bottom_right, bg=_SOAP_BG, padx=4, pady=4)
|
||
self._soap_container = soap_ctrl_bar
|
||
if not self._soap_collapsed:
|
||
soap_ctrl_bar.pack(fill="x")
|
||
|
||
self._soap_anchor = tk.Frame(bottom_right, bg=_SOAP_BG, height=0)
|
||
self._soap_anchor.pack(fill="x")
|
||
self._soap_section_labels = {}
|
||
self._soap_section_levels = load_soap_section_levels()
|
||
|
||
self._soap_inner = tk.Frame(soap_ctrl_bar, bg=_SOAP_BG)
|
||
self._soap_inner.pack(anchor="center")
|
||
self._soap_bg = _SOAP_BG
|
||
self._rebuild_soap_section_controls()
|
||
|
||
# KG-Bearbeitungs-Buttons (Kürzer / Ausführlicher / Vorlage)
|
||
# Zeile 1: gleiche Farbe wie KG erstellen (#7EC8E3)
|
||
_row1_bg, _row1_active = "#7EC8E3", "#6CB8D3"
|
||
kg_edit_bar = tk.Frame(soap_ctrl_bar, bg="#B9ECFA", pady=4)
|
||
kg_edit_bar.pack(fill="x")
|
||
self.btn_kg_kuerzer = RoundedButton(
|
||
kg_edit_bar, "Kürzer", command=self._kg_kuerzer,
|
||
width=120, height=28, canvas_bg="#B9ECFA", bg=_row1_bg, fg="#1a4d6d", active_bg=_row1_active,
|
||
)
|
||
self.btn_kg_kuerzer.pack(side="left")
|
||
self.btn_kg_ausfuehrlicher = RoundedButton(
|
||
kg_edit_bar, "Ausführlicher", command=self._kg_ausfuehrlicher,
|
||
width=140, height=28, canvas_bg="#B9ECFA", bg=_row1_bg, fg="#1a4d6d", active_bg=_row1_active,
|
||
)
|
||
self.btn_kg_ausfuehrlicher.pack(side="left", padx=(8, 0))
|
||
self.btn_kg_vorlage = RoundedButton(
|
||
kg_edit_bar, "Vorlage", command=self._open_kg_vorlage,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row1_bg, fg="#1a4d6d", active_bg=_row1_active,
|
||
)
|
||
self.btn_kg_vorlage.pack(side="left", padx=(8, 0))
|
||
self._update_kg_detail_display()
|
||
|
||
# Dokumente-Gruppe (Brief, Rezept, Korrektur) einklappbar
|
||
dokumente_header_frame = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
dokumente_header_frame.pack(fill="x", pady=(6, 0))
|
||
|
||
self._dokumente_collapsed = self._autotext_data.get("dokumente_collapsed", False)
|
||
self._dokumente_toggle_label = tk.Label(
|
||
dokumente_header_frame,
|
||
text="\u25B6 Dokumente:" if self._dokumente_collapsed else "\u25BC Dokumente:",
|
||
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d", cursor="hand2")
|
||
self._dokumente_toggle_label.pack(anchor="w")
|
||
self._dokumente_toggle_label.bind("<Button-1>", self._toggle_dokumente_collapse)
|
||
|
||
self._dokumente_container = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
self._dokumente_container.pack(fill="x")
|
||
|
||
self._dokumente_anchor = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
self._dokumente_anchor.pack(fill="x")
|
||
|
||
# Zeile 2 (+15% heller): Brief, Rezept, OP-Bericht
|
||
_row2_bg, _row2_active = "#97D3E9", "#87C3D9"
|
||
letter_bar = ttk.Frame(self._dokumente_container, padding=(0, 4), style="TranscriptBar.TFrame")
|
||
letter_bar.pack(fill="x")
|
||
self.btn_letter = RoundedButton(
|
||
letter_bar, "Brief", command=self.open_brief_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row2_bg, fg="#1a4d6d", active_bg=_row2_active,
|
||
)
|
||
self.btn_letter.pack(side="left")
|
||
self.btn_recipe = RoundedButton(
|
||
letter_bar, "Rezept", command=self.open_rezept_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row2_bg, fg="#1a4d6d", active_bg=_row2_active,
|
||
)
|
||
self.btn_recipe.pack(side="left", padx=(8, 0))
|
||
self.btn_op_bericht = RoundedButton(
|
||
letter_bar, "OP-Bericht", command=self.open_op_bericht_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row2_bg, fg="#1a4d6d", active_bg=_row2_active,
|
||
)
|
||
self.btn_op_bericht.pack(side="left", padx=(8, 0))
|
||
|
||
# Zeile 3 (+30% heller): KOGU, Diskussion mit KI, Arztzeugnis
|
||
_row3_bg, _row3_active = "#B0DEEF", "#A0CEDF"
|
||
kogu_bar = ttk.Frame(self._dokumente_container, padding=(0, 2), style="TranscriptBar.TFrame")
|
||
kogu_bar.pack(fill="x")
|
||
self.btn_kogu = RoundedButton(
|
||
kogu_bar, "KOGU", command=self.open_kogu_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row3_bg, fg="#1a4d6d", active_bg=_row3_active,
|
||
)
|
||
self.btn_kogu.pack(side="left")
|
||
self.btn_diskussion = RoundedButton(
|
||
kogu_bar, "Diskussion mit KI", command=self.open_diskussion_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row3_bg, fg="#1a4d6d", active_bg=_row3_active,
|
||
)
|
||
self.btn_diskussion.pack(side="left", padx=(8, 0))
|
||
self.btn_arztzeugnis = RoundedButton(
|
||
kogu_bar, "Arztzeugnis", command=self._open_arztzeugnis,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row3_bg, fg="#1a4d6d", active_bg=_row3_active,
|
||
)
|
||
self.btn_arztzeugnis.pack(side="left", padx=(8, 0))
|
||
|
||
# Zeile 4 (+45% heller): KI-Kontrolle, Korrektur
|
||
_row4_bg, _row4_active = "#C9E9F5", "#B9D9E5"
|
||
korrektur_bar = ttk.Frame(self._dokumente_container, padding=(0, 2), style="TranscriptBar.TFrame")
|
||
korrektur_bar.pack(fill="x")
|
||
self.btn_ki_pruefen = RoundedButton(
|
||
korrektur_bar, "KI-Kontrolle", command=self.open_ki_pruefen,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row4_bg, fg="#1a4d6d", active_bg=_row4_active,
|
||
)
|
||
self.btn_ki_pruefen.pack(side="left")
|
||
self.btn_korrektur = RoundedButton(
|
||
korrektur_bar, "Korrektur", command=self.open_pruefen_window,
|
||
width=100, height=28, canvas_bg="#B9ECFA", bg=_row4_bg, fg="#1a4d6d", active_bg=_row4_active,
|
||
)
|
||
self.btn_korrektur.pack(side="left", padx=(8, 0))
|
||
|
||
if self._dokumente_collapsed:
|
||
self._dokumente_container.pack_forget()
|
||
|
||
# Textblöcke unterhalb der Button-Reihen (rechte Seite unten)
|
||
textbloecke_header_frame = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
textbloecke_header_frame.pack(fill="x", pady=(6, 0))
|
||
|
||
self._textbloecke_collapsed = self._autotext_data.get("textbloecke_collapsed", False)
|
||
self._textbloecke_toggle_label = tk.Label(textbloecke_header_frame,
|
||
text="▶ Textblöcke:" if self._textbloecke_collapsed else "▼ Textblöcke:",
|
||
font=("Segoe UI", 10), bg="#B9ECFA", fg="#1a4d6d",
|
||
cursor="hand2")
|
||
self._textbloecke_toggle_label.pack(anchor="w")
|
||
self._textbloecke_toggle_label.bind("<Button-1>", self._toggle_textbloecke_collapse)
|
||
|
||
self._textbloecke_container = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
self._textbloecke_container.pack(fill="x")
|
||
|
||
self._textbloecke_anchor = ttk.Frame(bottom_right, style="TranscriptBar.TFrame")
|
||
self._textbloecke_anchor.pack(fill="x")
|
||
|
||
self._textbloecke_data = load_textbloecke()
|
||
self._removed_textbloecke = []
|
||
self._last_focused_text_widget = None
|
||
self._last_insert_index = "1.0"
|
||
self._textblock_drag_data = {"text": None, "active": False}
|
||
self._just_dropped_on_textblock = False
|
||
self._textbloecke_buttons = []
|
||
self._textbloecke_rows_frame = ttk.Frame(self._textbloecke_container)
|
||
self._textbloecke_rows_frame.pack(fill="x")
|
||
self._rebuild_textblock_buttons()
|
||
|
||
plus_minus_row = ttk.Frame(self._textbloecke_container, padding=(0, 4, 0, 0))
|
||
plus_minus_row.pack(fill="x")
|
||
pm_inner = ttk.Frame(plus_minus_row)
|
||
pm_inner.pack(anchor="center")
|
||
RoundedButton(pm_inner, "+", command=self._add_textblock, width=28, height=24, canvas_bg="#B9ECFA",
|
||
bg="#e1f6fc", fg="#1a4d6d", active_bg="#B8E8F4", radius=0).pack(side="left", padx=(0, 4))
|
||
RoundedButton(pm_inner, "−", command=self._remove_textblock, width=28, height=24, canvas_bg="#B9ECFA",
|
||
bg="#e1f6fc", fg="#1a4d6d", active_bg="#B8E8F4", radius=0).pack(side="left")
|
||
|
||
if self._textbloecke_collapsed:
|
||
self._textbloecke_container.pack_forget()
|
||
|
||
self.bind_all("<B1-Motion>", self._textblock_on_drag_motion)
|
||
self.bind_all("<ButtonRelease-1>", self._on_global_drag_release)
|
||
|
||
if not self._autotext_data.get("textbloecke_visible", True):
|
||
self._textbloecke_container.pack_forget()
|
||
|
||
self._bottom_frame = ttk.Frame(self, padding=(10, 0, 10, 10), style="StatusBar.TFrame")
|
||
self._bottom_frame.pack(fill="x")
|
||
ttk.Label(
|
||
self._bottom_frame,
|
||
text="Ablauf: ⏺ Start ⏹ Stopp automatische Transkription automatische KG. "
|
||
"⏺ Korrigieren ergänzt bestehende KG. KG erstellen erzeugt KG aus Text."
|
||
).pack(side="left", anchor="w")
|
||
|
||
add_resize_grip(self)
|
||
|
||
# GANZ LINKS & WEITER OBEN: "AzA" Text + Logo (über dem Resize-Grip!)
|
||
self._logo_frame = tk.Frame(self, bg="#B9ECFA")
|
||
self._logo_frame.place(relx=0.01, rely=0.97, anchor="sw")
|
||
|
||
# Internes Branding-Logo (logo.png)
|
||
self._logo_label = None
|
||
try:
|
||
logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logo.png")
|
||
if os.path.exists(logo_path):
|
||
from PIL import Image
|
||
img = Image.open(logo_path).convert("RGBA")
|
||
img_small = img.resize((82, 82), Image.Resampling.LANCZOS)
|
||
self.bottom_logo_photo = ImageTk.PhotoImage(img_small)
|
||
self._logo_label = tk.Label(
|
||
self._logo_frame, image=self.bottom_logo_photo,
|
||
bg="#B9ECFA", cursor="hand2")
|
||
self._logo_label.pack(side="left", padx=(0, 10))
|
||
self._logo_label.bind("<Button-1>", self._on_logo_click)
|
||
add_tooltip(self._logo_label, "Klick: Aufnahme starten / stoppen")
|
||
except Exception as e:
|
||
print(f"Bottom-Logo konnte nicht geladen werden: {e}")
|
||
|
||
# Text-Teil DANACH (rechts vom Logo)
|
||
text_frame = tk.Frame(self._logo_frame, bg="#B9ECFA")
|
||
text_frame.pack(side="left")
|
||
|
||
aza_label1 = tk.Label(text_frame, text="AzA von Arzt zu Arzt",
|
||
bg="#B9ECFA", fg="#1a4d6d",
|
||
font=("Segoe UI", 19, "bold"))
|
||
aza_label1.pack(anchor="w")
|
||
|
||
aza_label2 = tk.Label(text_frame, text="Informatik zu fairen Preisen",
|
||
bg="#B9ECFA", fg="#1a4d6d",
|
||
font=("Segoe UI", 11))
|
||
aza_label2.pack(anchor="w")
|
||
|
||
if not self._autotext_data.get("logo_visible", True):
|
||
self._logo_frame.place_forget()
|
||
|
||
|
||
# Initiale Schriftgröen- und Button-Gröen-Anwendung beim Start
|
||
self.after(100, lambda: self._apply_font_scale(load_font_scale()))
|
||
self.after(150, lambda: self._apply_button_scale(load_button_scale()))
|
||
|
||
# To-do wird nicht mehr automatisch beim Start geöffnet;
|
||
# Öffnung nur noch über den To-do-Button.
|
||
|
||
self._backend_status_after_id = None
|
||
self.after(800, self._refresh_backend_status)
|
||
|
||
def _on_configure(self, event):
|
||
"""Nach Verschieben/Resize: Gröe und Position mit Verzögerung speichern (nur Hauptfenster)."""
|
||
# KRITISCH: Supprimiere Speichern waehrend Docking!
|
||
if getattr(self, "_tiling_active", False):
|
||
return
|
||
if event.widget is not self:
|
||
return
|
||
if self._geometry_save_after_id:
|
||
self.after_cancel(self._geometry_save_after_id)
|
||
self._geometry_save_after_id = self.after(400, self._save_window_geometry)
|
||
|
||
def _on_paned_configure(self, event):
|
||
"""Nach Verschieben eines Teilers (Breite oder Transkript/KG-Höhe): mit Verzögerung speichern."""
|
||
# KRITISCH: Supprimiere waehrend Docking!
|
||
if getattr(self, "_tiling_active", False):
|
||
print(f"[_on_paned_configure] BLOCKED: tiling_active={self._tiling_active}")
|
||
return
|
||
if event.widget is not self.paned and event.widget is not self.paned_transcript and event.widget is not self.paned_kg:
|
||
return
|
||
if self._geometry_save_after_id:
|
||
self.after_cancel(self._geometry_save_after_id)
|
||
self._geometry_save_after_id = self.after(400, self._save_window_geometry)
|
||
|
||
def _restore_sash(self):
|
||
"""Stellt gespeicherte Sash-Positionen (Breite Transkript/KG, Höhe Transkript und KG) wieder her. Standard: Transkript 1/3 der Breite."""
|
||
# Gespeicherte PanedWindow-Positionen laden
|
||
paned_positions = load_paned_positions()
|
||
|
||
try:
|
||
w = self.winfo_width()
|
||
if w < 200:
|
||
w = 800
|
||
sash = self._saved_sash if self._saved_sash is not None else (w // 3)
|
||
sash = max(150, min(sash, w - 150))
|
||
self.paned.sashpos(0, sash)
|
||
except Exception:
|
||
pass
|
||
|
||
# Transkript vertikale Position wiederherstellen
|
||
try:
|
||
left_h = self.paned_transcript.winfo_height()
|
||
if left_h < 100:
|
||
left_h = 400
|
||
|
||
# Versuche gespeicherte Position zu laden
|
||
saved_transcript_pos = paned_positions.get("transcript_vertical")
|
||
if saved_transcript_pos is not None:
|
||
sash_v = max(80, min(saved_transcript_pos, max(80, left_h - 80)))
|
||
elif self._saved_sash_transcript is not None:
|
||
sash_v = max(80, min(self._saved_sash_transcript, max(80, left_h - 80)))
|
||
else:
|
||
sash_v = min(150, max(80, left_h - 80))
|
||
self.paned_transcript.sashpos(0, sash_v)
|
||
except Exception:
|
||
pass
|
||
|
||
# Krankengeschichte vertikale Position wiederherstellen
|
||
try:
|
||
right_h = self.paned_kg.winfo_height()
|
||
if right_h < 100:
|
||
right_h = 400
|
||
|
||
# Versuche gespeicherte Position zu laden
|
||
saved_kg_pos = paned_positions.get("kg_vertical")
|
||
if saved_kg_pos is not None:
|
||
sash_v = max(120, min(saved_kg_pos, max(120, right_h - 80)))
|
||
else:
|
||
# Standard: ca. 70% für KG Text, 30% für Buttons
|
||
sash_v = max(200, int(right_h * 0.70))
|
||
self.paned_kg.sashpos(0, sash_v)
|
||
except Exception:
|
||
pass
|
||
|
||
def _ensure_centered(self):
|
||
"""Zentriert das Hauptfenster robust nach vollstaendigem Widget-Aufbau."""
|
||
if self._center_done:
|
||
return
|
||
self._center_done = True
|
||
try:
|
||
self.update_idletasks()
|
||
w = self.winfo_width()
|
||
h = self.winfo_height()
|
||
if w < 400 or h < 300:
|
||
w = self._initial_w
|
||
h = self._initial_h
|
||
sw = self.winfo_screenwidth()
|
||
sh = self.winfo_screenheight()
|
||
x = max(0, (sw - w) // 2)
|
||
y = max(0, (sh - h) // 2)
|
||
self.geometry(f"{w}x{h}+{x}+{y}")
|
||
except Exception:
|
||
pass
|
||
|
||
def _save_window_geometry(self):
|
||
self._geometry_save_after_id = None
|
||
if getattr(self, "_tiling_active", False):
|
||
return
|
||
if getattr(self, "_minimized", False):
|
||
return
|
||
try:
|
||
w, h = self.winfo_width(), self.winfo_height()
|
||
x, y = self.winfo_x(), self.winfo_y()
|
||
if w >= 400 and h >= 300:
|
||
sash = None
|
||
sash_transcript = None
|
||
try:
|
||
sash = self.paned.sashpos(0)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
sash_transcript = self.paned_transcript.sashpos(0)
|
||
except Exception:
|
||
pass
|
||
save_window_geometry(w, h, x, y, sash, sash_transcript)
|
||
|
||
# Auch die vertikalen PanedWindow-Positionen speichern
|
||
paned_positions = {}
|
||
try:
|
||
paned_positions["transcript_vertical"] = self.paned_transcript.sashpos(0)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
paned_positions["kg_vertical"] = self.paned_kg.sashpos(0)
|
||
except Exception:
|
||
pass
|
||
|
||
if paned_positions:
|
||
save_paned_positions(paned_positions)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_font_scale(self, scale: float):
|
||
"""Wendet nur den Schriftgröen-Skalierungsfaktor auf Text-Widgets an."""
|
||
try:
|
||
# Aktualisiere Text-Widgets (ScrolledText, Text, Label)
|
||
# Basis-Gröe 16, damit bei Scale 0.3-0.8 die Schrift lesbar bleibt
|
||
base_size = 16
|
||
new_size = max(5, int(base_size * scale))
|
||
new_font = (self._text_font[0], new_size)
|
||
|
||
# Aktualisiere Status-Label
|
||
if hasattr(self, 'lbl_status'):
|
||
self.lbl_status.configure(font=new_font)
|
||
|
||
# Aktualisiere Text-Widgets
|
||
for txt_widget in [self.txt_transcript, self.txt_output]:
|
||
if txt_widget and txt_widget.winfo_exists():
|
||
txt_widget.configure(font=new_font)
|
||
|
||
# Skaliere Schrift in allen RoundedButtons
|
||
for widget in self.winfo_children():
|
||
self._scale_font_recursive(widget, scale)
|
||
|
||
except Exception as e:
|
||
pass
|
||
|
||
def _apply_font_scale_global(self, scale: float):
|
||
"""Wendet Schriftgröen-Skalierung auf ALLE offenen Fenster an."""
|
||
self._apply_font_scale(scale)
|
||
for win in _ALL_WINDOWS:
|
||
try:
|
||
if win and win.winfo_exists():
|
||
scale_window_fonts(win, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_button_scale(self, scale: float):
|
||
"""Wendet nur den Button-Gröen-Skalierungsfaktor an."""
|
||
try:
|
||
for widget in self.winfo_children():
|
||
self._scale_button_size_recursive(widget, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_button_scale_global(self, scale: float):
|
||
"""Wendet Button-Gröen-Skalierung auf ALLE offenen Fenster an."""
|
||
self._apply_button_scale(scale)
|
||
for win in _ALL_WINDOWS:
|
||
try:
|
||
if win and win.winfo_exists():
|
||
scale_window_buttons(win, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def update_token_display(self):
|
||
"""Aktualisiert die Token-Anzeige (Budget-basiert)."""
|
||
try:
|
||
token_data = load_token_usage()
|
||
used_dollars = token_data.get("used_dollars", 0)
|
||
budget_dollars = token_data.get("budget_dollars", 50)
|
||
|
||
if budget_dollars > 0:
|
||
remaining_percent = int(((budget_dollars - used_dollars) / budget_dollars * 100))
|
||
remaining_percent = max(0, min(100, remaining_percent))
|
||
else:
|
||
remaining_percent = 100
|
||
|
||
self._token_label.configure(
|
||
text=f" {remaining_percent}%",
|
||
foreground="#BD4500" if remaining_percent < 20 else ("#FF8C00" if remaining_percent < 50 else "#1a4d6d")
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _fetch_and_update_usage(self):
|
||
"""Ruft echte Verbrauchs-Daten von OpenAI ab (läuft in Thread) - OHNE Statusmeldungen."""
|
||
try:
|
||
usage_data = fetch_openai_usage(self.client)
|
||
|
||
if usage_data and usage_data.get("success"):
|
||
used_dollars = usage_data.get("used_dollars", 0)
|
||
save_token_usage(used_dollars=used_dollars)
|
||
# KEINE Statusmeldung mehr - stört den Benutzer
|
||
self.after(0, self.update_token_display)
|
||
# KEINE Fehlermeldungen mehr bei API-Problemen
|
||
except Exception:
|
||
pass # Fehler still ignorieren
|
||
|
||
def _open_budget_settings(self):
|
||
"""Dialog zum Einstellen des monatlichen Budgets."""
|
||
token_data = load_token_usage()
|
||
current_budget = token_data.get("budget_dollars", 50)
|
||
|
||
new_budget = simpledialog.askfloat(
|
||
"Monatliches Budget",
|
||
"Ihr monatliches OpenAI-Budget in Dollar:\n(z.B. 50 für $50/Monat)",
|
||
initialvalue=current_budget,
|
||
minvalue=1,
|
||
maxvalue=10000
|
||
)
|
||
|
||
if new_budget:
|
||
save_token_usage(budget_dollars=new_budget)
|
||
self.update_token_display()
|
||
self.set_status(f"Budget auf ${new_budget:.2f} gesetzt.")
|
||
|
||
def _scale_font_recursive(self, widget, scale: float):
|
||
"""Rekursive Funktion zum Skalieren der Schriftgröe in RoundedButtons."""
|
||
try:
|
||
if isinstance(widget, RoundedButton):
|
||
widget.set_font_size_scale(scale)
|
||
for child in widget.winfo_children():
|
||
self._scale_font_recursive(child, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _scale_button_size_recursive(self, widget, scale: float):
|
||
"""Rekursive Funktion zum Skalieren der Button-Gröe in RoundedButtons."""
|
||
try:
|
||
if isinstance(widget, RoundedButton):
|
||
widget.set_button_size_scale(scale)
|
||
for child in widget.winfo_children():
|
||
self._scale_button_size_recursive(child, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _scale_widget_recursive(self, widget, scale: float):
|
||
"""Legacy-Methode für Rückwärtskompatibilität."""
|
||
try:
|
||
if isinstance(widget, RoundedButton):
|
||
widget.set_font_scale(scale)
|
||
for child in widget.winfo_children():
|
||
self._scale_widget_recursive(child, scale)
|
||
except Exception:
|
||
pass
|
||
|
||
def _check_old_kg_entries(self):
|
||
"""Prüft KG-Einträge älter als 2 Wochen: bei Bestätigung löschen (oder automatisch, wenn Einstellung aktiv)."""
|
||
try:
|
||
old = get_old_kg_entries(14)
|
||
if not old:
|
||
return
|
||
auto = getattr(self, "_autotext_data", {}).get("kg_auto_delete_old", False)
|
||
if auto:
|
||
n = delete_kg_entries_older_than(14)
|
||
if n > 0:
|
||
self.set_status(f"{n} KG-Einträge älter als 2 Wochen automatisch gelöscht.")
|
||
return
|
||
if messagebox.askyesno(
|
||
"KG-Aufräumen",
|
||
f"Es gibt {len(old)} KG-Einträge älter als 2 Wochen.\n\nSollen diese gelöscht werden, um Speicher zu schonen?",
|
||
):
|
||
n = delete_kg_entries_older_than(14)
|
||
if n > 0:
|
||
self.set_status(f"{n} KG-Einträge gelöscht.")
|
||
except Exception:
|
||
pass
|
||
|
||
def _persist_session_before_shutdown(self):
|
||
"""Speichert Empfangs-/Autotext-Status und Ablage-Texte (ohne Tk zu zerstören)."""
|
||
try:
|
||
_edlg = getattr(self, "_empfang_dlg", None)
|
||
_empfang_open = _edlg is not None and _edlg.winfo_exists()
|
||
self._autotext_data["empfang_was_open"] = _empfang_open
|
||
save_autotext(self._autotext_data)
|
||
except Exception:
|
||
pass
|
||
|
||
def _str(w):
|
||
if w is None:
|
||
return ""
|
||
if hasattr(w, "get"):
|
||
try:
|
||
s = w.get("1.0", "end")
|
||
return (s if isinstance(s, str) else str(s)).strip()
|
||
except Exception:
|
||
return ""
|
||
return (str(w)).strip()
|
||
|
||
kg_text = _str(self.txt_output)
|
||
brief_text = _str(getattr(self, "_last_brief_text", None))
|
||
rezept_text = _str(getattr(self, "_last_rezept_text", None))
|
||
kogu_text = _str(getattr(self, "_last_kogu_text", None))
|
||
try:
|
||
if kg_text:
|
||
save_to_ablage("KG", kg_text)
|
||
if brief_text:
|
||
save_to_ablage("Briefe", brief_text)
|
||
if rezept_text:
|
||
save_to_ablage("Rezepte", rezept_text)
|
||
if kogu_text:
|
||
save_to_ablage("Kostengutsprachen", kogu_text)
|
||
except Exception:
|
||
pass
|
||
self._save_window_geometry()
|
||
try:
|
||
save_button_heat()
|
||
except Exception:
|
||
pass
|
||
|
||
def shutdown_app_completely(self, *, reason: str = ""):
|
||
"""Zentrales, idempotentes Beenden: Hooks stoppen, Tk mainloop verlassen."""
|
||
if getattr(self, "_shutdown_done", False):
|
||
self._lifecycle_shutdown_debug(
|
||
"shutdown_app_completely skipped (already done)")
|
||
return
|
||
self._shutdown_done = True
|
||
self._shutdown_in_progress = True
|
||
self._global_autotext_engine_state = "stopped"
|
||
try:
|
||
ev = getattr(self, "_autotext_hook_stop_event", None)
|
||
if ev is not None:
|
||
ev.set()
|
||
except Exception:
|
||
pass
|
||
|
||
with self._autotext_kbd_listener_lock:
|
||
kbd = getattr(self, "_autotext_active_kbd_listener", None)
|
||
if kbd is not None:
|
||
try:
|
||
kbd.stop()
|
||
except Exception:
|
||
pass
|
||
with self._autotext_mouse_listener_lock:
|
||
mou = getattr(self, "_autotext_active_mouse_listener", None)
|
||
if mou is not None:
|
||
try:
|
||
mou.stop()
|
||
except Exception:
|
||
pass
|
||
|
||
bw = getattr(self, "_autotext_bg_win", None)
|
||
if bw is not None:
|
||
try:
|
||
bw.destroy()
|
||
except Exception:
|
||
pass
|
||
self._autotext_bg_win = None
|
||
|
||
self._lifecycle_shutdown_debug(
|
||
f"app shutdown: reason={reason if reason else 'n/a'}")
|
||
|
||
try:
|
||
self.quit()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.destroy()
|
||
except Exception:
|
||
pass
|
||
|
||
self._lifecycle_shutdown_debug("app shutdown complete")
|
||
|
||
def _dialog_autotext_close_choice(self) -> str:
|
||
"""'quit' = beenden, 'background' = nur ausblenden, 'cancel' = nichts tun."""
|
||
res = {"v": "cancel"}
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("AzA schlie\u00dfen")
|
||
dlg.resizable(False, False)
|
||
dlg.transient(self)
|
||
try:
|
||
dlg.attributes("-topmost", True)
|
||
except Exception:
|
||
pass
|
||
bf = tk.Frame(dlg, padx=16, pady=14, bg="#F0F6FB")
|
||
bf.pack(fill="both", expand=True)
|
||
tk.Label(
|
||
bf,
|
||
text=(
|
||
"Soll AZA vollst\u00e4ndig beendet werden oder soll Autotext im Hintergrund\n"
|
||
"in anderen Programmen aktiv bleiben?\n\n"
|
||
"Bei \u00abAutotext im Hintergrund\u00bb erscheint eine Mini-Steuerung.\n"
|
||
"Dort k\u00f6nnen Sie AZA wieder \u00f6ffnen, Autotext pausieren oder beenden."
|
||
),
|
||
justify="left",
|
||
bg="#F0F6FB",
|
||
fg="#1a4d6d",
|
||
font=("Segoe UI", 10),
|
||
).pack(anchor="w", pady=(0, 10))
|
||
|
||
def _pick(v):
|
||
res["v"] = v
|
||
try:
|
||
dlg.grab_release()
|
||
except Exception:
|
||
pass
|
||
dlg.destroy()
|
||
|
||
row = tk.Frame(bf, bg="#F0F6FB")
|
||
row.pack(fill="x", pady=(6, 0))
|
||
tk.Button(
|
||
row,
|
||
text="Vollst\u00e4ndig beenden",
|
||
font=("Segoe UI", 9),
|
||
width=20,
|
||
command=lambda: _pick("quit"),
|
||
).pack(side="left", padx=(0, 6))
|
||
tk.Button(
|
||
row,
|
||
text="Autotext im Hintergrund aktiv lassen",
|
||
font=("Segoe UI", 9),
|
||
command=lambda: _pick("background"),
|
||
).pack(side="left", padx=(0, 6))
|
||
tk.Button(
|
||
row,
|
||
text="Abbrechen",
|
||
font=("Segoe UI", 9),
|
||
width=10,
|
||
command=lambda: _pick("cancel"),
|
||
).pack(side="left")
|
||
|
||
dlg.protocol(
|
||
"WM_DELETE_WINDOW",
|
||
lambda: _pick("cancel"),
|
||
)
|
||
dlg.grab_set()
|
||
try:
|
||
dlg.wait_visibility()
|
||
dlg.focus_force()
|
||
except Exception:
|
||
pass
|
||
dlg.wait_window(dlg)
|
||
return res["v"]
|
||
|
||
def _offer_autotext_background_instead_of_quit(self) -> bool:
|
||
"""True wenn Schließen abgebrochen oder nur ausgeblendet wurde."""
|
||
if getattr(self, "_autotext_force_quit", False):
|
||
return False
|
||
if not _HAS_PYNPUT or sys.platform != "win32":
|
||
return False
|
||
if not bool(self._autotext_data.get("enabled", True)):
|
||
return False
|
||
if not bool(self._autotext_data.get("offer_autotext_background_close", True)):
|
||
return False
|
||
choice = self._dialog_autotext_close_choice()
|
||
self._lifecycle_shutdown_debug(
|
||
{
|
||
"background": "close requested: background_autotext=True",
|
||
"quit": "close requested: background_autotext=False(full_quit)",
|
||
"cancel": "close requested: aborted(cancel)",
|
||
}.get(choice, "close requested: unknown_choice")
|
||
)
|
||
if choice == "cancel":
|
||
return True
|
||
if choice == "quit":
|
||
return False
|
||
if choice == "background":
|
||
self._enter_autotext_background_mode()
|
||
return True
|
||
return False
|
||
|
||
def _enter_autotext_background_mode(self):
|
||
"""Hauptfenster ausblenden, Mini-Fenster mit klarer Steuerung zeigen."""
|
||
self._autotext_background_mode = True
|
||
self._autotext_global_paused = False
|
||
try:
|
||
self.withdraw()
|
||
except Exception:
|
||
pass
|
||
self._ensure_autotext_background_control_window()
|
||
|
||
def _ensure_autotext_background_control_window(self):
|
||
bw = getattr(self, "_autotext_bg_win", None)
|
||
if bw is not None:
|
||
try:
|
||
if bw.winfo_exists():
|
||
try:
|
||
bw.deiconify()
|
||
bw.lift()
|
||
except Exception:
|
||
pass
|
||
return
|
||
except tk.TclError:
|
||
pass
|
||
w = tk.Toplevel(self)
|
||
w.title("AzA Autotext")
|
||
try:
|
||
w.transient(self)
|
||
except Exception:
|
||
pass
|
||
w.resizable(False, False)
|
||
try:
|
||
w.attributes("-topmost", True)
|
||
except Exception:
|
||
pass
|
||
self._autotext_bg_win = w
|
||
f = tk.Frame(w, padx=12, pady=10, bg="#1a4d6d")
|
||
f.pack(fill="both", expand=True)
|
||
st = tk.Label(
|
||
f,
|
||
text=(
|
||
"AzA-Hauptfenster ist ausgeblendet.\n"
|
||
"Globaler Autotext bleibt in anderen Apps aktiv.\n\n"
|
||
"Mit \u00abAzA \u00f6ffnen\u00bb oder dem Schlie\u00dfen (X)\n"
|
||
"dieses Fensters kehren Sie zur Hauptoberfl\u00e4che zur\u00fcck."
|
||
),
|
||
fg="white",
|
||
bg="#1a4d6d",
|
||
justify="left",
|
||
font=("Segoe UI", 10),
|
||
)
|
||
st.pack(anchor="w")
|
||
|
||
pause_btn_ref = {}
|
||
|
||
def _sync_pause_btn():
|
||
btn = pause_btn_ref.get("b")
|
||
if not btn:
|
||
return
|
||
btn.config(
|
||
text=(
|
||
"Autotext fortsetzen"
|
||
if self._autotext_global_paused
|
||
else "Autotext pausieren"
|
||
)
|
||
)
|
||
|
||
def _toggle_pause():
|
||
self._autotext_global_paused = not self._autotext_global_paused
|
||
self._autotext_debug(f"global pause={self._autotext_global_paused}")
|
||
_sync_pause_btn()
|
||
|
||
pb = tk.Button(
|
||
f,
|
||
font=("Segoe UI", 9),
|
||
fg="#1a4d6d",
|
||
bg="#e8f4fa",
|
||
relief="flat",
|
||
cursor="hand2",
|
||
command=_toggle_pause,
|
||
)
|
||
pause_btn_ref["b"] = pb
|
||
pb.pack(fill="x", pady=(10, 4))
|
||
_sync_pause_btn()
|
||
|
||
def _open_main():
|
||
self._exit_autotext_background_mode()
|
||
|
||
tk.Button(
|
||
f,
|
||
text="AzA \u00f6ffnen",
|
||
font=("Segoe UI", 9),
|
||
fg="#1a4d6d",
|
||
bg="#cce8f4",
|
||
relief="flat",
|
||
cursor="hand2",
|
||
command=_open_main,
|
||
).pack(fill="x", pady=(2, 4))
|
||
|
||
def _finalize():
|
||
self._autotext_force_quit = True
|
||
self._autotext_background_mode = False
|
||
try:
|
||
w.destroy()
|
||
except Exception:
|
||
pass
|
||
self._autotext_bg_win = None
|
||
try:
|
||
self._persist_session_before_shutdown()
|
||
except Exception:
|
||
pass
|
||
self.shutdown_app_completely(reason="autotext_control_full_quit")
|
||
|
||
tk.Button(
|
||
f,
|
||
text="AzA vollst\u00e4ndig beenden",
|
||
font=("Segoe UI", 9),
|
||
fg="white",
|
||
bg="#aa3344",
|
||
activebackground="#882233",
|
||
relief="flat",
|
||
cursor="hand2",
|
||
command=_finalize,
|
||
).pack(fill="x", pady=(8, 0))
|
||
|
||
w.protocol("WM_DELETE_WINDOW", _open_main)
|
||
|
||
def _exit_autotext_background_mode(self):
|
||
"""Hauptfenster zurueck, Mini-Fenster schliessen."""
|
||
self._autotext_background_mode = False
|
||
bw = getattr(self, "_autotext_bg_win", None)
|
||
if bw is not None:
|
||
try:
|
||
bw.destroy()
|
||
except Exception:
|
||
pass
|
||
self._autotext_bg_win = None
|
||
try:
|
||
self.deiconify()
|
||
self.lift()
|
||
self.focus_force()
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_close(self):
|
||
"""Beim Schließen: State sichern, Diktat-/Autotext-Hintergrund-Dialog, dann ggf. vollständiges Beenden."""
|
||
try:
|
||
self._persist_session_before_shutdown()
|
||
except Exception:
|
||
pass
|
||
diktat_win = getattr(self, "_diktat_window", None)
|
||
if diktat_win is not None and diktat_win.winfo_exists():
|
||
self._main_hidden = True
|
||
self.withdraw()
|
||
return
|
||
if not getattr(self, "_autotext_force_quit", False):
|
||
try:
|
||
if self._offer_autotext_background_instead_of_quit():
|
||
return
|
||
except Exception:
|
||
pass
|
||
# Normales Schliessen / Launcher: Listener stoppen und mainloop beenden.
|
||
reason = (
|
||
"return_to_launcher"
|
||
if getattr(self, "_return_to_launcher", False)
|
||
else "wm_close_main"
|
||
)
|
||
self.shutdown_app_completely(reason=reason)
|
||
|
||
def _go_to_launcher(self):
|
||
"""Schliesst die App und kehrt zur AZA-Startseite zurück."""
|
||
save_launcher_prefs("", False)
|
||
self._return_to_launcher = True
|
||
self._autotext_force_quit = True
|
||
self._on_close()
|
||
|
||
# _open_settings -> ausgelagert in Mixin-Modul
|
||
|
||
def _open_systemstatus(self):
|
||
"""Oeffnet das Systemstatus-Fenster."""
|
||
try:
|
||
from aza_systemstatus import show_systemstatus
|
||
show_systemstatus(self)
|
||
except Exception as e:
|
||
messagebox.showerror("Systemstatus", f"Fehler:\n{e}")
|
||
|
||
def _open_autotext_dialog(self, parent=None):
|
||
"""Autotext — neue Office-Hülle (global wie bisher über basis14 Listener)."""
|
||
try:
|
||
from aza_office_workspace_ui import open_workspace_autotext_manager
|
||
|
||
open_workspace_autotext_manager(self)
|
||
except Exception as e:
|
||
messagebox.showerror("Autotext", str(e), parent=parent or self)
|
||
|
||
@staticmethod
|
||
def _hash_password(pw: str) -> str:
|
||
"""Erzeugt einen bcrypt-Hash (cost=12, Salt eingebettet)."""
|
||
return bcrypt.hashpw(pw.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
|
||
|
||
@staticmethod
|
||
def _verify_password(pw: str, stored_hash: str) -> bool:
|
||
"""Prüft Passwort gegen gespeicherten Hash (bcrypt oder Legacy-SHA-256)."""
|
||
if stored_hash.startswith("$2b$") or stored_hash.startswith("$2a$"):
|
||
return bcrypt.checkpw(pw.encode("utf-8"), stored_hash.encode("utf-8"))
|
||
legacy = hashlib.sha256(pw.encode("utf-8")).hexdigest()
|
||
return legacy == stored_hash
|
||
|
||
@staticmethod
|
||
def _is_legacy_hash(stored_hash: str) -> bool:
|
||
"""Erkennt alte SHA-256-Hashes (64 Hex-Zeichen, kein bcrypt-Prefix)."""
|
||
if not stored_hash:
|
||
return False
|
||
return not stored_hash.startswith("$2") and len(stored_hash) == 64
|
||
|
||
def _show_login_dialog(self):
|
||
"""Zeigt beim ersten Start einen Registrierungs-Dialog, danach einen Passwort-Login."""
|
||
has_profile = bool(self._user_profile.get("name"))
|
||
|
||
if has_profile and self._user_profile.get("password_hash"):
|
||
self._show_password_login()
|
||
else:
|
||
self._show_registration_dialog()
|
||
|
||
def _show_password_login(self):
|
||
"""Passwort-Abfrage für bestehende Benutzer."""
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("🔒 Anmeldung")
|
||
dlg.configure(bg="#E8F4FA")
|
||
dlg.resizable(False, False)
|
||
dlg.geometry("380x260")
|
||
self._register_window(dlg)
|
||
dlg.attributes("-topmost", True)
|
||
dlg.grab_set()
|
||
center_window(dlg, 380, 260)
|
||
|
||
tk.Label(dlg, text="🔒 Anmeldung", font=("Segoe UI", 16, "bold"),
|
||
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=12)
|
||
|
||
user_name = self._user_profile.get("name", "Benutzer")
|
||
tk.Label(dlg, text=f"Willkommen zurück, {user_name}!",
|
||
font=("Segoe UI", 10), bg="#E8F4FA", fg="#4a8aaa").pack(fill="x", padx=16, pady=(10, 2))
|
||
|
||
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
tk.Label(form, text="Passwort:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
pw_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0, show="")
|
||
pw_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
|
||
err_label = tk.Label(form, text="", font=("Segoe UI", 9), bg="#E8F4FA", fg="#E05050")
|
||
err_label.pack(fill="x")
|
||
|
||
def do_login(event=None):
|
||
pw = pw_entry.get()
|
||
if not pw:
|
||
err_label.configure(text="⚠ Bitte Passwort eingeben.")
|
||
return
|
||
stored = self._user_profile.get("password_hash", "")
|
||
if self._verify_password(pw, stored):
|
||
uid = self._user_profile.get("name", "default")
|
||
if self._is_legacy_hash(stored):
|
||
self._user_profile["password_hash"] = self._hash_password(pw)
|
||
save_user_profile(self._user_profile)
|
||
log_event("PASSWORD_REHASH", uid, detail="legacy SHA-256 -> bcrypt")
|
||
if self._user_profile.get("totp_active") and is_2fa_enabled():
|
||
dlg.destroy()
|
||
self._show_totp_login(pw)
|
||
elif is_2fa_required() and is_2fa_enabled() and not self._user_profile.get("totp_active"):
|
||
dlg.destroy()
|
||
messagebox.showinfo("2FA erforderlich",
|
||
"Zwei-Faktor-Authentifizierung ist Pflicht.\n"
|
||
"Bitte aktivieren Sie 2FA in Ihrem Profil.")
|
||
self._show_2fa_setup(pw)
|
||
else:
|
||
log_event("LOGIN_OK", uid)
|
||
self._record_login()
|
||
dlg.destroy()
|
||
else:
|
||
log_event("LOGIN_FAIL", self._user_profile.get("name", "unknown"), success=False)
|
||
err_label.configure(text="❌ Falsches Passwort. Bitte erneut versuchen.")
|
||
pw_entry.delete(0, "end")
|
||
pw_entry.focus_set()
|
||
|
||
pw_entry.bind("<Return>", do_login)
|
||
|
||
btn_frame = tk.Frame(dlg, bg="#E8F4FA")
|
||
btn_frame.pack(pady=8)
|
||
tk.Button(btn_frame, text="🔑 Anmelden", font=("Segoe UI", 11, "bold"),
|
||
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
||
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
|
||
command=do_login).pack()
|
||
|
||
def _close_pw_dialog():
|
||
self._record_login()
|
||
dlg.destroy()
|
||
|
||
dlg.protocol("WM_DELETE_WINDOW", _close_pw_dialog)
|
||
pw_entry.focus_set()
|
||
self.wait_window(dlg)
|
||
|
||
def _show_totp_login(self, password: str):
|
||
"""TOTP-Code-Abfrage nach erfolgreichem Passwort."""
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("2FA Verifizierung")
|
||
dlg.configure(bg="#E8F4FA")
|
||
dlg.resizable(False, False)
|
||
dlg.geometry("380x280")
|
||
self._register_window(dlg)
|
||
dlg.attributes("-topmost", True)
|
||
dlg.grab_set()
|
||
center_window(dlg, 380, 280)
|
||
|
||
tk.Label(dlg, text="🔐 Zwei-Faktor-Authentifizierung",
|
||
font=("Segoe UI", 14, "bold"),
|
||
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=10)
|
||
|
||
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
tk.Label(form, text="Bitte geben Sie den 6-stelligen Code\n"
|
||
"aus Ihrer Authenticator-App ein:",
|
||
font=("Segoe UI", 9), bg="#E8F4FA", fg="#4a8aaa",
|
||
justify="left").pack(anchor="w", pady=(4, 8))
|
||
|
||
tk.Label(form, text="Code:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w")
|
||
code_entry = tk.Entry(form, font=("Segoe UI", 16), bg="white",
|
||
fg="#1a4d6d", relief="flat", bd=0,
|
||
justify="center")
|
||
code_entry.pack(fill="x", ipady=6, pady=(0, 6))
|
||
|
||
err_label = tk.Label(form, text="", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#E05050")
|
||
err_label.pack(fill="x")
|
||
|
||
user_id = self._user_profile.get("name", "user")
|
||
|
||
def do_verify(event=None):
|
||
code = code_entry.get().strip()
|
||
if not code:
|
||
err_label.configure(text="⚠ Bitte Code eingeben.")
|
||
return
|
||
if is_rate_limited(user_id):
|
||
err_label.configure(text="Zu viele Versuche. Bitte warten.")
|
||
return
|
||
|
||
enc_secret = self._user_profile.get("totp_secret_enc", "")
|
||
if not enc_secret:
|
||
err_label.configure(text="2FA-Konfiguration fehlerhaft.")
|
||
return
|
||
|
||
secret = decrypt_secret(enc_secret, password)
|
||
|
||
if verify_totp(secret, code, user_id):
|
||
log_event("LOGIN_OK", user_id, detail="2FA TOTP")
|
||
log_event("2FA_OK", user_id)
|
||
self._record_login()
|
||
dlg.destroy()
|
||
return
|
||
|
||
backup_hashes = self._user_profile.get("backup_codes", [])
|
||
idx = verify_backup_code(code, backup_hashes)
|
||
if idx is not None:
|
||
self._user_profile["backup_codes"][idx] = ""
|
||
save_user_profile(self._user_profile)
|
||
remaining = sum(1 for c in self._user_profile["backup_codes"] if c)
|
||
log_event("LOGIN_OK", user_id, detail="2FA backup-code")
|
||
log_event("2FA_OK", user_id, detail=f"backup-code, verbleibend={remaining}")
|
||
self._record_login()
|
||
messagebox.showinfo("Backup-Code verwendet",
|
||
f"Backup-Code akzeptiert.\n"
|
||
f"Verbleibende Backup-Codes: {remaining}",
|
||
parent=dlg)
|
||
dlg.destroy()
|
||
return
|
||
|
||
log_event("2FA_FAIL", user_id, success=False)
|
||
err_label.configure(text="Falscher Code. Bitte erneut versuchen.")
|
||
code_entry.delete(0, "end")
|
||
code_entry.focus_set()
|
||
|
||
code_entry.bind("<Return>", do_verify)
|
||
|
||
btn_frame = tk.Frame(dlg, bg="#E8F4FA")
|
||
btn_frame.pack(pady=6)
|
||
tk.Button(btn_frame, text="Verifizieren",
|
||
font=("Segoe UI", 11, "bold"),
|
||
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
||
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
|
||
command=do_verify).pack()
|
||
|
||
dlg.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0))
|
||
code_entry.focus_set()
|
||
self.wait_window(dlg)
|
||
|
||
def _show_2fa_setup(self, password: str):
|
||
"""2FA-Aktivierungsdialog mit QR-Code und Erst-Validierung."""
|
||
secret = generate_totp_secret()
|
||
user_name = self._user_profile.get("name", "Benutzer")
|
||
uri = get_provisioning_uri(secret, user_name)
|
||
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("2FA einrichten")
|
||
dlg.configure(bg="#E8F4FA")
|
||
dlg.resizable(False, False)
|
||
dlg.geometry("420x620")
|
||
self._register_window(dlg)
|
||
dlg.attributes("-topmost", True)
|
||
dlg.grab_set()
|
||
center_window(dlg, 420, 620)
|
||
|
||
tk.Label(dlg, text="🔐 2FA einrichten",
|
||
font=("Segoe UI", 14, "bold"),
|
||
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=10)
|
||
|
||
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
tk.Label(form, text="1. Scannen Sie den QR-Code mit Ihrer\n"
|
||
" Authenticator-App (z.B. Google Authenticator):",
|
||
font=("Segoe UI", 9), bg="#E8F4FA", fg="#4a8aaa",
|
||
justify="left").pack(anchor="w", pady=(4, 8))
|
||
|
||
qr_img = qrcode.make(uri, box_size=5, border=2)
|
||
qr_photo = ImageTk.PhotoImage(qr_img)
|
||
qr_label = tk.Label(form, image=qr_photo, bg="#E8F4FA")
|
||
qr_label.image = qr_photo
|
||
qr_label.pack(pady=(0, 8))
|
||
|
||
tk.Label(form, text="Manueller Schlüssel:",
|
||
font=("Segoe UI", 8), bg="#E8F4FA", fg="#888").pack(anchor="w")
|
||
secret_display = tk.Entry(form, font=("Consolas", 9), bg="#F0F8FF",
|
||
fg="#1a4d6d", relief="flat", bd=0,
|
||
readonlybackground="#F0F8FF", state="readonly")
|
||
secret_display.pack(fill="x", ipady=2, pady=(0, 8))
|
||
secret_display.configure(state="normal")
|
||
secret_display.insert(0, secret)
|
||
secret_display.configure(state="readonly")
|
||
|
||
sep = tk.Frame(form, bg="#B9ECFA", height=1)
|
||
sep.pack(fill="x", pady=(4, 8))
|
||
|
||
tk.Label(form, text="2. Geben Sie den aktuellen 6-stelligen Code ein:",
|
||
font=("Segoe UI", 9, "bold"), bg="#E8F4FA",
|
||
fg="#1a4d6d").pack(anchor="w")
|
||
|
||
code_entry = tk.Entry(form, font=("Segoe UI", 16), bg="white",
|
||
fg="#1a4d6d", relief="flat", bd=0,
|
||
justify="center")
|
||
code_entry.pack(fill="x", ipady=6, pady=(4, 4))
|
||
|
||
err_label = tk.Label(form, text="", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#E05050")
|
||
err_label.pack(fill="x")
|
||
|
||
def do_activate(event=None):
|
||
code = code_entry.get().strip()
|
||
if not code:
|
||
err_label.configure(text="⚠ Bitte Code eingeben.")
|
||
return
|
||
totp = pyotp.TOTP(secret)
|
||
if not totp.verify(code, valid_window=1):
|
||
err_label.configure(text="Falscher Code. Bitte erneut versuchen.")
|
||
code_entry.delete(0, "end")
|
||
code_entry.focus_set()
|
||
return
|
||
|
||
backup_codes = generate_backup_codes()
|
||
backup_hashes = [hash_backup_code(c) for c in backup_codes]
|
||
|
||
self._user_profile["totp_secret_enc"] = encrypt_secret(secret, password)
|
||
self._user_profile["totp_active"] = True
|
||
self._user_profile["backup_codes"] = backup_hashes
|
||
save_user_profile(self._user_profile)
|
||
|
||
dlg.destroy()
|
||
self._show_backup_codes(backup_codes)
|
||
|
||
code_entry.bind("<Return>", do_activate)
|
||
|
||
btn = tk.Button(form, text="2FA aktivieren",
|
||
font=("Segoe UI", 11, "bold"),
|
||
bg="#27AE60", fg="white", activebackground="#219A52",
|
||
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
|
||
command=do_activate)
|
||
btn.pack(pady=(8, 0))
|
||
|
||
dlg.protocol("WM_DELETE_WINDOW", lambda: sys.exit(0) if is_2fa_required() else dlg.destroy())
|
||
code_entry.focus_set()
|
||
self.wait_window(dlg)
|
||
|
||
def _show_backup_codes(self, codes: list[str]):
|
||
"""Zeigt die Backup-Codes an (einmalig nach Aktivierung)."""
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("Backup-Codes")
|
||
dlg.configure(bg="#E8F4FA")
|
||
dlg.resizable(False, False)
|
||
dlg.geometry("380x420")
|
||
self._register_window(dlg)
|
||
dlg.attributes("-topmost", True)
|
||
dlg.grab_set()
|
||
center_window(dlg, 380, 420)
|
||
|
||
tk.Label(dlg, text="Backup-Codes sichern!",
|
||
font=("Segoe UI", 14, "bold"),
|
||
bg="#E74C3C", fg="white").pack(fill="x", ipady=10)
|
||
|
||
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
tk.Label(form, text="Diese Codes werden NUR EINMAL angezeigt.\n"
|
||
"Bewahren Sie sie sicher auf (z.B. ausdrucken).\n"
|
||
"Jeder Code kann nur einmal verwendet werden.",
|
||
font=("Segoe UI", 9), bg="#E8F4FA", fg="#E74C3C",
|
||
justify="left").pack(anchor="w", pady=(4, 12))
|
||
|
||
codes_text = tk.Text(form, font=("Consolas", 14), bg="#F0F8FF",
|
||
fg="#1a4d6d", relief="flat", bd=1,
|
||
height=len(codes), width=20)
|
||
codes_text.pack(pady=(0, 12))
|
||
for i, code in enumerate(codes, 1):
|
||
codes_text.insert("end", f" {i}. {code}\n")
|
||
codes_text.configure(state="disabled")
|
||
|
||
def do_copy():
|
||
dlg.clipboard_clear()
|
||
dlg.clipboard_append("\n".join(codes))
|
||
messagebox.showinfo("Kopiert",
|
||
"Backup-Codes in Zwischenablage kopiert.", parent=dlg)
|
||
|
||
btn_row = tk.Frame(form, bg="#E8F4FA")
|
||
btn_row.pack()
|
||
tk.Button(btn_row, text="Kopieren", font=("Segoe UI", 10),
|
||
bg="#C8DDE6", fg="#1a4d6d", relief="flat", padx=12,
|
||
pady=4, cursor="hand2", command=do_copy).pack(side="left", padx=4)
|
||
tk.Button(btn_row, text="Ich habe die Codes gesichert",
|
||
font=("Segoe UI", 10, "bold"),
|
||
bg="#27AE60", fg="white", relief="flat", padx=12,
|
||
pady=4, cursor="hand2",
|
||
command=dlg.destroy).pack(side="left", padx=4)
|
||
|
||
dlg.protocol("WM_DELETE_WINDOW", dlg.destroy)
|
||
self.wait_window(dlg)
|
||
|
||
def _show_registration_dialog(self):
|
||
"""Erstregistrierung: Profil + Passwort festlegen."""
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("Registrierung AzA Profil")
|
||
dlg.configure(bg="#E8F4FA")
|
||
dlg.resizable(True, True)
|
||
dlg.geometry("420x700")
|
||
dlg.minsize(380, 580)
|
||
add_resize_grip(dlg, 380, 580)
|
||
self._register_window(dlg)
|
||
dlg.attributes("-topmost", True)
|
||
dlg.grab_set()
|
||
center_window(dlg, 420, 700)
|
||
|
||
tk.Label(dlg, text="👤 Willkommen bei AzA", font=("Segoe UI", 16, "bold"),
|
||
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=12)
|
||
tk.Label(dlg, text="Bitte erfassen Sie Ihr Profil.\nPasswort ist optional und kann spaeter gesetzt werden.",
|
||
font=("Segoe UI", 9), bg="#E8F4FA", fg="#4a8aaa").pack(fill="x", padx=16, pady=(8, 4))
|
||
|
||
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
tk.Label(form, text="Name / Titel:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
name_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0)
|
||
name_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
name_entry.insert(0, self._user_profile.get("name", ""))
|
||
|
||
tk.Label(form, text="Fachrichtung:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
spec_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0)
|
||
spec_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
spec_entry.insert(0, self._user_profile.get("specialty", ""))
|
||
|
||
tk.Label(form, text="Praxis / Klinik:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
clinic_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0)
|
||
clinic_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
clinic_entry.insert(0, self._user_profile.get("clinic", ""))
|
||
|
||
tk.Label(form, text="Code (ZSR/GLN, optional):", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
code_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0)
|
||
code_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
code_entry.insert(0, self._user_profile.get("code", ""))
|
||
|
||
tk.Label(form, text="E-Mail (optional):", font=("Segoe UI", 10),
|
||
bg="#E8F4FA", fg="#888").pack(anchor="w", pady=(4, 0))
|
||
email_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0)
|
||
email_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
email_entry.insert(0, self._user_profile.get("email", ""))
|
||
|
||
sep = tk.Frame(form, bg="#B9ECFA", height=1)
|
||
sep.pack(fill="x", pady=(6, 6))
|
||
|
||
tk.Label(form, text="\U0001f511 Passwort festlegen (optional):", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
pw_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0, show="")
|
||
pw_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
|
||
tk.Label(form, text="Passwort bestätigen:", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
pw_confirm_entry = tk.Entry(form, font=("Segoe UI", 11), bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0, show="")
|
||
pw_confirm_entry.pack(fill="x", ipady=4, pady=(0, 6))
|
||
|
||
def do_save():
|
||
name = name_entry.get().strip()
|
||
if not name:
|
||
messagebox.showwarning("Pflichtfeld", "Bitte geben Sie Ihren Namen ein.", parent=dlg)
|
||
return
|
||
pw = pw_entry.get()
|
||
pw_confirm = pw_confirm_entry.get()
|
||
profile = {
|
||
"name": name,
|
||
"specialty": spec_entry.get().strip(),
|
||
"clinic": clinic_entry.get().strip(),
|
||
"code": code_entry.get().strip(),
|
||
"email": email_entry.get().strip(),
|
||
}
|
||
if pw:
|
||
if len(pw) < 4:
|
||
messagebox.showwarning("Passwort zu kurz",
|
||
"Das Passwort muss mindestens 4 Zeichen lang sein.", parent=dlg)
|
||
return
|
||
if pw != pw_confirm:
|
||
messagebox.showerror("Fehler", "Die Passwoerter stimmen nicht ueberein.", parent=dlg)
|
||
pw_confirm_entry.delete(0, "end")
|
||
pw_confirm_entry.focus_set()
|
||
return
|
||
profile["password_hash"] = self._hash_password(pw)
|
||
self._user_profile = profile
|
||
save_user_profile(self._user_profile)
|
||
self._record_login()
|
||
dlg.destroy()
|
||
|
||
def _close_with_default():
|
||
if not self._user_profile.get("name"):
|
||
self._user_profile["name"] = "Benutzer"
|
||
save_user_profile(self._user_profile)
|
||
self._record_login()
|
||
dlg.destroy()
|
||
|
||
tk.Button(dlg, text="\U0001f4be Registrieren & Starten", font=("Segoe UI", 12, "bold"),
|
||
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
||
relief="flat", bd=0, padx=28, pady=8, cursor="hand2",
|
||
command=do_save).pack(pady=(16, 12))
|
||
|
||
dlg.protocol("WM_DELETE_WINDOW", _close_with_default)
|
||
name_entry.focus_set()
|
||
self.wait_window(dlg)
|
||
|
||
def _show_license_required_notice(self):
|
||
"""Zeigt beim Start im Demo-Modus direkt den Aktivierungsdialog."""
|
||
if self.license_mode == "active":
|
||
return
|
||
self._show_activation_dialog()
|
||
|
||
def _kick_workspace_license_hybrid_after_activation(self):
|
||
"""Nach Lizenz-/Offline-Aktivierung erneuter Workspace-Pfad (Dialog ggfs.)."""
|
||
try:
|
||
from aza_workspace_license import (
|
||
ensure_workspace_license_dialog_then_start_hybrid_sync,
|
||
)
|
||
|
||
ensure_workspace_license_dialog_then_start_hybrid_sync(self)
|
||
except Exception:
|
||
pass
|
||
|
||
def _show_device_limit_notice(self):
|
||
"""Zeigt beim Start eine Meldung wenn das Gerätelimit erreicht ist."""
|
||
msg = getattr(self, "_pending_device_limit_msg", None)
|
||
if not msg:
|
||
return
|
||
from tkinter import messagebox
|
||
messagebox.showwarning("AZA – Geraete-Limit", msg)
|
||
|
||
def _show_activation_dialog(self):
|
||
"""Einziger Aktivierungsdialog: Demo-Hinweis + Lizenzschluessel-Eingabe."""
|
||
saved_key = (self._user_profile.get("license_key") or "").strip()
|
||
|
||
_has_remote = False
|
||
try:
|
||
_burl = self.get_backend_url()
|
||
if _burl and not any(h in _burl for h in ("127.0.0.1", "localhost", "0.0.0.0")):
|
||
_has_remote = True
|
||
except Exception:
|
||
pass
|
||
|
||
is_active = self.license_mode == "active"
|
||
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("AzA Aktivierung")
|
||
dlg.configure(bg="#E8F4FA")
|
||
dlg.resizable(True, True)
|
||
dlg.geometry("520x420")
|
||
dlg.minsize(460, 360)
|
||
dlg.attributes("-topmost", True)
|
||
self._register_window(dlg)
|
||
center_window(dlg, 520, 420)
|
||
|
||
tk.Label(dlg, text="AzA Lizenz", font=("Segoe UI", 14, "bold"),
|
||
bg="#5B8DB3", fg="white").pack(fill="x", ipady=10)
|
||
|
||
info = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=10)
|
||
info.pack(fill="x")
|
||
|
||
if is_active:
|
||
tk.Label(info, text="\u2713 Vollversion aktiv", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#2a7a3a").pack(anchor="w")
|
||
if saved_key:
|
||
tk.Label(info, text=f"Lizenz: {saved_key}", font=("Consolas", 9),
|
||
bg="#E8F4FA", fg="#5a7a8a").pack(anchor="w", pady=(2, 0))
|
||
else:
|
||
tk.Label(info, text="Testversion", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#c04040").pack(anchor="w")
|
||
tk.Label(info, text=f"In der Demo koennen Sie bis zu {DEMO_MAX_DICTATIONS} Diktate testen.\n"
|
||
"Fuer die Vollversion geben Sie bitte Ihren Lizenzschluessel ein.",
|
||
font=("Segoe UI", 9), bg="#E8F4FA", fg="#5a7a8a",
|
||
wraplength=440, justify="left").pack(anchor="w", pady=(4, 0))
|
||
|
||
form = tk.Frame(dlg, bg="#E8F4FA", padx=20, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
tk.Label(form, text="Lizenzschluessel:", font=("Segoe UI", 9, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w")
|
||
tk.Label(form, text="Format: AZA-XXXX-XXXX-XXXX-XXXX", font=("Segoe UI", 8),
|
||
bg="#E8F4FA", fg="#aaa").pack(anchor="w")
|
||
key_entry = tk.Entry(form, font=("Consolas", 12), bg="white", fg="#1a4d6d",
|
||
relief="solid", bd=1, justify="center")
|
||
key_entry.pack(fill="x", ipady=6, pady=(4, 6))
|
||
if saved_key:
|
||
key_entry.insert(0, saved_key)
|
||
|
||
def _paste_from_clipboard(event=None):
|
||
try:
|
||
clip = dlg.clipboard_get().strip()
|
||
if clip:
|
||
key_entry.delete(0, "end")
|
||
key_entry.insert(0, clip)
|
||
except tk.TclError:
|
||
pass
|
||
return "break"
|
||
|
||
_ctx_menu = tk.Menu(dlg, tearoff=0, font=("Segoe UI", 9))
|
||
_ctx_menu.add_command(label="Einfuegen", command=_paste_from_clipboard)
|
||
_ctx_menu.add_command(label="Alles auswaehlen",
|
||
command=lambda: key_entry.select_range(0, "end"))
|
||
key_entry.bind("<Button-3>", lambda e: _ctx_menu.tk_popup(e.x_root, e.y_root))
|
||
|
||
status_label = tk.Label(form, text="", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#888", wraplength=440, justify="left")
|
||
status_label.pack(fill="x", pady=(2, 0))
|
||
|
||
def do_activate():
|
||
k = key_entry.get().strip().upper()
|
||
if not k:
|
||
status_label.configure(text="Bitte Lizenzschluessel eingeben.", fg="#E05050")
|
||
return
|
||
|
||
if not _has_remote:
|
||
from aza_activation import validate_key as _val_key, save_activation_key as _save_key
|
||
valid, expiry, reason = _val_key(k)
|
||
if valid:
|
||
_save_key(k)
|
||
status_label.configure(text=f"Offline gespeichert: {reason}", fg="#2a7a3a")
|
||
try:
|
||
self.after(
|
||
200,
|
||
self._kick_workspace_license_hybrid_after_activation,
|
||
)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
status_label.configure(text=reason, fg="#E05050")
|
||
return
|
||
|
||
status_label.configure(text="Wird geprueft...", fg="#5B8DB3")
|
||
dlg.update_idletasks()
|
||
|
||
def _worker():
|
||
try:
|
||
backend_url = self.get_backend_url()
|
||
api_token = self.get_backend_token()
|
||
device_id = _get_or_create_device_id()
|
||
_dn = f"{platform.node() or 'unknown'}"
|
||
try:
|
||
from aza_version import APP_VERSION as _av
|
||
except Exception:
|
||
_av = ""
|
||
resp = requests.post(
|
||
f"{backend_url}/license/activate",
|
||
json={"license_key": k},
|
||
headers={
|
||
"X-API-Token": api_token,
|
||
"X-Device-Id": device_id,
|
||
"X-Device-Name": _dn,
|
||
"X-App-Version": _av,
|
||
"X-Device-Fingerprint": _get_hardware_fingerprint(),
|
||
},
|
||
timeout=10,
|
||
)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
_is_active = (
|
||
data.get("valid")
|
||
or data.get("license_active")
|
||
or (isinstance(data, dict) and str(data.get("status", "")).lower() == "active")
|
||
)
|
||
if _is_active:
|
||
self._user_profile["license_key"] = k
|
||
if data.get("customer_email"):
|
||
self._user_profile["email"] = data["customer_email"]
|
||
_pid = (data.get("practice_id") or "").strip() if isinstance(data, dict) else ""
|
||
if _pid:
|
||
self._user_profile["practice_id"] = _pid
|
||
save_user_profile(self._user_profile)
|
||
self.license_mode = "active"
|
||
_save_license_cache({
|
||
"valid": True,
|
||
"valid_until": data.get("valid_until"),
|
||
"cached_at": time.time(),
|
||
})
|
||
self.after(0, lambda: status_label.configure(
|
||
text="\u2713 Lizenz erfolgreich aktiviert!", fg="#2a7a3a"))
|
||
self.after(0, lambda: self.set_status("Lizenz aktiviert \u2013 Vollversion"))
|
||
self.after(0, lambda: self.title("AzA Office"))
|
||
self.after(0, self._provision_empfang_account)
|
||
self.after(
|
||
200,
|
||
self._kick_workspace_license_hybrid_after_activation,
|
||
)
|
||
else:
|
||
self.after(0, lambda: status_label.configure(
|
||
text=f"Lizenz inaktiv ({data.get('status', '?')}). Bitte Abo pruefen.",
|
||
fg="#E05050"))
|
||
elif resp.status_code == 404:
|
||
self.after(0, lambda: status_label.configure(
|
||
text="Lizenzschluessel ungueltig.", fg="#E05050"))
|
||
elif resp.status_code == 403:
|
||
_d = ""
|
||
try:
|
||
_d = resp.json().get("detail", "")
|
||
except Exception:
|
||
pass
|
||
self.after(0, lambda: status_label.configure(
|
||
text=_d or "Geraete-Limit erreicht.", fg="#E05050"))
|
||
else:
|
||
self.after(0, lambda: status_label.configure(
|
||
text=f"Serverfehler ({resp.status_code}).", fg="#E05050"))
|
||
except requests.RequestException as e:
|
||
self.after(0, lambda: status_label.configure(
|
||
text=f"Verbindungsfehler: {e}", fg="#E05050"))
|
||
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
btn_frame = tk.Frame(dlg, bg="#E8F4FA")
|
||
btn_frame.pack(pady=(12, 10))
|
||
tk.Button(btn_frame, text="Aktivieren", font=("Segoe UI", 9, "bold"),
|
||
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
||
relief="flat", bd=0, width=12, padx=6, pady=3, cursor="hand2",
|
||
command=do_activate).pack(side="left", padx=6)
|
||
tk.Button(btn_frame, text="Schliessen", font=("Segoe UI", 9),
|
||
bg="#C8DDE6", fg="#1a4d6d", relief="flat", bd=0,
|
||
width=12, padx=6, pady=3, cursor="hand2",
|
||
command=dlg.destroy).pack(side="left", padx=6)
|
||
|
||
def _show_profile_editor(self):
|
||
"""Öffnet ein Fenster zum Bearbeiten des Benutzerprofils (inkl. Passwort ändern)."""
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("Profil bearbeiten")
|
||
dlg.configure(bg="#E8F4FA")
|
||
dlg.resizable(True, True)
|
||
dlg.geometry("760x760")
|
||
dlg.minsize(520, 440)
|
||
dlg.attributes("-topmost", True)
|
||
self._register_window(dlg)
|
||
center_window(dlg, 760, 760)
|
||
|
||
tk.Label(dlg, text="\U0001f464 Profil bearbeiten", font=("Segoe UI", 13, "bold"),
|
||
bg="#B9ECFA", fg="#1a4d6d").pack(fill="x", ipady=8)
|
||
|
||
scroll_canvas = tk.Canvas(dlg, bg="#E8F4FA", highlightthickness=0)
|
||
scroll_vsb = ttk.Scrollbar(dlg, orient="vertical", command=scroll_canvas.yview)
|
||
scroll_canvas.configure(yscrollcommand=scroll_vsb.set)
|
||
scroll_vsb.pack(side="right", fill="y")
|
||
scroll_canvas.pack(side="left", fill="both", expand=True)
|
||
scroll_inner = tk.Frame(scroll_canvas, bg="#E8F4FA")
|
||
scroll_win = scroll_canvas.create_window((0, 0), window=scroll_inner, anchor="nw")
|
||
scroll_inner.bind("<Configure>", lambda e: scroll_canvas.configure(scrollregion=scroll_canvas.bbox("all")))
|
||
scroll_canvas.bind("<Configure>", lambda e: scroll_canvas.itemconfigure(scroll_win, width=e.width))
|
||
scroll_canvas.bind_all("<MouseWheel>", lambda e: scroll_canvas.yview_scroll(-1 * (e.delta // 120), "units"))
|
||
dlg.bind("<Destroy>", lambda e: scroll_canvas.unbind_all("<MouseWheel>") if e.widget is dlg else None)
|
||
|
||
def _profile_dlg_scroll_refresh():
|
||
try:
|
||
dlg.update_idletasks()
|
||
scroll_canvas.configure(scrollregion=scroll_canvas.bbox("all"))
|
||
except Exception:
|
||
pass
|
||
|
||
_ACC_BG = "#5B8DB3"
|
||
_ACC_FG = "white"
|
||
_ACC_SUB = "#E2EEF6"
|
||
|
||
form = tk.Frame(scroll_inner, bg=_ACC_BG, padx=20, pady=12)
|
||
form.pack(fill="x")
|
||
|
||
_FLD_FONT = ("Segoe UI", 9)
|
||
_LBL_FONT = ("Segoe UI", 9, "bold")
|
||
_PH_COLOR = "#aaa"
|
||
_FG_COLOR = "#1a4d6d"
|
||
|
||
def _make_placeholder_entry(parent, placeholder, initial=""):
|
||
e = tk.Entry(parent, font=_FLD_FONT, bg="white", fg=_FG_COLOR, relief="flat")
|
||
e.pack(fill="x", ipady=4, pady=(0, 6))
|
||
|
||
def _set_ph():
|
||
if not e.get().strip():
|
||
e.delete(0, "end")
|
||
e.insert(0, placeholder)
|
||
e.configure(fg=_PH_COLOR)
|
||
|
||
def _on_focus_in(ev):
|
||
if e.get() == placeholder and e.cget("fg") == _PH_COLOR:
|
||
e.delete(0, "end")
|
||
e.configure(fg=_FG_COLOR)
|
||
|
||
def _on_focus_out(ev):
|
||
_set_ph()
|
||
|
||
def _get_val():
|
||
v = e.get().strip()
|
||
return "" if v == placeholder else v
|
||
|
||
e.bind("<FocusIn>", _on_focus_in)
|
||
e.bind("<FocusOut>", _on_focus_out)
|
||
e._get_real_value = _get_val
|
||
|
||
if initial and initial != placeholder:
|
||
e.insert(0, initial)
|
||
e.configure(fg=_FG_COLOR)
|
||
else:
|
||
_set_ph()
|
||
return e
|
||
|
||
tk.Label(form, text="Name / Titel:", font=_LBL_FONT,
|
||
bg=_ACC_BG, fg=_ACC_FG).pack(anchor="w", pady=(8, 0))
|
||
_name_initial = self._user_profile.get("name", "")
|
||
if _name_initial == "Benutzer":
|
||
_name_initial = ""
|
||
name_e = _make_placeholder_entry(form, "Ihr Name / Titel eingeben...", _name_initial)
|
||
|
||
tk.Label(form, text="Fachrichtung:", font=_LBL_FONT,
|
||
bg=_ACC_BG, fg=_ACC_FG).pack(anchor="w", pady=(4, 0))
|
||
spec_e = _make_placeholder_entry(form, "z.B. Dermatologie", self._user_profile.get("specialty", ""))
|
||
|
||
tk.Label(form, text="Praxis / Klinik:", font=_LBL_FONT,
|
||
bg=_ACC_BG, fg=_ACC_FG).pack(anchor="w", pady=(4, 0))
|
||
clinic_e = _make_placeholder_entry(form, "z.B. Hautarztpraxis Winterthur", self._user_profile.get("clinic", ""))
|
||
|
||
tk.Label(form, text="Code (ZSR/GLN, optional):", font=_LBL_FONT,
|
||
bg=_ACC_BG, fg=_ACC_FG).pack(anchor="w", pady=(4, 0))
|
||
code_e = _make_placeholder_entry(form, "ZSR / GLN", self._user_profile.get("code", ""))
|
||
|
||
tk.Label(form, text="E-Mail:", font=_LBL_FONT,
|
||
bg=_ACC_BG, fg=_ACC_FG).pack(anchor="w", pady=(4, 0))
|
||
email_e = _make_placeholder_entry(form, "praxis@beispiel.ch", self._user_profile.get("email", ""))
|
||
|
||
# --- Lizenzschlüssel (Software-Aktivierung) ---
|
||
lic_sep = tk.Frame(form, bg=_ACC_SUB, height=1)
|
||
lic_sep.pack(fill="x", pady=(10, 6))
|
||
tk.Label(form, text="\U0001f511 Lizenzschluessel", font=("Segoe UI", 10, "bold"),
|
||
bg=_ACC_BG, fg=_ACC_FG).pack(anchor="w", pady=(2, 2))
|
||
tk.Label(form, text="Ihr persoenlicher Lizenzschluessel fuer die Software-Aktivierung.",
|
||
font=("Segoe UI", 8), bg=_ACC_BG, fg=_ACC_SUB).pack(anchor="w", pady=(0, 4))
|
||
|
||
_lic_key = (self._user_profile.get("license_key") or "").strip()
|
||
if _lic_key:
|
||
_lic_lbl = tk.Label(form, text=_lic_key, font=("Consolas", 10),
|
||
bg="#f0f4f8", fg="#1a3a5a", relief="flat",
|
||
padx=6, pady=4, cursor="hand2", anchor="w")
|
||
_lic_lbl.pack(fill="x", pady=(0, 10))
|
||
_lic_lbl.bind("<Button-1>", lambda e: (
|
||
dlg.clipboard_clear(), dlg.clipboard_append(_lic_key),
|
||
self.set_status("Lizenznummer kopiert.")))
|
||
add_tooltip(_lic_lbl, "Klick zum Kopieren")
|
||
else:
|
||
tk.Label(form, text="Keine Lizenz aktiviert.", font=("Segoe UI", 9),
|
||
bg=_ACC_BG, fg=_ACC_SUB).pack(anchor="w", pady=(0, 10))
|
||
|
||
# --- Chat-Bereich (einklappbar, heller Container) ---
|
||
light_profile_lower = tk.Frame(scroll_inner, bg="#E8F4FA")
|
||
light_profile_lower.pack(fill="x")
|
||
|
||
_chat_open = [False]
|
||
|
||
_chat_head = tk.Frame(light_profile_lower, bg="#E8F4FA", cursor="hand2")
|
||
_chat_head.pack(fill="x", padx=20, pady=(12, 4))
|
||
|
||
_chat_arrow = tk.Label(_chat_head, text="\u25B6", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#5B8DB3", width=2)
|
||
_chat_arrow.pack(side="left")
|
||
tk.Label(
|
||
_chat_head,
|
||
text=" \U0001f4ac Chat",
|
||
font=("Segoe UI", 12, "bold"),
|
||
bg="#E8F4FA",
|
||
fg="#1a4d6d",
|
||
cursor="hand2",
|
||
).pack(side="left")
|
||
|
||
_chat_body = tk.Frame(light_profile_lower, bg="#E8F4FA")
|
||
|
||
def _toggle_chat_section(_event=None):
|
||
_chat_open[0] = not _chat_open[0]
|
||
if _chat_open[0]:
|
||
_chat_body.pack(fill="x", after=_chat_head)
|
||
_chat_arrow.configure(text="\u25BC")
|
||
else:
|
||
_chat_body.pack_forget()
|
||
_chat_arrow.configure(text="\u25B6")
|
||
_profile_dlg_scroll_refresh()
|
||
|
||
_chat_head.bind("<Button-1>", _toggle_chat_section)
|
||
for _ch in _chat_head.winfo_children():
|
||
_ch.bind("<Button-1>", _toggle_chat_section)
|
||
|
||
form = tk.Frame(_chat_body, bg="#E8F4FA", padx=20, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
chat_sep = tk.Frame(form, bg="#B9ECFA", height=1)
|
||
chat_sep.pack(fill="x", pady=(0, 6))
|
||
tk.Label(form, text="\U0001f4e8 Chat-Einladungscode", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 2))
|
||
tk.Label(form, text="Teilen Sie diesen Code mit Kollegen, damit diese\n"
|
||
"Ihrem Praxis-Chat beitreten koennen.\n"
|
||
"Der Code ist getrennt vom Lizenzschluessel.",
|
||
font=("Segoe UI", 8), bg="#E8F4FA", fg="#888",
|
||
justify="left").pack(anchor="w", pady=(0, 4))
|
||
|
||
_focal_frame = tk.Frame(form, bg="#E8F4FA")
|
||
_focal_frame.pack(fill="x", pady=(0, 10))
|
||
tk.Label(_focal_frame, text="Fuehrende Praxis-ID (Desktop und Browser)",
|
||
font=("Segoe UI", 10, "bold"), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w")
|
||
_lbl_focal_name = tk.Label(_focal_frame, text="Praxis: …", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#1a3a5a", anchor="w", justify="left")
|
||
_lbl_focal_name.pack(anchor="w")
|
||
_lbl_focal_pid = tk.Label(_focal_frame, text="Practice-ID: …", font=("Consolas", 9),
|
||
bg="#E8F4FA", fg="#356488", anchor="w")
|
||
_lbl_focal_pid.pack(anchor="w")
|
||
tk.Label(
|
||
_focal_frame,
|
||
text="Diese Praxis-ID ist massgeblich fuer Desktop und Browser-Empfang.\n"
|
||
"Einladungscode und Link gelten immer nur fuer genau diese Praxis.",
|
||
font=("Segoe UI", 8), bg="#E8F4FA", fg="#7a909e", justify="left",
|
||
).pack(anchor="w", pady=(4, 0))
|
||
|
||
_invite_code_frame = tk.Frame(form, bg="#E8F4FA")
|
||
_invite_code_frame.pack(fill="x", pady=(0, 4))
|
||
_invite_ent = tk.Entry(
|
||
_invite_code_frame, font=("Consolas", 12, "bold"),
|
||
bg="#f0f4f8", fg="#1a3a5a", relief="flat",
|
||
highlightthickness=1, highlightbackground="#d0dce8",
|
||
)
|
||
_invite_ent.insert(0, "Wird geladen...")
|
||
_invite_ent.config(state="readonly")
|
||
_invite_ent.pack(fill="x", ipady=4)
|
||
|
||
def _invite_code_text():
|
||
try:
|
||
return (_invite_ent.get() or "").strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
def _set_invite_fields(code_txt: str, link_txt: str = "") -> None:
|
||
"""Readonly-Felder; Text ist markierbar (Kopieren mit Maus/Ctrl+C)."""
|
||
_invite_ent.config(state="normal")
|
||
_invite_ent.delete(0, "end")
|
||
_invite_ent.insert(0, code_txt)
|
||
_invite_ent.config(state="readonly")
|
||
_link_ent.config(state="normal")
|
||
_link_ent.delete(0, "end")
|
||
_link_ent.insert(0, link_txt)
|
||
_link_ent.config(state="readonly")
|
||
|
||
def _copy_invite_code(e=None):
|
||
code = _invite_code_text()
|
||
if code and code not in ("Wird geladen...", "Offline", "Nicht verfuegbar") and not code.startswith("Fehler"):
|
||
try:
|
||
dlg.clipboard_clear()
|
||
dlg.clipboard_append(code)
|
||
dlg.update_idletasks()
|
||
self.set_status("Chat-Einladungscode kopiert.")
|
||
except Exception:
|
||
self.set_status("Kopieren fehlgeschlagen.")
|
||
_invite_ent.bind("<Button-1>", _copy_invite_code)
|
||
add_tooltip(_invite_ent, "Markieren und kopieren, oder Klick fuer Zwischenablage")
|
||
|
||
_link_frame = tk.Frame(form, bg="#E8F4FA")
|
||
_link_frame.pack(fill="x", pady=(0, 4))
|
||
tk.Label(_link_frame, text="Einladungslink:", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#5B8DB3").pack(anchor="w")
|
||
_link_ent = tk.Entry(
|
||
_link_frame, font=("Segoe UI", 9),
|
||
bg="#f0f4f8", fg="#1a3a5a", relief="flat",
|
||
highlightthickness=1, highlightbackground="#d0dce8",
|
||
)
|
||
_link_ent.pack(fill="x", ipady=2)
|
||
_link_ent.config(state="readonly")
|
||
|
||
_link_btn_row = tk.Frame(form, bg="#E8F4FA")
|
||
_link_btn_row.pack(fill="x", pady=(4, 8))
|
||
|
||
def _copy_invite_link():
|
||
try:
|
||
text = (_link_ent.get() or "").strip()
|
||
if text:
|
||
dlg.clipboard_clear()
|
||
dlg.clipboard_append(text)
|
||
dlg.update_idletasks()
|
||
self.set_status("Einladungslink kopiert.")
|
||
except Exception:
|
||
self.set_status("Kopieren fehlgeschlagen.")
|
||
|
||
def _send_invite_message():
|
||
code = _invite_code_text()
|
||
try:
|
||
link = (_link_ent.get() or "").strip()
|
||
except Exception:
|
||
link = ""
|
||
clinic = self._user_profile.get("clinic", "Praxis")
|
||
name = self._user_profile.get("name", "")
|
||
msg_parts = [
|
||
f"Einladung zum Praxis-Chat von {clinic}",
|
||
"",
|
||
f"Chat-Einladungscode: {code}" if code else "",
|
||
f"Einladungslink: {link}" if link else "",
|
||
"",
|
||
"Oeffnen Sie den Einladungslink oder geben Sie",
|
||
"den Einladungscode im Praxis-Chat ein,",
|
||
"um dem Chat beizutreten.",
|
||
]
|
||
if name:
|
||
msg_parts += ["", f"Absender: {name}"]
|
||
full_msg = "\n".join(l for l in msg_parts if l)
|
||
if not full_msg.strip():
|
||
self.set_status("Kein Einladungscode geladen – bitte Verbindung pruefen.")
|
||
return
|
||
try:
|
||
dlg.clipboard_clear()
|
||
dlg.clipboard_append(full_msg)
|
||
dlg.update_idletasks()
|
||
self.set_status("Einladungstext in Zwischenablage kopiert.")
|
||
except Exception:
|
||
self.set_status("Kopieren fehlgeschlagen.")
|
||
|
||
def _regenerate_code():
|
||
if not messagebox.askyesno(
|
||
"Code erneuern",
|
||
"Neuen Chat-Einladungscode erstellen?\n\n"
|
||
"Der alte Code wird ungueltig.\n"
|
||
"Bereits registrierte Benutzer bleiben erhalten.",
|
||
parent=dlg):
|
||
return
|
||
def _worker():
|
||
try:
|
||
bu = self.get_backend_url()
|
||
r = requests.post(
|
||
f"{bu}/empfang/auth/regenerate_invite",
|
||
headers=self._empfang_headers(), timeout=5)
|
||
if r.status_code == 200:
|
||
new_code = r.json().get("invite_code", "")
|
||
link = f"https://empfang.aza-medwork.ch/?invite={new_code}"
|
||
self.after(0, lambda nc=new_code, lk=link: _set_invite_fields(nc, lk))
|
||
self.after(0, lambda: self.set_status("Neuer Einladungscode erstellt."))
|
||
else:
|
||
self.after(0, lambda: messagebox.showerror(
|
||
"Fehler", "Code konnte nicht erneuert werden.", parent=dlg))
|
||
except Exception as exc:
|
||
self.after(0, lambda m=str(exc): messagebox.showerror(
|
||
"Fehler", m, parent=dlg))
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
tk.Button(_link_btn_row, text="Link kopieren", font=("Segoe UI", 9),
|
||
bg="#e8f0f8", fg="#2a5a8a", relief="flat", padx=10, pady=3,
|
||
cursor="hand2", command=_copy_invite_link).pack(side="left", padx=(0, 6))
|
||
tk.Button(_link_btn_row, text="Einladung kopieren", font=("Segoe UI", 9),
|
||
bg="#5B8DB3", fg="white", relief="flat", padx=10, pady=3,
|
||
cursor="hand2", command=_send_invite_message).pack(side="left", padx=(0, 6))
|
||
tk.Button(_link_btn_row, text="\u21bb Neuer Code", font=("Segoe UI", 9),
|
||
bg="#e8f0f8", fg="#2a5a8a", relief="flat", padx=10, pady=3,
|
||
cursor="hand2", command=_regenerate_code).pack(side="left")
|
||
|
||
join_sep = tk.Frame(form, bg="#B9ECFA", height=1)
|
||
join_sep.pack(fill="x", pady=(14, 8))
|
||
tk.Label(form, text="\U0001f517 Mit bestehendem Praxis-Chat verbinden",
|
||
font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 2))
|
||
tk.Label(form, text="Einladungscode (CHAT-\u2026) aus der Hauptinstallation eintragen.\n"
|
||
"Dieses Geraet nutzt dieselbe Praxis — es wird kein zweiter Chat angelegt.",
|
||
font=("Segoe UI", 8), bg="#E8F4FA", fg="#888",
|
||
justify="left").pack(anchor="w", pady=(0, 4))
|
||
join_code_ent = tk.Entry(
|
||
form, font=("Consolas", 12), bg="white", fg="#1a4d6d",
|
||
relief="solid", bd=1,
|
||
)
|
||
join_code_ent.pack(fill="x", ipady=4, pady=(0, 6))
|
||
|
||
def _join_praxis_chat():
|
||
raw = (join_code_ent.get() or "").strip().upper().replace(" ", "")
|
||
if len(raw) < 8:
|
||
messagebox.showwarning(
|
||
"Einladungscode",
|
||
"Bitte einen vollstaendigen Chat-Einladungscode eingeben.",
|
||
parent=dlg,
|
||
)
|
||
return
|
||
|
||
def _gv_el(e):
|
||
return e._get_real_value() if hasattr(e, "_get_real_value") else e.get().strip()
|
||
|
||
name = _gv_el(name_e)
|
||
email = _gv_el(email_e)
|
||
if not name:
|
||
messagebox.showwarning(
|
||
"Profil",
|
||
"Bitte zuerst Ihren Namen unter «Name / Titel» ausfuellen.",
|
||
parent=dlg,
|
||
)
|
||
return
|
||
pw_base = email.split("@")[0] if email else name.lower()
|
||
pw = pw_base if len(pw_base) >= 4 else (pw_base + "1234")
|
||
|
||
def _worker():
|
||
try:
|
||
bu = self.get_backend_url()
|
||
tok = self.get_backend_token()
|
||
except Exception as exc:
|
||
self.after(0, lambda m=str(exc): messagebox.showerror(
|
||
"Backend", m, parent=dlg))
|
||
return
|
||
|
||
try:
|
||
r = requests.post(
|
||
f"{bu}/empfang/auth/provision",
|
||
json={
|
||
"name": name,
|
||
"email": email,
|
||
"password": pw,
|
||
"invite_code": raw,
|
||
},
|
||
headers={"X-API-Token": tok},
|
||
timeout=(5, 18),
|
||
)
|
||
if r.status_code != 200:
|
||
detail = str(r.status_code)
|
||
try:
|
||
dj = r.json()
|
||
if isinstance(dj.get("detail"), str):
|
||
detail = dj["detail"]
|
||
except Exception:
|
||
pass
|
||
self.after(0, lambda d=detail: messagebox.showerror(
|
||
"Verbinden fehlgeschlagen", d, parent=dlg))
|
||
return
|
||
data = r.json()
|
||
pid = (data.get("practice_id") or "").strip()
|
||
disp = (data.get("display_name") or "").strip()
|
||
if not pid:
|
||
self.after(0, lambda: messagebox.showerror(
|
||
"Verbinden fehlgeschlagen",
|
||
"Server hat keine practice_id geliefert.",
|
||
parent=dlg,
|
||
))
|
||
return
|
||
old_pid = (self._user_profile.get("practice_id") or "").strip()
|
||
if pid != old_pid:
|
||
try:
|
||
self._invalidate_empfang_prefs_for_new_practice()
|
||
except Exception:
|
||
pass
|
||
self._user_profile["practice_id"] = pid
|
||
if disp:
|
||
self._user_profile["empfang_display_name"] = disp
|
||
save_user_profile(self._user_profile)
|
||
|
||
verify_ok = False
|
||
try:
|
||
r_chk = requests.get(
|
||
f"{bu}/empfang/practice/info",
|
||
headers={"X-API-Token": tok, "X-Practice-Id": pid},
|
||
timeout=10,
|
||
)
|
||
if r_chk.status_code == 200:
|
||
chk = r_chk.json() or {}
|
||
verify_ok = (chk.get("practice_id") or "").strip() == pid
|
||
except Exception:
|
||
verify_ok = False
|
||
|
||
strong_msg = (
|
||
"Diese Installation nutzt nun denselben Praxis-Chat wie die Hauptinstallation.\n"
|
||
"Empfang-Nutzer und Verlauf sind gemeinsam."
|
||
)
|
||
weak_msg = (
|
||
"Der Server hat die Verbindung gemeldet, aber die Praxis konnte nicht "
|
||
"gegen /empfang/practice/info verifiziert werden (Netzwerk oder URL). "
|
||
"Bitte Backend-Adresse pruefen und erneut verbinden, falls Benutzer fehlen."
|
||
)
|
||
|
||
def _ok():
|
||
if verify_ok:
|
||
self.set_status("Mit bestehendem Praxis-Chat verbunden.")
|
||
messagebox.showinfo("Verbunden", strong_msg, parent=dlg)
|
||
else:
|
||
self.set_status("Praxis-Chat: Verbindung nicht verifiziert.")
|
||
messagebox.showwarning("Verbindung pruefen", weak_msg, parent=dlg)
|
||
join_code_ent.delete(0, tk.END)
|
||
threading.Thread(target=_load_invite_info, daemon=True).start()
|
||
|
||
self.after(0, _ok)
|
||
self.after(150, self._provision_empfang_account)
|
||
except Exception as exc:
|
||
self.after(0, lambda m=str(exc): messagebox.showerror(
|
||
"Fehler", m, parent=dlg))
|
||
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
tk.Button(form, text="Mit diesem Code verbinden", font=("Segoe UI", 9, "bold"),
|
||
bg="#5B8DB3", fg="white", relief="flat", padx=12, pady=4,
|
||
cursor="hand2", command=_join_praxis_chat).pack(anchor="w", pady=(0, 8))
|
||
|
||
def _load_invite_info():
|
||
def _apply_loaded(pname: str, pid_disp: str, code: str, err_txt: str = ""):
|
||
_lbl_focal_name.config(text=("Praxis: " + pname) if pname else "Praxis: —")
|
||
_lbl_focal_pid.config(text=("Practice-ID: " + pid_disp) if pid_disp else "Practice-ID: —")
|
||
if err_txt:
|
||
_set_invite_fields(err_txt, "")
|
||
elif code:
|
||
link = f"https://empfang.aza-medwork.ch/?invite={code}"
|
||
_set_invite_fields(code, link)
|
||
else:
|
||
_set_invite_fields("Nicht verfuegbar (kein Code)", "")
|
||
|
||
try:
|
||
bu = self.get_backend_url()
|
||
r = requests.get(f"{bu}/empfang/practice/info",
|
||
headers=self._empfang_headers(), timeout=12)
|
||
if r.status_code == 200:
|
||
d = r.json() or {}
|
||
pname = (d.get("practice_name") or "").strip()
|
||
pid_disp = (d.get("practice_id") or "").strip()
|
||
code = (d.get("invite_code") or "").strip()
|
||
self.after(0, lambda: _apply_loaded(pname, pid_disp, code, ""))
|
||
else:
|
||
detail = str(r.status_code)
|
||
try:
|
||
dj = r.json()
|
||
if isinstance(dj, dict) and dj.get("detail"):
|
||
detail = str(dj["detail"])
|
||
except Exception:
|
||
pass
|
||
err = f"Fehler ({detail})"
|
||
self.after(0, lambda: _apply_loaded("", "", "", err))
|
||
except Exception as exc:
|
||
self.after(0, lambda: _apply_loaded("", "", "", f"Offline ({exc})"))
|
||
|
||
threading.Thread(target=_load_invite_info, daemon=True).start()
|
||
|
||
# --- Benutzer & Geräte ---
|
||
usr_sep = tk.Frame(form, bg="#B9ECFA", height=1)
|
||
usr_sep.pack(fill="x", pady=(10, 6))
|
||
tk.Label(form, text="\U0001f465 Registrierte Benutzer", font=("Segoe UI", 10, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 4))
|
||
|
||
_users_list_frame = tk.Frame(form, bg="#E8F4FA")
|
||
_users_list_frame.pack(fill="x")
|
||
_users_loading_lbl = tk.Label(_users_list_frame, text="Wird geladen...",
|
||
font=("Segoe UI", 9), bg="#E8F4FA", fg="#888")
|
||
_users_loading_lbl.pack(anchor="w")
|
||
|
||
_add_row = tk.Frame(form, bg="#E8F4FA")
|
||
_add_row.pack(fill="x", pady=(6, 2))
|
||
_add_entry = tk.Entry(_add_row, font=("Segoe UI", 9), bg="white",
|
||
fg="#1a4d6d", relief="flat")
|
||
_add_entry.pack(side="left", fill="x", expand=True, ipady=3)
|
||
_add_entry.insert(0, "Neuer Benutzername...")
|
||
_add_entry.configure(fg="#aaa")
|
||
|
||
def _add_focus_in(e):
|
||
if _add_entry.get() == "Neuer Benutzername..." and _add_entry.cget("fg") == "#aaa":
|
||
_add_entry.delete(0, "end")
|
||
_add_entry.configure(fg="#1a4d6d")
|
||
|
||
def _add_focus_out(e):
|
||
if not _add_entry.get().strip():
|
||
_add_entry.delete(0, "end")
|
||
_add_entry.insert(0, "Neuer Benutzername...")
|
||
_add_entry.configure(fg="#aaa")
|
||
|
||
_add_entry.bind("<FocusIn>", _add_focus_in)
|
||
_add_entry.bind("<FocusOut>", _add_focus_out)
|
||
|
||
def _render_users(users_full):
|
||
for w in _users_list_frame.winfo_children():
|
||
w.destroy()
|
||
if not users_full:
|
||
tk.Label(_users_list_frame, text="Keine Benutzer registriert.",
|
||
font=("Segoe UI", 9), bg="#E8F4FA", fg="#888").pack(anchor="w")
|
||
return
|
||
admin_n = sum(1 for x in users_full if x.get("role") == "admin")
|
||
me_dn = (self._empfang_self_display_name() or "").strip().lower()
|
||
for u in users_full:
|
||
uid = u.get("user_id", "")
|
||
dname = u.get("display_name", uid)
|
||
role = u.get("role", "mpa")
|
||
role_labels = {"admin": "Admin", "arzt": "Arzt",
|
||
"mpa": "MPA", "empfang": "Empfang"}
|
||
role_text = role_labels.get(role, role)
|
||
devs = u.get("devices", [])
|
||
is_last_admin = (role == "admin" and admin_n <= 1)
|
||
is_self = (dname or "").strip().lower() == me_dn
|
||
hide_del = is_last_admin or is_self
|
||
card = tk.Frame(_users_list_frame, bg="#f0f4f8", relief="solid", bd=1)
|
||
card.pack(fill="x", pady=1)
|
||
info_row = tk.Frame(card, bg="#f0f4f8")
|
||
info_row.pack(fill="x", padx=6, pady=3)
|
||
tk.Label(info_row, text=dname, font=("Segoe UI", 9, "bold"),
|
||
bg="#f0f4f8", fg="#1a4d6d").pack(side="left")
|
||
tk.Label(info_row, text=f" {role_text}", font=("Segoe UI", 8),
|
||
bg="#f0f4f8", fg="#5B8DB3").pack(side="left")
|
||
for dev in devs:
|
||
dev_name = dev.get("device_name", "")
|
||
dev_ip = dev.get("ip_last", "")
|
||
dev_info = dev_name or dev.get("platform", "")
|
||
if dev_ip:
|
||
dev_info += f" ({dev_ip})"
|
||
if dev_info:
|
||
dev_row = tk.Frame(card, bg="#f0f4f8")
|
||
dev_row.pack(fill="x", padx=6, pady=(0, 2))
|
||
tk.Label(dev_row, text=f" \U0001f4bb {dev_info}",
|
||
font=("Segoe UI", 8), bg="#f0f4f8", fg="#888").pack(side="left")
|
||
last_active = dev.get("last_active", "")
|
||
if last_active:
|
||
tk.Label(dev_row, text=f" zuletzt: {last_active[:16]}",
|
||
font=("Segoe UI", 7), bg="#f0f4f8",
|
||
fg="#aaa").pack(side="left")
|
||
if not devs:
|
||
dev_row = tk.Frame(card, bg="#f0f4f8")
|
||
dev_row.pack(fill="x", padx=6, pady=(0, 2))
|
||
tk.Label(dev_row, text=" \U0001f4bb Konto vorhanden, aktuell kein Geraet verbunden",
|
||
font=("Segoe UI", 8), bg="#f0f4f8", fg="#aaa").pack(side="left")
|
||
|
||
def _do_del(name=dname):
|
||
if not messagebox.askyesno(
|
||
"Benutzer entfernen",
|
||
f"'{name}' wirklich entfernen?\n\n"
|
||
"Sessions und Geraete werden geloescht.",
|
||
parent=dlg):
|
||
return
|
||
def _worker():
|
||
try:
|
||
bu = self.get_backend_url()
|
||
r = requests.post(
|
||
f"{bu}/empfang/users",
|
||
json={
|
||
"name": name,
|
||
"action": "delete",
|
||
"actor_display_name": self._empfang_self_display_name(),
|
||
},
|
||
headers=self._empfang_headers(), timeout=5)
|
||
if r.status_code == 200:
|
||
self.after(0, _refresh_user_list)
|
||
self.after(0, lambda: self.set_status(f"Benutzer '{name}' entfernt."))
|
||
else:
|
||
det = "Loeschen fehlgeschlagen."
|
||
try:
|
||
dj = r.json()
|
||
if isinstance(dj, dict) and dj.get("detail"):
|
||
det = str(dj["detail"])
|
||
except Exception:
|
||
pass
|
||
self.after(0, lambda t=det: messagebox.showerror(
|
||
"Fehler", t, parent=dlg))
|
||
except Exception as exc:
|
||
self.after(0, lambda m=str(exc): messagebox.showerror(
|
||
"Fehler", m, parent=dlg))
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
if not hide_del:
|
||
tk.Label(info_row, text="\u2715", font=("Segoe UI", 9),
|
||
bg="#f0f4f8", fg="#cc4444", cursor="hand2").pack(side="right")
|
||
info_row.winfo_children()[-1].bind("<Button-1>", lambda e, n=dname: _do_del(n))
|
||
else:
|
||
_prot_lbl = tk.Label(
|
||
info_row,
|
||
text="gesch\u00fctzt",
|
||
font=("Segoe UI", 7),
|
||
bg="#f0f4f8", fg="#aaa",
|
||
)
|
||
_prot_lbl.pack(side="right")
|
||
add_tooltip(
|
||
_prot_lbl,
|
||
"Letzter Administrator" if is_last_admin
|
||
else "Aktiver Benutzer \u2014 L\u00f6schen nicht m\u00f6glich",
|
||
)
|
||
|
||
def _refresh_user_list():
|
||
def _fetch():
|
||
users_full = []
|
||
try:
|
||
bu = self.get_backend_url()
|
||
r = requests.get(f"{bu}/empfang/users",
|
||
headers=self._empfang_headers(), timeout=5)
|
||
if r.status_code == 200:
|
||
data = r.json()
|
||
users_full = data.get("users_full", [])
|
||
if not users_full:
|
||
names = data.get("users", [])
|
||
users_full = [{"display_name": n, "role": "mpa"} for n in names]
|
||
except Exception:
|
||
pass
|
||
self.after(0, lambda: _render_users(users_full))
|
||
threading.Thread(target=_fetch, daemon=True).start()
|
||
|
||
def _add_user():
|
||
new_name = _add_entry.get().strip()
|
||
if not new_name or new_name == "Neuer Benutzername...":
|
||
return
|
||
def _worker():
|
||
try:
|
||
bu = self.get_backend_url()
|
||
r = requests.post(
|
||
f"{bu}/empfang/users",
|
||
json={"name": new_name, "action": "add"},
|
||
headers=self._empfang_headers(), timeout=5)
|
||
if r.status_code == 200:
|
||
self.after(0, lambda: _add_entry.delete(0, "end"))
|
||
self.after(0, lambda: _add_entry.insert(0, "Neuer Benutzername..."))
|
||
self.after(0, lambda: _add_entry.configure(fg="#aaa"))
|
||
self.after(0, _refresh_user_list)
|
||
self.after(0, lambda: self.set_status(f"Benutzer '{new_name}' hinzugefuegt."))
|
||
else:
|
||
self.after(0, lambda: messagebox.showerror(
|
||
"Fehler", "Hinzufuegen fehlgeschlagen.", parent=dlg))
|
||
except Exception as exc:
|
||
self.after(0, lambda m=str(exc): messagebox.showerror(
|
||
"Fehler", m, parent=dlg))
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
tk.Button(_add_row, text="+ Hinzufuegen", font=("Segoe UI", 9),
|
||
bg="#5B8DB3", fg="white", relief="flat", padx=10, pady=2,
|
||
cursor="hand2", command=_add_user).pack(side="left", padx=(6, 0))
|
||
_add_entry.bind("<Return>", lambda e: _add_user())
|
||
|
||
_refresh_user_list()
|
||
|
||
# --- Passwort ändern (Kopfzeile und ausklappbare Felder direkt darunter, vor Speichern) ---
|
||
pw_sep = tk.Frame(light_profile_lower, bg="#B9ECFA", height=1)
|
||
pw_sep.pack(fill="x", padx=20, pady=(10, 6))
|
||
|
||
_pw_header = tk.Frame(light_profile_lower, bg="#E8F4FA", cursor="hand2")
|
||
_pw_header.pack(fill="x", padx=20, pady=(2, 0))
|
||
_pw_arrow = tk.Label(_pw_header, text="\u25B6", font=("Segoe UI", 8),
|
||
bg="#E8F4FA", fg="#5B8DB3", width=2)
|
||
_pw_arrow.pack(side="left")
|
||
tk.Label(_pw_header, text="\U0001f511 Passwort \u00e4ndern",
|
||
font=_LBL_FONT, bg="#E8F4FA", fg=_FG_COLOR,
|
||
cursor="hand2").pack(side="left", padx=(2, 0))
|
||
|
||
_pw_body = tk.Frame(light_profile_lower, bg="#E8F4FA")
|
||
|
||
pw_old_e = tk.Entry(_pw_body, font=_FLD_FONT, bg="white", fg=_FG_COLOR,
|
||
relief="flat", bd=0, show="")
|
||
pw_new_e = tk.Entry(_pw_body, font=_FLD_FONT, bg="white", fg=_FG_COLOR,
|
||
relief="flat", bd=0, show="")
|
||
pw_confirm_e = tk.Entry(_pw_body, font=_FLD_FONT, bg="white", fg=_FG_COLOR,
|
||
relief="flat", bd=0, show="")
|
||
|
||
tk.Label(_pw_body, text="Leer lassen, um das Passwort beizubehalten.",
|
||
font=("Segoe UI", 8), bg="#E8F4FA", fg="#888").pack(anchor="w", pady=(4, 0))
|
||
|
||
if self._user_profile.get("password_hash"):
|
||
tk.Label(_pw_body, text="Altes Passwort:", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(4, 0))
|
||
pw_old_e.pack(fill="x", ipady=3, pady=(0, 4))
|
||
|
||
tk.Label(_pw_body, text="Neues Passwort:", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0))
|
||
pw_new_e.pack(fill="x", ipady=3, pady=(0, 4))
|
||
tk.Label(_pw_body, text="Neues Passwort bestaetigen:", font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(2, 0))
|
||
pw_confirm_e.pack(fill="x", ipady=3, pady=(0, 4))
|
||
|
||
_pw_open = [False]
|
||
|
||
def _toggle_pw(event=None):
|
||
_pw_open[0] = not _pw_open[0]
|
||
if _pw_open[0]:
|
||
_pw_body.pack(fill="x", padx=20, pady=(4, 0), after=_pw_header)
|
||
_pw_arrow.configure(text="\u25BC")
|
||
else:
|
||
_pw_body.pack_forget()
|
||
_pw_arrow.configure(text="\u25B6")
|
||
_profile_dlg_scroll_refresh()
|
||
|
||
_pw_header.bind("<Button-1>", _toggle_pw)
|
||
for ch in _pw_header.winfo_children():
|
||
ch.bind("<Button-1>", _toggle_pw)
|
||
|
||
def do_save():
|
||
name = name_e._get_real_value() if hasattr(name_e, '_get_real_value') else name_e.get().strip()
|
||
if not name:
|
||
messagebox.showwarning("Pflichtfeld", "Name darf nicht leer sein.", parent=dlg)
|
||
return
|
||
new_pw = pw_new_e.get()
|
||
new_pw_confirm = pw_confirm_e.get()
|
||
old_hash = self._user_profile.get("password_hash", "")
|
||
|
||
if new_pw:
|
||
if old_hash and not self._verify_password(pw_old_e.get(), old_hash):
|
||
messagebox.showerror("Fehler", "Das alte Passwort ist nicht korrekt.", parent=dlg)
|
||
return
|
||
if len(new_pw) < 4:
|
||
messagebox.showwarning("Zu kurz", "Das neue Passwort muss mindestens 4 Zeichen lang sein.", parent=dlg)
|
||
return
|
||
if new_pw != new_pw_confirm:
|
||
messagebox.showerror("Fehler", "Die neuen Passw\u00f6rter stimmen nicht \u00fcberein.", parent=dlg)
|
||
return
|
||
pw_hash = self._hash_password(new_pw)
|
||
else:
|
||
pw_hash = old_hash
|
||
|
||
_gv = lambda e: e._get_real_value() if hasattr(e, '_get_real_value') else e.get().strip()
|
||
updated = {
|
||
"name": name,
|
||
"specialty": _gv(spec_e),
|
||
"clinic": _gv(clinic_e),
|
||
"code": _gv(code_e),
|
||
"email": _gv(email_e),
|
||
"password_hash": pw_hash,
|
||
}
|
||
for k in ("totp_secret_enc", "totp_active", "backup_codes",
|
||
"practice_id", "license_key"):
|
||
if k in self._user_profile:
|
||
updated[k] = self._user_profile[k]
|
||
self._user_profile = updated
|
||
save_user_profile(self._user_profile)
|
||
self.set_status(f"Profil gespeichert: {name}")
|
||
dlg.destroy()
|
||
|
||
# --- Buttons ---
|
||
btn_row = tk.Frame(scroll_inner, bg="#E8F4FA")
|
||
btn_row.pack(pady=(12, 10))
|
||
tk.Button(btn_row, text="\U0001f4be Speichern", font=("Segoe UI", 9, "bold"),
|
||
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
||
relief="flat", padx=14, pady=4, cursor="hand2",
|
||
command=do_save).pack(side="left", padx=6)
|
||
tk.Button(btn_row, text="Abbrechen", font=("Segoe UI", 9),
|
||
bg="#C8DDE6", fg="#1a4d6d", activebackground="#B8CDD6",
|
||
relief="flat", padx=12, pady=4, cursor="hand2",
|
||
command=dlg.destroy).pack(side="left", padx=6)
|
||
|
||
if is_2fa_enabled():
|
||
sep2 = tk.Frame(scroll_inner, bg="#B9ECFA", height=1)
|
||
sep2.pack(fill="x", padx=20, pady=(4, 4))
|
||
tfa_frame = tk.Frame(scroll_inner, bg="#E8F4FA", padx=20)
|
||
tfa_frame.pack(fill="x")
|
||
is_active = self._user_profile.get("totp_active", False)
|
||
status_text = "2FA aktiv" if is_active else "2FA nicht aktiv"
|
||
status_color = "#27AE60" if is_active else "#E74C3C"
|
||
tk.Label(tfa_frame, text=f"🔐 {status_text}",
|
||
font=("Segoe UI", 10, "bold"), bg="#E8F4FA",
|
||
fg=status_color).pack(side="left")
|
||
|
||
def do_toggle_2fa():
|
||
if is_active:
|
||
if messagebox.askyesno("2FA deaktivieren",
|
||
"Zwei-Faktor-Authentifizierung wirklich deaktivieren?\n\n"
|
||
"Dies verringert die Sicherheit Ihres Kontos.",
|
||
parent=dlg):
|
||
self._user_profile.pop("totp_secret_enc", None)
|
||
self._user_profile.pop("backup_codes", None)
|
||
self._user_profile["totp_active"] = False
|
||
save_user_profile(self._user_profile)
|
||
dlg.destroy()
|
||
self._show_profile_editor()
|
||
else:
|
||
pw_check = simpledialog.askstring("Passwort",
|
||
"Bitte Passwort eingeben:", show="*", parent=dlg)
|
||
if pw_check and self._verify_password(pw_check,
|
||
self._user_profile.get("password_hash", "")):
|
||
dlg.destroy()
|
||
self._show_2fa_setup(pw_check)
|
||
elif pw_check:
|
||
messagebox.showerror("Fehler",
|
||
"Falsches Passwort.", parent=dlg)
|
||
|
||
btn_text = "Deaktivieren" if is_active else "2FA einrichten"
|
||
btn_color = "#E74C3C" if is_active else "#27AE60"
|
||
tk.Button(tfa_frame, text=btn_text, font=("Segoe UI", 9),
|
||
bg=btn_color, fg="white", relief="flat",
|
||
padx=8, pady=2, cursor="hand2",
|
||
command=do_toggle_2fa).pack(side="right")
|
||
|
||
def _reset_window_positions(self):
|
||
"""Setzt alle gespeicherten Fensterpositionen und KG-Einstellungen zurück."""
|
||
answer = messagebox.askyesno(
|
||
"Fensterpositionen zurücksetzen",
|
||
"Alle Fensterpositionen und KG-Einstellungen zurücksetzen?\n\n"
|
||
"Beim nächsten Start werden alle Fenster\n"
|
||
"in der Bildschirmmitte geöffnet.\n"
|
||
"Die KG-Detailstufe (Kürzer/Ausführlicher)\n"
|
||
"wird auf Standard zurückgesetzt.",
|
||
parent=self,
|
||
)
|
||
if not answer:
|
||
return
|
||
deleted = reset_all_window_positions()
|
||
self._update_kg_detail_display()
|
||
self._soap_section_levels = {k: 0 for k in _SOAP_SECTIONS}
|
||
self._update_soap_section_display()
|
||
try:
|
||
reset_button_heat()
|
||
except Exception:
|
||
pass
|
||
messagebox.showinfo(
|
||
"Zurückgesetzt",
|
||
f"{deleted} Fensterposition(en) zurückgesetzt.\n"
|
||
"KG-Detailstufe und Button-Farben auf Standard zurückgesetzt.\n\n"
|
||
"Bitte starten Sie die Anwendung neu,\n"
|
||
"damit die Änderung wirksam wird.",
|
||
parent=self,
|
||
)
|
||
|
||
def _toggle_minimize(self):
|
||
"""Fenster minimieren: Neu, Brief, OP-Bericht, Diktat oben; Aufnahme unten; Transparenz unten."""
|
||
if self._minimized:
|
||
self._status_row.pack(fill="x")
|
||
self.paned.pack(fill="both", expand=True, padx=10, pady=(0, 10))
|
||
self._bottom_frame.pack(fill="x")
|
||
self._top_right.pack(side="right", anchor="n")
|
||
self._btn_row_left.pack(side="left")
|
||
if getattr(self, "_mini_frame", None) is not None:
|
||
try:
|
||
self._mini_frame.destroy()
|
||
except Exception:
|
||
pass
|
||
self._mini_frame = None
|
||
self._mini_btn_korrigieren = None
|
||
self._btn_minimize.configure(text="−")
|
||
self._minimized = False
|
||
self.minsize(680, 560)
|
||
try:
|
||
g = getattr(self, "_geometry_before_minimize", None)
|
||
if g and len(g) >= 4:
|
||
self.geometry(f"{g[0]}x{g[1]}+{g[2]}+{g[3]}")
|
||
except Exception:
|
||
pass
|
||
else:
|
||
self._geometry_before_minimize = (
|
||
self.winfo_width(), self.winfo_height(), self.winfo_x(), self.winfo_y()
|
||
)
|
||
self.paned.pack_forget()
|
||
self._bottom_frame.pack_forget()
|
||
self._top_right.pack_forget()
|
||
self._btn_row_left.pack_forget()
|
||
self._btn_minimize.configure(text="□")
|
||
self._minimized = True
|
||
self.minsize(360, 120)
|
||
top = self._btn_row_left.master
|
||
self._mini_frame = ttk.Frame(top, style="TopBar.TFrame", padding=(0, 0, 0, 4))
|
||
self._mini_frame.pack(fill="x")
|
||
top_row = ttk.Frame(self._mini_frame)
|
||
top_row.pack(fill="x")
|
||
|
||
# Buttons mit aktueller Skalierung
|
||
button_scale = load_button_scale()
|
||
font_scale = load_font_scale()
|
||
|
||
self._mini_btn_start = RoundedButton(
|
||
top_row, "⏺ Start", command=self.toggle_record,
|
||
bg="#5B8DB3", fg="white", active_bg="#4A7A9E",
|
||
width=80, height=26, canvas_bg="#B9ECFA",
|
||
)
|
||
self._mini_btn_start.set_button_size_scale(button_scale)
|
||
self._mini_btn_start.set_font_size_scale(font_scale)
|
||
self._mini_btn_start.pack(side="left", padx=(0, 4), anchor="n")
|
||
if self.is_recording and getattr(self, "_recording_mode", "") == "new":
|
||
self._mini_btn_start.configure(text="⏹ Stopp")
|
||
|
||
self._mini_btn_korrigieren = RoundedButton(
|
||
top_row, "⏺ Korrig.", command=self._toggle_record_append,
|
||
bg="#5B8DB3", fg="white", active_bg="#4A7A9E",
|
||
width=65, height=26, canvas_bg="#B9ECFA",
|
||
)
|
||
self._mini_btn_korrigieren.set_button_size_scale(button_scale)
|
||
self._mini_btn_korrigieren.set_font_size_scale(font_scale)
|
||
self._mini_btn_korrigieren.pack(side="left", padx=(0, 4), anchor="n")
|
||
if self.is_recording and getattr(self, "_recording_mode", "") == "append":
|
||
self._mini_btn_korrigieren.configure(text="⏹ Stopp")
|
||
|
||
btn_diktat = RoundedButton(top_row, "Diktat", command=self.open_diktat_window, width=60, height=26, canvas_bg="#95D6ED", bg="#95D6ED", fg="#1a4d6d", active_bg="#7BC8E0")
|
||
btn_diktat.set_button_size_scale(button_scale)
|
||
btn_diktat.set_font_size_scale(font_scale)
|
||
btn_diktat.pack(side="left", padx=(0, 4), anchor="n")
|
||
|
||
btn_brief = RoundedButton(top_row, "Brief", command=self.open_brief_window, width=60, height=26, canvas_bg="#7EC8E3", bg="#7EC8E3", fg="#1a4d6d", active_bg="#6CB8D3")
|
||
btn_brief.set_button_size_scale(button_scale)
|
||
btn_brief.set_font_size_scale(font_scale)
|
||
btn_brief.pack(side="left", padx=(0, 4), anchor="n")
|
||
|
||
btn_op = RoundedButton(top_row, "OP-Bericht", command=self.open_op_bericht_window, width=90, height=26, canvas_bg="#A6E0F5", bg="#A6E0F5", fg="#1a4d6d", active_bg="#94D0E5")
|
||
btn_op.set_button_size_scale(button_scale)
|
||
btn_op.set_font_size_scale(font_scale)
|
||
btn_op.pack(side="left", padx=(0, 4), anchor="n")
|
||
|
||
btn_min = RoundedButton(top_row, "−", command=self._toggle_minimize, width=28, height=26, canvas_bg="#B9ECFA")
|
||
btn_min.set_button_size_scale(button_scale)
|
||
btn_min.set_font_size_scale(font_scale)
|
||
btn_min.pack(side="left", padx=(4, 0), anchor="n")
|
||
|
||
btn_tile = RoundedButton(top_row, "⊞", command=self.arrange_windows_top, width=28, height=26, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8")
|
||
btn_tile.set_button_size_scale(button_scale)
|
||
btn_tile.set_font_size_scale(font_scale)
|
||
btn_tile.pack(side="left", padx=(3, 0), anchor="n")
|
||
|
||
btn_reset_pos = RoundedButton(top_row, "↺", command=self._reset_window_positions, width=28, height=26, canvas_bg="#BAE8F8", bg="#BAE8F8", fg="#1a4d6d", active_bg="#A8D8E8")
|
||
btn_reset_pos.set_button_size_scale(button_scale)
|
||
btn_reset_pos.set_font_size_scale(font_scale)
|
||
btn_reset_pos.pack(side="left", padx=(4, 0), anchor="n")
|
||
opacity_var = tk.DoubleVar(value=round(load_opacity() * 100))
|
||
|
||
def on_opacity_change(val):
|
||
try:
|
||
alpha = float(val) / 100.0
|
||
alpha = max(MIN_OPACITY, min(1.0, alpha))
|
||
self.attributes("-alpha", alpha)
|
||
save_opacity(alpha)
|
||
except Exception:
|
||
pass
|
||
|
||
mini_rclick = tk.Checkbutton(
|
||
self._mini_frame, text="Rechtsklick = Einfügen",
|
||
variable=self._rclick_paste_var,
|
||
command=self._toggle_rclick_paste,
|
||
bg="#B9ECFA", activebackground="#B9ECFA",
|
||
font=("Segoe UI", 8), anchor="w",
|
||
)
|
||
mini_rclick.pack(fill="x", padx=(4, 0))
|
||
|
||
self._mini_status_label = tk.Label(
|
||
self._mini_frame, textvariable=self.status_var,
|
||
fg=self._autotext_data.get("status_color", "#BD4500"),
|
||
bg="#B9ECFA", font=("Segoe UI", 8), anchor="w",
|
||
)
|
||
if self._autotext_data.get("status_color") != "hidden":
|
||
self._mini_status_label.pack(fill="x", padx=(4, 0))
|
||
|
||
opacity_row = ttk.Frame(self._mini_frame, padding=(0, 4, 0, 0))
|
||
opacity_row.pack(fill="x")
|
||
opacity_inner = ttk.Frame(opacity_row)
|
||
opacity_inner.pack(side="left")
|
||
|
||
lbl_half = tk.Label(opacity_inner, text="◐", font=("Segoe UI Symbol", 14),
|
||
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
|
||
lbl_half.pack(side="left", padx=(0, 1))
|
||
lbl_half.bind("<Button-1>", lambda e: (opacity_var.set(round(MIN_OPACITY * 100)),
|
||
on_opacity_change(str(MIN_OPACITY * 100))))
|
||
lbl_half.bind("<Enter>", lambda e: lbl_half.configure(fg="#1a4d6d"))
|
||
lbl_half.bind("<Leave>", lambda e: lbl_half.configure(fg="#7AAFC8"))
|
||
|
||
try:
|
||
s = ttk.Style(self)
|
||
s.configure("MiniOpacity.Horizontal.TScale", troughcolor="#c8ecf8", background="#5B8DB3")
|
||
except Exception:
|
||
pass
|
||
opacity_scale = ttk.Scale(
|
||
opacity_inner,
|
||
from_=40, to=100, variable=opacity_var,
|
||
orient="horizontal", length=50, command=on_opacity_change,
|
||
style="MiniOpacity.Horizontal.TScale",
|
||
)
|
||
opacity_scale.pack(side="left")
|
||
|
||
lbl_sun = tk.Label(opacity_inner, text="☀", font=("Segoe UI Symbol", 14),
|
||
bg="#B9ECFA", fg="#7AAFC8", cursor="hand2")
|
||
lbl_sun.pack(side="left", padx=(1, 0))
|
||
lbl_sun.bind("<Button-1>", lambda e: (opacity_var.set(100),
|
||
on_opacity_change("100")))
|
||
lbl_sun.bind("<Enter>", lambda e: lbl_sun.configure(fg="#1a4d6d"))
|
||
lbl_sun.bind("<Leave>", lambda e: lbl_sun.configure(fg="#7AAFC8"))
|
||
|
||
# Fenstergröe dynamisch basierend auf Button-Skalierung
|
||
base_width = 580
|
||
base_height = 150
|
||
scaled_width = int(base_width * button_scale)
|
||
scaled_height = int(base_height * button_scale)
|
||
self.geometry(f"{scaled_width}x{scaled_height}")
|
||
|
||
def _register_window(self, win):
|
||
"""Registriert ein AZA-Fenster im zentralen Tracker und entfernt es beim Schliessen."""
|
||
try:
|
||
if win and win.winfo_exists():
|
||
self._window_registry.add(win)
|
||
ico = getattr(self, "_aza_icon_path", None)
|
||
if ico:
|
||
try:
|
||
win.iconbitmap(ico)
|
||
except Exception:
|
||
pass
|
||
|
||
orig_protocol = None
|
||
try:
|
||
orig_protocol = win.protocol("WM_DELETE_WINDOW")
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_close():
|
||
try:
|
||
self._window_registry.discard(win)
|
||
except Exception:
|
||
pass
|
||
if orig_protocol and callable(orig_protocol):
|
||
try:
|
||
orig_protocol()
|
||
return
|
||
except Exception:
|
||
pass
|
||
try:
|
||
win.destroy()
|
||
except Exception:
|
||
pass
|
||
|
||
win.protocol("WM_DELETE_WINDOW", _on_close)
|
||
except Exception:
|
||
pass
|
||
|
||
def _get_registered_windows(self):
|
||
"""Liefert alle noch lebenden registrierten Fenster (ohne self)."""
|
||
out = []
|
||
for w in list(self._window_registry):
|
||
try:
|
||
if w and w.winfo_exists() and w is not self:
|
||
out.append(w)
|
||
elif w is self:
|
||
continue
|
||
else:
|
||
self._window_registry.discard(w)
|
||
except Exception:
|
||
self._window_registry.discard(w)
|
||
return out
|
||
|
||
def _get_work_area_for_window(self, win):
|
||
"""Returns (left, top, right, bottom) of the monitor work-area containing *win*."""
|
||
try:
|
||
user32 = ctypes.windll.user32
|
||
MONITOR_DEFAULTTONEAREST = 2
|
||
|
||
class RECT(ctypes.Structure):
|
||
_fields_ = [("left", wintypes.LONG),
|
||
("top", wintypes.LONG),
|
||
("right", wintypes.LONG),
|
||
("bottom", wintypes.LONG)]
|
||
|
||
class MONITORINFO(ctypes.Structure):
|
||
_fields_ = [("cbSize", wintypes.DWORD),
|
||
("rcMonitor", RECT),
|
||
("rcWork", RECT),
|
||
("dwFlags", wintypes.DWORD)]
|
||
|
||
hwnd = win.winfo_id()
|
||
hmon = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
|
||
mi = MONITORINFO()
|
||
mi.cbSize = ctypes.sizeof(MONITORINFO)
|
||
user32.GetMonitorInfoW(hmon, ctypes.byref(mi))
|
||
r = mi.rcWork
|
||
return (r.left, r.top, r.right, r.bottom)
|
||
except Exception:
|
||
sw = self.winfo_screenwidth()
|
||
sh = self.winfo_screenheight()
|
||
return (0, 0, sw, sh)
|
||
|
||
def _get_all_toplevels(self):
|
||
"""Alle sichtbaren Toplevel-Fenster sammeln (rekursiv)."""
|
||
result = []
|
||
def _collect(parent):
|
||
for w in parent.winfo_children():
|
||
if isinstance(w, tk.Toplevel) and w.winfo_exists() and w.winfo_viewable():
|
||
result.append(w)
|
||
_collect(w)
|
||
_collect(self)
|
||
return result
|
||
|
||
def arrange_windows_top(self):
|
||
"""
|
||
Minimiert ALLE AZA-Fenster und dockt sie nebeneinander oben an.
|
||
"""
|
||
try:
|
||
print("=== ARRANGE WINDOWS START ===")
|
||
all_windows = [self] + self._get_registered_windows()
|
||
if not all_windows:
|
||
print("Keine Fenster gefunden")
|
||
return
|
||
|
||
self._tiling_active = True
|
||
print(f"Gefundene Fenster: {len(all_windows)}")
|
||
|
||
# SCHRITT 1: Minimiere alle Fenster
|
||
for w in all_windows:
|
||
try:
|
||
if not (w and w.winfo_exists()):
|
||
continue
|
||
minimize_fn = getattr(w, "_aza_minimize", None)
|
||
is_mini_fn = getattr(w, "_aza_is_minimized", None)
|
||
|
||
already_mini = False
|
||
if is_mini_fn and callable(is_mini_fn):
|
||
try:
|
||
already_mini = is_mini_fn()
|
||
except Exception:
|
||
already_mini = False
|
||
|
||
if minimize_fn and callable(minimize_fn) and not already_mini:
|
||
print(f"Minimiere Fenster: {w.title()}")
|
||
minimize_fn()
|
||
except Exception as e:
|
||
print(f"Fehler beim Minimieren: {e}")
|
||
|
||
# SCHRITT 2: Warte LAENGER, damit Tk alle Minimize-Operationen abgeschlossen hat
|
||
print("Warte auf Minimize-Abschluss (1500ms)...")
|
||
self.after(1500, lambda: self._perform_docking_logic_horizontal(all_windows))
|
||
|
||
except Exception as e:
|
||
print(f"arrange_windows_top error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
self._tiling_active = False
|
||
|
||
def _perform_docking_logic_horizontal(self, windows):
|
||
"""
|
||
Positions windows horizontally in a row at the top.
|
||
"""
|
||
try:
|
||
print("=== DOCKING LOGIC START ===")
|
||
|
||
for w in windows:
|
||
try:
|
||
if w and w.winfo_exists():
|
||
w.update()
|
||
w.update_idletasks()
|
||
except Exception:
|
||
pass
|
||
|
||
L, T, R, _B = self._get_work_area_for_window(self)
|
||
work_w = max(1, R - L)
|
||
|
||
print(f"Work area: L={L}, T={T}, R={R}, work_w={work_w}")
|
||
|
||
gap = 12
|
||
pad = 15
|
||
y_top = T + pad
|
||
|
||
alive = [w for w in windows if w and w.winfo_exists()]
|
||
print(f"Alive windows: {len(alive)}")
|
||
|
||
if not alive:
|
||
self._tiling_active = False
|
||
return
|
||
|
||
window_info = []
|
||
for i, w in enumerate(alive):
|
||
try:
|
||
ww = w.winfo_width()
|
||
hh = w.winfo_height()
|
||
|
||
if ww < 80:
|
||
ww = 180
|
||
if hh < 80:
|
||
hh = 80
|
||
|
||
print(f"Window {i}: width={ww}, height={hh}")
|
||
window_info.append({'win': w, 'width': ww, 'height': hh})
|
||
except Exception as e:
|
||
print(f"Error getting window dims: {e}")
|
||
window_info.append({'win': w, 'width': 180, 'height': 80})
|
||
|
||
total_width = sum(info['width'] for info in window_info) + max(0, (len(alive) - 1) * gap)
|
||
print(f"Total width needed: {total_width}, available: {work_w - 2*pad}")
|
||
|
||
if total_width <= work_w - 2 * pad:
|
||
x_start = L + (work_w - total_width) // 2
|
||
print(f"All windows fit, centering at x={x_start}")
|
||
else:
|
||
x_start = L + pad
|
||
print(f"Windows don't fit, aligning left at x={x_start}")
|
||
|
||
x_pos = x_start
|
||
for i, info in enumerate(window_info):
|
||
w = info['win']
|
||
ww = info['width']
|
||
hh = info['height']
|
||
try:
|
||
x_clamped = x_pos
|
||
if x_clamped + ww > R - pad:
|
||
x_clamped = max(L + pad, R - ww - pad)
|
||
|
||
geom_str = f"{ww}x{hh}+{x_clamped}+{y_top}"
|
||
print(f"Setting window {i} geometry: {geom_str}")
|
||
|
||
w.geometry(geom_str)
|
||
|
||
w.update()
|
||
w.update_idletasks()
|
||
|
||
w.lift()
|
||
w.deiconify()
|
||
|
||
x_pos += ww + gap
|
||
except Exception as e:
|
||
print(f"Error placing window {i}: {e}")
|
||
|
||
print("=== DOCKING LOGIC END ===")
|
||
print(f"_tiling_active: {self._tiling_active}")
|
||
|
||
except Exception as e:
|
||
print(f"_perform_docking_logic_horizontal error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
finally:
|
||
self.after(500, self._clear_tiling_flag)
|
||
|
||
def _clear_tiling_flag(self):
|
||
"""Clears the tiling flag and triggers final geometry save."""
|
||
self._tiling_active = False
|
||
print("_tiling_active: False (flag cleared)")
|
||
self.after(50, self._save_window_geometry)
|
||
|
||
def set_status(self, s: str):
|
||
self.status_var.set(s)
|
||
self.update_idletasks()
|
||
|
||
# ─── Build-Info ───
|
||
|
||
@staticmethod
|
||
def _make_build_info_string() -> str:
|
||
try:
|
||
from _build_info import BUILD_TIME, GIT_COMMIT, GIT_BRANCH, GIT_DIRTY
|
||
parts = [f"Build {BUILD_TIME}"]
|
||
if GIT_COMMIT:
|
||
parts.append(GIT_COMMIT)
|
||
if GIT_BRANCH and GIT_BRANCH != "HEAD":
|
||
parts.append(GIT_BRANCH)
|
||
if GIT_DIRTY:
|
||
parts.append("*")
|
||
return " · ".join(parts)
|
||
except Exception:
|
||
return "Build: dev"
|
||
|
||
def _show_about_dialog(self):
|
||
try:
|
||
from aza_version import APP_VERSION, APP_CHANNEL
|
||
except Exception:
|
||
APP_VERSION, APP_CHANNEL = "?", "?"
|
||
try:
|
||
from _build_info import BUILD_TIME, GIT_COMMIT, GIT_BRANCH, GIT_DIRTY
|
||
except Exception:
|
||
BUILD_TIME = GIT_COMMIT = GIT_BRANCH = "?"
|
||
GIT_DIRTY = False
|
||
|
||
dirty_txt = " (uncommitted changes)" if GIT_DIRTY else ""
|
||
info_text = (
|
||
f"AZA – KI Assistent\n\n"
|
||
f"Version: {APP_VERSION} ({APP_CHANNEL})\n"
|
||
f"Build: {BUILD_TIME}\n"
|
||
f"Commit: {GIT_COMMIT}{dirty_txt}\n"
|
||
f"Branch: {GIT_BRANCH}\n"
|
||
f"System: {platform.system()} {platform.release()}\n"
|
||
f"Host: {platform.node()}"
|
||
)
|
||
messagebox.showinfo("Über AZA", info_text)
|
||
|
||
def _toggle_transcript_collapse(self, event=None):
|
||
self._transcript_collapsed = not self._transcript_collapsed
|
||
if self._transcript_collapsed:
|
||
self._transcript_frame.pack_forget()
|
||
self._transcript_toggle_label.configure(text="\u25B6 Transkript:")
|
||
else:
|
||
self._transcript_frame.pack(fill="both", expand=True)
|
||
self._transcript_toggle_label.configure(text="\u25BC Transkript:")
|
||
|
||
def _toggle_kg_collapse(self, event=None):
|
||
self._kg_collapsed = not self._kg_collapsed
|
||
if self._kg_collapsed:
|
||
self._kg_frame.pack_forget()
|
||
self._kg_toggle_label.configure(text="\u25B6 Krankengeschichte:")
|
||
else:
|
||
self._kg_frame.pack(fill="both", expand=True)
|
||
self._kg_toggle_label.configure(text="\u25BC Krankengeschichte:")
|
||
|
||
def _on_logo_click(self, event=None):
|
||
"""Logo-Klick: Aufnahme starten oder stoppen."""
|
||
self.toggle_record()
|
||
|
||
def _apply_status_color(self):
|
||
"""Wendet die gespeicherte Statusanzeige-Farbe an."""
|
||
color = self._autotext_data.get("status_color", "#BD4500")
|
||
if color == "hidden":
|
||
self._status_row.pack_forget()
|
||
else:
|
||
try:
|
||
self._status_row.pack(fill="x")
|
||
# Vor paned einordnen
|
||
self._status_row.pack(fill="x", before=self.paned)
|
||
except Exception:
|
||
self._status_row.pack(fill="x")
|
||
self.lbl_status.configure(fg=color)
|
||
|
||
def _start_timer(self, phase: str):
|
||
self._phase = phase
|
||
self._timer_sec = 0
|
||
self._timer_running = True
|
||
self._tick_timer()
|
||
|
||
def _tick_timer(self):
|
||
if not self._timer_running:
|
||
return
|
||
self._timer_sec += 1
|
||
if self._phase == "transcribe":
|
||
self.set_status("Transkribiere Audio (%d s)" % self._timer_sec)
|
||
elif self._phase == "kg":
|
||
self.set_status("Erstelle Krankengeschichte (%d s)" % self._timer_sec)
|
||
self.after(1000, self._tick_timer)
|
||
|
||
def _show_interaktion_window(self, meds: list, result: str) -> None:
|
||
"""Zeigt das Interaktionscheck-Ergebnis in einem Fenster."""
|
||
win = tk.Toplevel(self)
|
||
win.title("Interaktionscheck")
|
||
win.transient(self)
|
||
win.configure(bg="#B9ECFA")
|
||
win.minsize(500, 400)
|
||
win.attributes("-topmost", True)
|
||
self._register_window(win)
|
||
|
||
# Fensterposition: gespeichert laden oder zentrieren
|
||
setup_window_geometry_saving(win, "interaktionscheck", 700, 550)
|
||
|
||
add_resize_grip(win, 500, 400)
|
||
add_font_scale_control(win)
|
||
f = ttk.Frame(win, padding=12)
|
||
f.pack(fill="both", expand=True)
|
||
med_header = ttk.Frame(f)
|
||
med_header.pack(fill="x", anchor="w")
|
||
ttk.Label(med_header, text=f"Geprüfte Medikamente/Therapien: {', '.join(meds)}").pack(side="left")
|
||
txt = ScrolledText(f, wrap="word", font=self._text_font, bg="#F5FCFF", height=18)
|
||
txt.pack(fill="both", expand=True, pady=(8, 8))
|
||
add_text_font_size_control(med_header, txt, initial_size=10, bg_color="#B9ECFA", save_key="medikamenten_check")
|
||
txt.insert("1.0", result.strip())
|
||
self._bind_text_context_menu(txt)
|
||
|
||
# _show_text_window -> ausgelagert in Mixin-Modul
|
||
|
||
# _request_async_document -> ausgelagert in Mixin-Modul
|
||
|
||
# open_brief_window -> ausgelagert in Mixin-Modul
|
||
|
||
# open_rezept_window -> ausgelagert in Mixin-Modul
|
||
|
||
# open_kogu_window -> ausgelagert in Mixin-Modul
|
||
|
||
# open_diskussion_window -> ausgelagert in Mixin-Modul
|
||
|
||
# open_op_bericht_window -> ausgelagert in Mixin-Modul
|
||
|
||
def _open_uebersetzer(self):
|
||
"""Startet das Übersetzer-Programm (translate.py) als eigenständiges Fenster."""
|
||
if not self._check_ai_consent():
|
||
return
|
||
try:
|
||
if getattr(sys, "frozen", False):
|
||
import translate
|
||
threading.Thread(target=translate.main, daemon=True).start()
|
||
else:
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
translate_path = os.path.join(script_dir, "translate.py")
|
||
if not os.path.exists(translate_path):
|
||
messagebox.showerror("Fehler", f"translate.py nicht gefunden:\n{translate_path}")
|
||
return
|
||
kwargs = {"cwd": script_dir}
|
||
if sys.platform == "win32":
|
||
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||
subprocess.Popen([sys.executable, translate_path], **kwargs)
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e))
|
||
|
||
def _open_email(self):
|
||
"""Startet das E-Mail-Programm (aza_email.py) als eigenständiges Fenster."""
|
||
try:
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
email_path = os.path.join(script_dir, "aza_email.py")
|
||
if not os.path.exists(email_path):
|
||
messagebox.showerror("Fehler", f"aza_email.py nicht gefunden:\n{email_path}")
|
||
return
|
||
|
||
# Einfacher Start ohne komplizierte Flags (funktioniert besser)
|
||
if sys.platform == "win32":
|
||
# Windows: Einfach mit pythonw.exe starten (kein Konsolenfenster)
|
||
pythonw = sys.executable.replace("python.exe", "pythonw.exe")
|
||
if os.path.exists(pythonw):
|
||
subprocess.Popen([pythonw, email_path], cwd=script_dir)
|
||
else:
|
||
subprocess.Popen([sys.executable, email_path], cwd=script_dir,
|
||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||
else:
|
||
subprocess.Popen([sys.executable, email_path], cwd=script_dir)
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", f"E-Mail konnte nicht gestartet werden:\n{str(e)}")
|
||
|
||
def _open_whatsapp(self):
|
||
"""Startet das WhatsApp-Programm (aza_whatsapp.py) als eigenständiges Fenster."""
|
||
try:
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
whatsapp_path = os.path.join(script_dir, "aza_whatsapp.py")
|
||
if not os.path.exists(whatsapp_path):
|
||
messagebox.showerror("Fehler", f"aza_whatsapp.py nicht gefunden:\n{whatsapp_path}")
|
||
return
|
||
|
||
# Einfacher Start ohne komplizierte Flags (funktioniert besser)
|
||
if sys.platform == "win32":
|
||
# Windows: Einfach mit pythonw.exe starten (kein Konsolenfenster)
|
||
pythonw = sys.executable.replace("python.exe", "pythonw.exe")
|
||
if os.path.exists(pythonw):
|
||
subprocess.Popen([pythonw, whatsapp_path], cwd=script_dir)
|
||
else:
|
||
subprocess.Popen([sys.executable, whatsapp_path], cwd=script_dir,
|
||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||
else:
|
||
subprocess.Popen([sys.executable, whatsapp_path], cwd=script_dir)
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", f"WhatsApp konnte nicht gestartet werden:\n{str(e)}")
|
||
|
||
def _open_docapp(self):
|
||
"""Startet das DocApp-Programm (aza_docapp.py) als eigenständiges Fenster."""
|
||
try:
|
||
if getattr(sys, "frozen", False):
|
||
import aza_docapp
|
||
threading.Thread(target=aza_docapp.main, daemon=True).start()
|
||
else:
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
docapp_path = os.path.join(script_dir, "aza_docapp.py")
|
||
if not os.path.exists(docapp_path):
|
||
messagebox.showerror("Fehler", f"aza_docapp.py nicht gefunden:\n{docapp_path}")
|
||
return
|
||
kwargs = {"cwd": script_dir}
|
||
if sys.platform == "win32":
|
||
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||
subprocess.Popen([sys.executable, docapp_path], **kwargs)
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", f"DocApp konnte nicht gestartet werden:\n{str(e)}")
|
||
|
||
def _open_macro(self):
|
||
"""Startet das Macro-Programm (aza_macro.py) als eigenständiges Fenster."""
|
||
self._launch_macro_process()
|
||
|
||
# ── Dev-Status-Fenster ──────────────────────────────────────────────
|
||
|
||
def _open_dev_status_window(self):
|
||
try:
|
||
existing = getattr(self, "_dev_status_window", None)
|
||
if existing is not None and existing.winfo_exists():
|
||
existing.deiconify()
|
||
existing.lift()
|
||
return
|
||
from dev_status_window import DevStatusWindow
|
||
self._dev_status_window = DevStatusWindow(self)
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Kongress-Fenster ───────────────────────────────────────────────────
|
||
|
||
def _open_kongress_window(self):
|
||
from congress_window import CongressWindow
|
||
try:
|
||
from aza_ai_client import get_ai_client
|
||
ai_client = get_ai_client()
|
||
except Exception:
|
||
ai_client = self.client
|
||
CongressWindow(self, ai_client, self._autotext_data, save_autotext)
|
||
|
||
# ── Kongress 2 (Google CSE Trial) ─────────────────────────────────────
|
||
|
||
def _open_kongress2_window(self):
|
||
existing = getattr(self, "_kongress2_window", None)
|
||
if existing is not None and existing.winfo_exists():
|
||
existing.deiconify()
|
||
existing.lift()
|
||
existing.focus_force()
|
||
return
|
||
from kongress2_window import Kongress2Window
|
||
self._kongress2_window = Kongress2Window(self)
|
||
|
||
# ── News-Fenster ──────────────────────────────────────────────────────
|
||
|
||
def _open_news_window(self):
|
||
existing = getattr(self, "_news_window", None)
|
||
if existing is not None and existing.winfo_exists():
|
||
existing.deiconify()
|
||
existing.lift()
|
||
existing.focus_force()
|
||
return
|
||
|
||
win = tk.Toplevel(self)
|
||
self._news_window = win
|
||
win.title("Medizin-News")
|
||
win.configure(bg="#f7fafc")
|
||
win.minsize(480, 400)
|
||
add_resize_grip(win, 480, 400)
|
||
|
||
try:
|
||
sw = max(1200, int(self.winfo_screenwidth()))
|
||
sh = max(800, int(self.winfo_screenheight()))
|
||
w = max(500, int(sw * 0.30))
|
||
h = max(500, int(sh * 0.80))
|
||
x_off = 8 + w + 8
|
||
win.geometry(f"{w}x{h}+{x_off}+40")
|
||
except Exception:
|
||
pass
|
||
|
||
header = tk.Frame(win, bg="#e6f0e8", padx=10, pady=6)
|
||
header.pack(fill="x")
|
||
tk.Label(header, text="Medizin-News", bg="#e6f0e8", fg="#2a5a3a",
|
||
font=("Segoe UI", 10, "bold")).pack(side="left")
|
||
status_var = tk.StringVar(value="")
|
||
|
||
btn_bar = tk.Frame(header, bg="#e6f0e8")
|
||
btn_bar.pack(side="right")
|
||
ttk.Button(btn_bar, text="⟳", width=3, command=lambda: _search()).pack(side="right", padx=2)
|
||
|
||
text_frame = tk.Frame(win, bg="#ffffff", bd=0)
|
||
text_frame.pack(fill="both", expand=True, padx=6, pady=(2, 4))
|
||
|
||
text_widget = tk.Text(text_frame, wrap="word", font=("Segoe UI", 9), bg="#ffffff",
|
||
fg="#23404f", relief="flat", padx=10, pady=8, cursor="arrow",
|
||
spacing1=1, spacing3=1)
|
||
scrollbar = ttk.Scrollbar(text_frame, orient="vertical", command=text_widget.yview)
|
||
text_widget.configure(yscrollcommand=scrollbar.set)
|
||
scrollbar.pack(side="right", fill="y")
|
||
text_widget.pack(side="left", fill="both", expand=True)
|
||
text_widget.configure(state="disabled")
|
||
|
||
text_widget.tag_configure("nw_heading", font=("Segoe UI", 10, "bold"), foreground="#1a5a2a",
|
||
spacing1=8, spacing3=3, background="#eaf5ed")
|
||
text_widget.tag_configure("nw_bold", font=("Segoe UI", 9, "bold"), foreground="#0e3520")
|
||
text_widget.tag_configure("nw_normal", font=("Segoe UI", 9), foreground="#2b5040")
|
||
text_widget.tag_configure("nw_loading", font=("Segoe UI", 9), foreground="#6aab80")
|
||
|
||
status_bar = tk.Label(win, textvariable=status_var, bg="#e6f0e8", fg="#4a8a5c",
|
||
font=("Segoe UI", 8), anchor="w", padx=8)
|
||
status_bar.pack(fill="x", side="bottom")
|
||
|
||
nw_link_cnt = [0]
|
||
|
||
def _open_url(url):
|
||
try:
|
||
webbrowser.open(url)
|
||
except Exception:
|
||
pass
|
||
|
||
def _clean_md(line):
|
||
line = re.sub(r'\[([^\]]*)\]\(([^\)]*)\)', r'\1 \2', line)
|
||
return line
|
||
|
||
def _render_line(raw):
|
||
line = str(raw)
|
||
if not line.strip():
|
||
return
|
||
line = re.sub(r'\[([^\]]*)\]\(([^\)]*)\)', r'\1 \2', line)
|
||
cursor = 0
|
||
for m in re.finditer(r'https?://[^\s\)\]>,;]+', line):
|
||
url = m.group(0).rstrip('.,;)>')
|
||
if m.start() > cursor:
|
||
text_widget.insert("end", line[cursor:m.start()], "nw_normal")
|
||
nw_link_cnt[0] += 1
|
||
ltag = f"nwl_{nw_link_cnt[0]}"
|
||
text_widget.tag_configure(ltag, font=("Segoe UI", 8, "underline"),
|
||
foreground="#2a8a4a")
|
||
text_widget.tag_bind(ltag, "<Button-1>", lambda e, u=url: _open_url(u))
|
||
text_widget.tag_bind(ltag, "<Enter>",
|
||
lambda e: text_widget.configure(cursor="hand2"))
|
||
text_widget.tag_bind(ltag, "<Leave>",
|
||
lambda e: text_widget.configure(cursor="arrow"))
|
||
text_widget.insert("end", url, ltag)
|
||
cursor = m.end()
|
||
if cursor < len(line):
|
||
text_widget.insert("end", line[cursor:], "nw_normal")
|
||
|
||
def _search():
|
||
status_var.set("Suche aktuelle Medizin-News …")
|
||
text_widget.configure(state="normal")
|
||
text_widget.delete("1.0", "end")
|
||
text_widget.insert("end", "Suche die neuesten Medizin-News …\n", "nw_loading")
|
||
text_widget.insert("end", "Das kann 15–30 Sekunden Jetzt ist es total kaputt. Jetzt werden nicht mehr die wichtigsten Organisationen, oben dargestellt pro Fachrichtung, welche man angekreuzt hatte, sondern alle Fachrichtungen. Oder ich habe nicht geschaut, ob das Häkchen plötzlich rausgefallen ist. Aber wenn kein Häkchen gesetzt ist, dann soll man den Benutzer fragen, welche Fachrichtungen er gerne hätte, damit man nicht alles aufzählen muss und viel Tokens verliert. Und bitte dieses Fenster, welches die Fachrichtungen abfragt, wenn kein Häkchen gesetzt wurde, bitte jeweils in der Mitte vom Bildschirm präsentieren..\n", "nw_loading")
|
||
text_widget.configure(state="disabled")
|
||
|
||
def _job():
|
||
try:
|
||
today = date.today().isoformat()
|
||
prompt = (
|
||
f"Suche im Internet nach den wichtigsten aktuellen Medizin-News "
|
||
f"weltweit von heute ({today}) und den letzten Tagen.\n\n"
|
||
f"Berücksichtige die renommiertesten Quellen weltweit:\n"
|
||
f"- NEJM (New England Journal of Medicine)\n"
|
||
f"- The Lancet\n"
|
||
f"- JAMA (Journal of the American Medical Association)\n"
|
||
f"- BMJ (British Medical Journal)\n"
|
||
f"- Nature Medicine, Nature, Science\n"
|
||
f"- Deutsches Ärzteblatt\n"
|
||
f"- Swiss Medical Weekly\n"
|
||
f"- Medscape, Reuters Health\n"
|
||
f"- WHO, FDA, EMA, Swissmedic\n"
|
||
f"- CDC, ECDC\n"
|
||
f"- PubMed/PMC (aktuelle Studien)\n\n"
|
||
f"Für jede News formatiere so:\n"
|
||
f"**Titel der News hier**\n"
|
||
f"Quelle · Datum\n"
|
||
f"Zusammenfassung in 2–3 Sätzen mit den wichtigsten Erkenntnissen.\n"
|
||
f"URL zum Originalartikel\n\n"
|
||
f"Zeige 20–30 der wichtigsten News weltweit, neueste zuerst.\n"
|
||
f"Decke verschiedene Bereiche ab: Onkologie, Kardiologie, "
|
||
f"Infektiologie, Neurologie, Pharmakologie, Public Health etc.\n"
|
||
f"Verwende KEIN Markdown-Link-Format [text](url), "
|
||
f"schreibe die URL einfach direkt hin."
|
||
)
|
||
model = os.getenv("NEWS_SEARCH_MODEL", "gpt-4o-mini-search-preview").strip()
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": (
|
||
"Du bist ein weltweit führender medizinischer News-Kurator. "
|
||
"Suche die aktuellsten, wichtigsten medizinischen Nachrichten "
|
||
"aus den renommiertesten Quellen weltweit. "
|
||
"Nur den Titel **fett** schreiben. "
|
||
"Quelle, Datum, Zusammenfassung und URL NICHT fett. "
|
||
"URLs direkt ausschreiben, KEINE Markdown-Links [text](url)."
|
||
)},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
)
|
||
result = (resp.choices[0].message.content or "").strip()
|
||
self.after(0, lambda: _display(result))
|
||
except Exception as exc:
|
||
self.after(0, lambda e=str(exc): _display_error(e))
|
||
|
||
threading.Thread(target=_job, daemon=True).start()
|
||
|
||
def _display(raw_text):
|
||
text_widget.configure(state="normal")
|
||
text_widget.delete("1.0", "end")
|
||
nw_link_cnt[0] = 0
|
||
for line in raw_text.split("\n"):
|
||
stripped = line.strip()
|
||
if not stripped:
|
||
text_widget.insert("end", "\n")
|
||
continue
|
||
if re.match(r'^#{1,4}\s', stripped):
|
||
clean = stripped.lstrip("#").strip()
|
||
text_widget.insert("end", f" {clean}\n", "nw_heading")
|
||
else:
|
||
_render_line(stripped)
|
||
text_widget.insert("end", "\n")
|
||
text_widget.configure(state="disabled")
|
||
n = sum(1 for l in raw_text.split("\n") if l.strip())
|
||
status_var.set(f"Fertig · {n} Zeilen")
|
||
|
||
def _display_error(msg):
|
||
text_widget.configure(state="normal")
|
||
text_widget.delete("1.0", "end")
|
||
if "insufficient_quota" in msg or "429" in msg:
|
||
text_widget.insert("end", "OpenAI-Guthaben aufgebraucht.\n\n", "nw_bold")
|
||
text_widget.insert("end", "Bitte Guthaben aufladen:\n", "nw_normal")
|
||
text_widget.tag_configure("url_billing", font=("Segoe UI", 9, "underline"), foreground="#2a8a4a")
|
||
text_widget.tag_bind("url_billing", "<Button-1>",
|
||
lambda e: _open_url("https://platform.openai.com/account/billing"))
|
||
text_widget.insert("end", "platform.openai.com/account/billing\n", "url_billing")
|
||
elif "OPENAI_API_KEY" in msg:
|
||
text_widget.insert("end", "KI-Verbindung nicht eingerichtet.\n", "nw_bold")
|
||
text_widget.insert("end", "Bitte über das Startmenü einrichten.", "nw_normal")
|
||
else:
|
||
text_widget.insert("end", f"Fehler: {msg}\n", "nw_normal")
|
||
text_widget.configure(state="disabled")
|
||
status_var.set("Fehler")
|
||
|
||
_search()
|
||
|
||
def _persist_news_settings(self):
|
||
try:
|
||
save_autotext(self._autotext_data)
|
||
except Exception:
|
||
pass
|
||
|
||
def _all_medical_specialties(self):
|
||
items = [
|
||
("dermatology", "Dermatologie"),
|
||
("general-medicine", "Allgemeinmedizin"),
|
||
("gynecology", "Gynäkologie"),
|
||
("internal-medicine", "Innere Medizin"),
|
||
("anesthesiology", "Anästhesiologie"),
|
||
("cardiology", "Kardiologie"),
|
||
("neurology", "Neurologie"),
|
||
("oncology", "Onkologie"),
|
||
("infectiology", "Infektiologie"),
|
||
("pediatrics", "Pädiatrie"),
|
||
("psychiatry", "Psychiatrie"),
|
||
("orthopedics", "Orthopädie"),
|
||
("ophthalmology", "Ophthalmologie"),
|
||
("otolaryngology", "HNO"),
|
||
("urology", "Urologie"),
|
||
("endocrinology", "Endokrinologie"),
|
||
("rheumatology", "Rheumatologie"),
|
||
("hematology", "Hämatologie"),
|
||
("gastroenterology", "Gastroenterologie"),
|
||
("nephrology", "Nephrologie"),
|
||
("pulmonology", "Pneumologie"),
|
||
("radiology", "Radiologie"),
|
||
("pathology", "Pathologie"),
|
||
("emergency-medicine", "Notfallmedizin"),
|
||
("surgery", "Chirurgie"),
|
||
("plastic-surgery", "Plastische Chirurgie"),
|
||
("immunology", "Immunologie"),
|
||
("allergy", "Allergologie"),
|
||
("geriatrics", "Geriatrie"),
|
||
("all", "Alle Fachrichtungen"),
|
||
]
|
||
no_all = sorted([x for x in items if x[0] != "all"], key=lambda x: x[1].lower())
|
||
return no_all + [("all", "Alle Fachrichtungen")]
|
||
|
||
def _fmt_eu_date(self, raw: str) -> str:
|
||
s = str(raw or "").strip()
|
||
if not s:
|
||
return ""
|
||
try:
|
||
d = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||
return d.strftime("%d.%m.%Y")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
d2 = datetime.strptime(s[:10], "%Y-%m-%d")
|
||
return d2.strftime("%d.%m.%Y")
|
||
except Exception:
|
||
return s[:10]
|
||
|
||
def _ensure_user_specialty_preferences(self):
|
||
if self._autotext_data.get("user_specialties_selected"):
|
||
return
|
||
catalog = [x for x in self._all_medical_specialties() if x[0] != "all"]
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("Fachgebiet festlegen")
|
||
dlg.transient(self)
|
||
dlg.grab_set()
|
||
dlg.configure(bg="#F2F8FC")
|
||
dlg.geometry("520x620")
|
||
dlg.minsize(420, 500)
|
||
add_resize_grip(dlg, 420, 500)
|
||
center_window(dlg, 520, 620)
|
||
body = tk.Frame(dlg, bg="#F2F8FC", padx=12, pady=12)
|
||
body.pack(fill="both", expand=True)
|
||
tk.Label(body, text="Primäres Fachgebiet (Erststart)", bg="#F2F8FC", fg="#1a4d6d", font=("Segoe UI", 11, "bold")).pack(anchor="w")
|
||
primary_var = tk.StringVar(value="")
|
||
label_to_key = {label: key for key, label in catalog}
|
||
key_to_label = {key: label for key, label in catalog}
|
||
combo_val = tk.StringVar(value="")
|
||
combo = ttk.Combobox(body, values=[""] + [label for _, label in catalog], textvariable=combo_val, state="readonly", width=32)
|
||
combo.pack(anchor="w", pady=(6, 10))
|
||
|
||
tk.Label(body, text="Weitere Fachgebiete (optional)", bg="#F2F8FC", fg="#1a4d6d").pack(anchor="w")
|
||
vars_map = {}
|
||
for key, label in catalog:
|
||
v = tk.BooleanVar(value=False)
|
||
vars_map[key] = v
|
||
tk.Checkbutton(body, text=label, variable=v, bg="#F2F8FC", activebackground="#F2F8FC", selectcolor="#E7F4FA").pack(anchor="w")
|
||
|
||
def _save():
|
||
selected = [k for k, v in vars_map.items() if v.get()]
|
||
chosen_label = combo_val.get().strip()
|
||
primary = label_to_key.get(chosen_label, "dermatology")
|
||
if primary not in selected:
|
||
selected.insert(0, primary)
|
||
if not selected:
|
||
selected = [primary]
|
||
self._autotext_data["user_specialty_default"] = primary
|
||
self._autotext_data["user_specialties_selected"] = selected
|
||
if not self._autotext_data.get("eventsSelectedSpecialties"):
|
||
self._autotext_data["eventsSelectedSpecialties"] = [primary]
|
||
if not self._autotext_data.get("newsSelectedSpecialties"):
|
||
self._autotext_data["newsSelectedSpecialties"] = [primary]
|
||
self._persist_news_settings()
|
||
dlg.destroy()
|
||
|
||
foot = tk.Frame(body, bg="#F2F8FC")
|
||
foot.pack(fill="x", pady=(10, 0))
|
||
ttk.Button(foot, text="Speichern", command=_save).pack(side="left")
|
||
ttk.Button(foot, text="Überspringen", command=dlg.destroy).pack(side="left", padx=(6, 0))
|
||
dlg.wait_window()
|
||
|
||
def _apply_large_window_geometry(self, window, side="left"):
|
||
try:
|
||
sw = max(1200, int(self.winfo_screenwidth()))
|
||
sh = max(800, int(self.winfo_screenheight()))
|
||
width = max(260, int(sw * 0.25))
|
||
height = max(500, int(sh * 0.80))
|
||
window.minsize(max(240, int(width * 0.85)), max(420, int(height * 0.80)))
|
||
|
||
gap = 12
|
||
start_x = 8
|
||
y = max(10, int(sh * 0.10))
|
||
if side == "right":
|
||
x = start_x + width + gap
|
||
else:
|
||
x = start_x
|
||
window.geometry(f"{width}x{height}+{x}+{y}")
|
||
except Exception:
|
||
pass
|
||
|
||
def _enable_mousewheel_scroll(self, canvas, host_widget):
|
||
def _on_mousewheel(event):
|
||
try:
|
||
if sys.platform == "darwin":
|
||
canvas.yview_scroll(int(-1 * event.delta), "units")
|
||
else:
|
||
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_mousewheel_linux_up(_event):
|
||
canvas.yview_scroll(-1, "units")
|
||
|
||
def _on_mousewheel_linux_down(_event):
|
||
canvas.yview_scroll(1, "units")
|
||
|
||
def _bind(_event=None):
|
||
canvas.bind_all("<MouseWheel>", _on_mousewheel)
|
||
canvas.bind_all("<Button-4>", _on_mousewheel_linux_up)
|
||
canvas.bind_all("<Button-5>", _on_mousewheel_linux_down)
|
||
|
||
def _unbind(_event=None):
|
||
canvas.unbind_all("<MouseWheel>")
|
||
canvas.unbind_all("<Button-4>")
|
||
canvas.unbind_all("<Button-5>")
|
||
|
||
host_widget.bind("<Enter>", _bind)
|
||
host_widget.bind("<Leave>", _unbind)
|
||
|
||
def _run_macro1(self):
|
||
"""Führt das gespeicherte Makro-Profil 'macro1' aus."""
|
||
self._launch_macro_process("run", "macro1")
|
||
|
||
def _record_macro1(self):
|
||
"""Startet die Aufnahme für das Makro-Profil 'macro1'."""
|
||
self._launch_macro_process("record", "macro1")
|
||
|
||
def _launch_macro_process(self, mode=None, profile=None):
|
||
"""Startet aza_macro.py optional mit Modus/Profil."""
|
||
try:
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
macro_path = os.path.join(script_dir, "aza_macro.py")
|
||
if not os.path.exists(macro_path):
|
||
messagebox.showerror("Fehler", f"aza_macro.py nicht gefunden:\n{macro_path}")
|
||
return
|
||
|
||
cmd = [sys.executable, macro_path]
|
||
if mode:
|
||
cmd.append(str(mode))
|
||
if profile:
|
||
cmd.append(str(profile))
|
||
|
||
if sys.platform == "win32":
|
||
pythonw = sys.executable.replace("python.exe", "pythonw.exe")
|
||
if os.path.exists(pythonw):
|
||
cmd = [pythonw, macro_path] + cmd[2:]
|
||
subprocess.Popen(cmd, cwd=script_dir)
|
||
else:
|
||
subprocess.Popen(cmd, cwd=script_dir,
|
||
creationflags=subprocess.CREATE_NO_WINDOW)
|
||
else:
|
||
subprocess.Popen(cmd, cwd=script_dir)
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", f"Macro konnte nicht gestartet werden:\n{str(e)}")
|
||
|
||
# _open_todo_window -> ausgelagert in Mixin-Modul
|
||
|
||
def _open_lernkarten_abfrage(self):
|
||
"""Startet das Lernkarten-Abfrage-Programm Vokabeln und Sätze üben mit Lernzielkontrolle."""
|
||
try:
|
||
import subprocess
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
abfrage_path = os.path.join(script_dir, "lernkarten_abfrage.py")
|
||
if not os.path.exists(abfrage_path):
|
||
messagebox.showerror("Fehler", f"lernkarten_abfrage.py nicht gefunden:\n{abfrage_path}")
|
||
return
|
||
kwargs = {"cwd": script_dir}
|
||
if sys.platform == "win32":
|
||
kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||
subprocess.Popen([sys.executable, abfrage_path], **kwargs)
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e))
|
||
|
||
def _open_arztzeugnis(self):
|
||
"""Öffnet ein Fenster zum Erstellen eines Arztzeugnisses mit Diktat, Drucken, E-Mail und Speichern."""
|
||
|
||
AZ_MIN_W, AZ_MIN_H = 520, 680
|
||
win = tk.Toplevel(self)
|
||
win.title("Arztzeugnis erstellen")
|
||
win.transient(self)
|
||
win.minsize(AZ_MIN_W, AZ_MIN_H)
|
||
win.configure(bg="#E8F4FA")
|
||
win.attributes("-topmost", True)
|
||
self._register_window(win)
|
||
add_resize_grip(win, AZ_MIN_W, AZ_MIN_H)
|
||
|
||
# Fensterposition: gespeichert laden oder zentrieren
|
||
saved_geom = load_toplevel_geometry("arztzeugnis")
|
||
if saved_geom:
|
||
win.geometry(saved_geom)
|
||
else:
|
||
win.geometry(f"{AZ_MIN_W}x{AZ_MIN_H}")
|
||
center_window(win, AZ_MIN_W, AZ_MIN_H)
|
||
|
||
_az_geom_after = [None]
|
||
|
||
def _az_save_geom(event=None):
|
||
if _az_geom_after[0]:
|
||
win.after_cancel(_az_geom_after[0])
|
||
_az_geom_after[0] = win.after(400, lambda: save_toplevel_geometry("arztzeugnis", win.geometry()))
|
||
|
||
win.bind("<Configure>", _az_save_geom)
|
||
|
||
def _az_on_close():
|
||
try:
|
||
save_toplevel_geometry("arztzeugnis", win.geometry())
|
||
except Exception:
|
||
pass
|
||
win.destroy()
|
||
|
||
win.protocol("WM_DELETE_WINDOW", _az_on_close)
|
||
|
||
# Header
|
||
header = tk.Frame(win, bg="#B9ECFA")
|
||
header.pack(fill="x")
|
||
tk.Label(header, text="Arztzeugnis", font=("Segoe UI", 14, "bold"),
|
||
bg="#B9ECFA", fg="#1a4d6d").pack(pady=10)
|
||
|
||
# Formular
|
||
form = tk.Frame(win, bg="#E8F4FA", padx=16, pady=8)
|
||
form.pack(fill="x")
|
||
|
||
lbl_font = ("Segoe UI", 10, "bold")
|
||
ent_font = ("Segoe UI", 10)
|
||
|
||
def _add_field(parent, label_text, row):
|
||
tk.Label(parent, text=label_text, font=lbl_font, bg="#E8F4FA",
|
||
fg="#1a4d6d", anchor="w").grid(row=row, column=0, sticky="w", pady=(4, 0))
|
||
var = tk.StringVar()
|
||
ent = tk.Entry(parent, textvariable=var, font=ent_font, bg="white",
|
||
fg="#1a4d6d", relief="flat", bd=0, insertbackground="#1a4d6d")
|
||
ent.grid(row=row, column=1, sticky="ew", padx=(8, 0), pady=(4, 0), ipady=4)
|
||
return var, ent
|
||
|
||
form.columnconfigure(1, weight=1)
|
||
|
||
patient_var, patient_ent = _add_field(form, "Patient:", 0)
|
||
gebdat_var, gebdat_ent = _add_field(form, "Geb.-Datum:", 1)
|
||
datum_var, datum_ent = _add_field(form, "Datum:", 2)
|
||
datum_var.set(datetime.now().strftime("%d.%m.%Y"))
|
||
|
||
tk.Label(form, text="Diagnose:", font=lbl_font, bg="#E8F4FA",
|
||
fg="#1a4d6d", anchor="w").grid(row=3, column=0, sticky="nw", pady=(8, 0))
|
||
diagnose_text = tk.Text(form, font=ent_font, bg="white", fg="#1a4d6d",
|
||
relief="flat", bd=0, height=3, wrap="word",
|
||
insertbackground="#1a4d6d")
|
||
diagnose_text.grid(row=3, column=1, sticky="ew", padx=(8, 0), pady=(8, 0))
|
||
|
||
# Beurteilung / Freitext
|
||
tk.Label(win, text="Beurteilung / Zeugnis-Text:", font=lbl_font,
|
||
bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", padx=16, pady=(8, 0))
|
||
|
||
text_frame = tk.Frame(win, bg="#E8F4FA", padx=16)
|
||
text_frame.pack(fill="both", expand=True, pady=(0, 4))
|
||
|
||
az_text = tk.Text(text_frame, font=("Segoe UI", 11), bg="white",
|
||
fg="#1a4d6d", relief="flat", bd=0, wrap="word",
|
||
insertbackground="#1a4d6d", padx=8, pady=6)
|
||
az_text.pack(fill="both", expand=True)
|
||
|
||
# Diktat
|
||
az_recorder = [None]
|
||
az_is_recording = [False]
|
||
az_rec_status = tk.StringVar(value="")
|
||
|
||
def _az_toggle_record():
|
||
if az_is_recording[0]:
|
||
az_is_recording[0] = False
|
||
btn_rec.configure(text="⏺ Diktieren", bg="#5B8DB3")
|
||
az_rec_status.set("Transkribiere")
|
||
recorder = az_recorder[0]
|
||
if recorder:
|
||
selfref = self
|
||
def _do():
|
||
try:
|
||
wav_path = recorder.stop_and_save_wav()
|
||
if wav_path:
|
||
text = selfref.transcribe_wav(wav_path)
|
||
if text:
|
||
def _insert():
|
||
if az_text.get("1.0", "end-1c").strip():
|
||
az_text.insert("insert", " " + text)
|
||
else:
|
||
az_text.insert("1.0", text)
|
||
az_rec_status.set("Diktat eingefügt.")
|
||
win.after(0, _insert)
|
||
else:
|
||
win.after(0, lambda: az_rec_status.set("Kein Text erkannt."))
|
||
else:
|
||
win.after(0, lambda: az_rec_status.set("Aufnahme fehlgeschlagen."))
|
||
except Exception as e:
|
||
win.after(0, lambda: az_rec_status.set(f"Fehler: {e}"))
|
||
threading.Thread(target=_do, daemon=True).start()
|
||
else:
|
||
if not self._ensure_microphone_ready():
|
||
return
|
||
az_is_recording[0] = True
|
||
btn_rec.configure(text="⏹ Stoppen", bg="#C03030")
|
||
az_rec_status.set("Aufnahme läuft")
|
||
az_recorder[0] = AudioRecorder()
|
||
try:
|
||
az_recorder[0].start()
|
||
except Exception as e:
|
||
az_is_recording[0] = False
|
||
btn_rec.configure(text="⏺ Diktieren", bg="#5B8DB3")
|
||
az_rec_status.set(f"Fehler: {e}")
|
||
return
|
||
|
||
rec_frame = tk.Frame(win, bg="#E8F4FA")
|
||
rec_frame.pack(fill="x", padx=16, pady=(4, 0))
|
||
|
||
btn_rec = tk.Button(rec_frame, text="⏺ Diktieren", font=("Segoe UI", 10, "bold"),
|
||
bg="#5B8DB3", fg="white", activebackground="#4A7A9E",
|
||
relief="flat", bd=0, padx=14, pady=4, cursor="hand2",
|
||
command=_az_toggle_record)
|
||
btn_rec.pack(side="left")
|
||
|
||
tk.Label(rec_frame, textvariable=az_rec_status, font=("Segoe UI", 9),
|
||
bg="#E8F4FA", fg="#4a8aaa").pack(side="left", padx=(8, 0))
|
||
|
||
# Aktions-Buttons
|
||
action_frame = tk.Frame(win, bg="#D4EEF7", padx=16, pady=8)
|
||
action_frame.pack(fill="x", side="bottom")
|
||
|
||
def _btn_style(text, bg_color, active_color, cmd):
|
||
return tk.Button(action_frame, text=text, font=("Segoe UI", 10, "bold"),
|
||
bg=bg_color, fg="#1a4d6d", activebackground=active_color,
|
||
relief="flat", bd=0, padx=14, pady=6, cursor="hand2",
|
||
command=cmd)
|
||
|
||
def _get_az_text():
|
||
lines = []
|
||
lines.append("ARZTZEUGNIS")
|
||
lines.append("=" * 40)
|
||
if patient_var.get().strip():
|
||
lines.append(f"Patient: {patient_var.get().strip()}")
|
||
if gebdat_var.get().strip():
|
||
lines.append(f"Geb.-Datum: {gebdat_var.get().strip()}")
|
||
lines.append(f"Datum: {datum_var.get().strip()}")
|
||
diag = diagnose_text.get("1.0", "end-1c").strip()
|
||
if diag:
|
||
lines.append(f"\nDiagnose:\n{diag}")
|
||
body = az_text.get("1.0", "end-1c").strip()
|
||
if body:
|
||
lines.append(f"\nBeurteilung:\n{body}")
|
||
return "\n".join(lines)
|
||
|
||
def _az_save():
|
||
content = _get_az_text()
|
||
if not content.strip():
|
||
messagebox.showinfo("Speichern", "Kein Inhalt zum Speichern.", parent=win)
|
||
return
|
||
from tkinter import filedialog
|
||
path = filedialog.asksaveasfilename(
|
||
parent=win, title="Arztzeugnis speichern",
|
||
defaultextension=".txt",
|
||
filetypes=[("Textdatei", "*.txt"), ("Alle Dateien", "*.*")],
|
||
initialfile=f"Arztzeugnis_{patient_var.get().strip().replace(' ', '_') or 'Patient'}_{datum_var.get().strip().replace('.', '-')}.txt"
|
||
)
|
||
if path:
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
az_rec_status.set(f"Gespeichert: {os.path.basename(path)}")
|
||
|
||
def _az_print():
|
||
content = _get_az_text()
|
||
if not content.strip():
|
||
messagebox.showinfo("Drucken", "Kein Inhalt zum Drucken.", parent=win)
|
||
return
|
||
import tempfile, subprocess as _sp
|
||
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8")
|
||
tmp.write(content)
|
||
tmp.close()
|
||
try:
|
||
if sys.platform == "win32":
|
||
os.startfile(tmp.name, "print")
|
||
else:
|
||
_sp.Popen(["lpr", tmp.name])
|
||
az_rec_status.set("Druckauftrag gesendet.")
|
||
except Exception as e:
|
||
az_rec_status.set(f"Druckfehler: {e}")
|
||
|
||
def _az_email():
|
||
content = _get_az_text()
|
||
if not content.strip():
|
||
messagebox.showinfo("E-Mail", "Kein Inhalt zum Senden.", parent=win)
|
||
return
|
||
import urllib.parse
|
||
subject = urllib.parse.quote(f"Arztzeugnis {patient_var.get().strip()}")
|
||
body = urllib.parse.quote(content)
|
||
mailto = f"mailto:?subject={subject}&body={body}"
|
||
import webbrowser
|
||
webbrowser.open(mailto)
|
||
az_rec_status.set("E-Mail-Client geöffnet.")
|
||
|
||
_btn_style(" Speichern", "#B9ECFA", "#A8DCE8", _az_save).pack(side="left", padx=(0, 6))
|
||
_btn_style(" Drucken", "#C8E8F0", "#B8D8E6", _az_print).pack(side="left", padx=(0, 6))
|
||
_btn_style(" E-Mail", "#D4EEF7", "#C4DEE8", _az_email).pack(side="left", padx=(0, 6))
|
||
|
||
# open_diktat_window -> ausgelagert in Mixin-Modul
|
||
|
||
def _toggle_addon_collapse(self, event=None):
|
||
"""Klappt die Add-on-Buttons ein/aus."""
|
||
if self._addon_collapsed:
|
||
self._addon_buttons_container.pack(fill="x")
|
||
self._addon_toggle_label.configure(text="\u25BC Weitere Module")
|
||
self._addon_collapsed = False
|
||
else:
|
||
self._addon_buttons_container.pack_forget()
|
||
self._addon_toggle_label.configure(text="\u25B6 Weitere Module")
|
||
self._addon_collapsed = True
|
||
|
||
def _toggle_soap_collapse(self, event=None):
|
||
"""Klappt die SOAP-Steuerung (A, S, O, B, D, T, P) ein/aus."""
|
||
if self._soap_collapsed:
|
||
self._soap_container.pack(fill="x", before=self._soap_anchor)
|
||
self._soap_toggle_label.configure(text="\u25BC SOAP:")
|
||
self._soap_collapsed = False
|
||
else:
|
||
self._soap_container.pack_forget()
|
||
self._soap_toggle_label.configure(text="\u25B6 SOAP:")
|
||
self._soap_collapsed = True
|
||
self._autotext_data["soap_collapsed"] = self._soap_collapsed
|
||
save_autotext(self._autotext_data)
|
||
|
||
def _toggle_dokumente_collapse(self, event=None):
|
||
"""Klappt die Dokumente-Buttons (Brief, Rezept, Korrektur) ein/aus."""
|
||
if self._dokumente_collapsed:
|
||
self._dokumente_container.pack(fill="x", before=self._dokumente_anchor)
|
||
self._dokumente_toggle_label.configure(text="\u25BC Dokumente:")
|
||
self._dokumente_collapsed = False
|
||
else:
|
||
self._dokumente_container.pack_forget()
|
||
self._dokumente_toggle_label.configure(text="\u25B6 Dokumente:")
|
||
self._dokumente_collapsed = True
|
||
self._autotext_data["dokumente_collapsed"] = self._dokumente_collapsed
|
||
save_autotext(self._autotext_data)
|
||
|
||
def _toggle_textbloecke_collapse(self, event=None):
|
||
"""Klappt die Textblöcke (1, 2, 3, 4, 5) ein/aus."""
|
||
if self._textbloecke_collapsed:
|
||
# Aufklappen - VOR dem Anker einfügen!
|
||
self._textbloecke_container.pack(fill="x", before=self._textbloecke_anchor)
|
||
self._textbloecke_toggle_label.configure(text="▼ Textblöcke:")
|
||
self._textbloecke_collapsed = False
|
||
else:
|
||
# Einklappen
|
||
self._textbloecke_container.pack_forget()
|
||
self._textbloecke_toggle_label.configure(text="▶ Textblöcke:")
|
||
self._textbloecke_collapsed = True
|
||
|
||
# Speichern
|
||
self._autotext_data["textbloecke_collapsed"] = self._textbloecke_collapsed
|
||
save_autotext(self._autotext_data)
|
||
|
||
def _update_addon_buttons_visibility(self):
|
||
"""Aktualisiert die Sichtbarkeit der einzelnen Add-on-Buttons basierend auf Einstellungen."""
|
||
addon_buttons = self._autotext_data.get("addon_buttons", {})
|
||
for button_id, row in self._addon_button_rows.items():
|
||
if addon_buttons.get(button_id, True):
|
||
row.pack(fill="x")
|
||
else:
|
||
row.pack_forget()
|
||
|
||
# open_ordner_window -> ausgelagert in Mixin-Modul
|
||
|
||
def open_ki_pruefen(self):
|
||
"""Prüft den oberen Text (KG) per KI auf Logik, Zusammenhänge, Diagnose-Therapie-Passung."""
|
||
kg = self.txt_output.get("1.0", "end").strip()
|
||
if not kg:
|
||
messagebox.showinfo("KI-Kontrolle", "Bitte zuerst Text im oberen Feld (Krankengeschichte) eingeben.")
|
||
return
|
||
if not self.ensure_ready():
|
||
messagebox.showinfo("KI-Kontrolle", "Die KI-Verbindung ist noch nicht eingerichtet.\nBitte zuerst über das Startmenü einrichten.")
|
||
return
|
||
if not self._check_ai_consent():
|
||
return
|
||
self.set_status("KI-Kontrolle")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": KI_PRUEFEN_PROMPT},
|
||
{"role": "user", "content": kg},
|
||
],
|
||
)
|
||
result = resp.choices[0].message.content.strip()
|
||
self.after(0, lambda: self._show_ki_pruefen_window(result, kg))
|
||
self.after(0, lambda: self.set_status("Fertig."))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("KI-Kontrolle", str(e)))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _show_ki_pruefen_window(self, result: str, original_kg: str) -> None:
|
||
"""Zeigt das KI-Prüfergebnis in einem Fenster; Button Übernehme Korrektur erzeugt korrigierte KG."""
|
||
win = tk.Toplevel(self)
|
||
win.title("KI-Kontrolle Logik & Zusammenhänge")
|
||
win.transient(self)
|
||
win.configure(bg="#B9ECFA")
|
||
win.minsize(550, 450)
|
||
win.attributes("-topmost", True)
|
||
self._register_window(win)
|
||
|
||
# Fensterposition: gespeichert laden oder zentrieren
|
||
setup_window_geometry_saving(win, "ki_kontrolle", 700, 600)
|
||
|
||
add_resize_grip(win, 550, 450)
|
||
add_font_scale_control(win)
|
||
f = ttk.Frame(win, padding=12)
|
||
f.pack(fill="both", expand=True)
|
||
ki_header = ttk.Frame(f)
|
||
ki_header.pack(fill="x", anchor="w")
|
||
ttk.Label(ki_header, text="KI-Kontrolle (Logik, Diagnose/Therapie-Passung):").pack(side="left")
|
||
txt = ScrolledText(f, wrap="word", font=self._text_font, bg="#F5FCFF", height=16)
|
||
txt.pack(fill="both", expand=True, pady=(8, 8))
|
||
add_text_font_size_control(ki_header, txt, initial_size=10, bg_color="#B9ECFA", save_key="ki_kontrolle")
|
||
txt.insert("1.0", result.strip())
|
||
self._bind_text_context_menu(txt)
|
||
|
||
btn_row = ttk.Frame(win, padding=(12, 0, 12, 12))
|
||
btn_row.pack(fill="x")
|
||
|
||
def do_uebernehme_korrektur():
|
||
if not self.ensure_ready():
|
||
return
|
||
self.set_status("Erstelle korrigierte Krankengeschichte")
|
||
|
||
prompt = """Du bist ein ärztlicher Dokumentationsassistent (Deutsch).
|
||
|
||
Es liegt eine kurze Krankengeschichte und eine Prüfkritik vor.
|
||
Aufgabe: Passe die Krankengeschichte MINIMAL an, sodass die Kritikpunkte adressiert werden. Die Korrektur muss KNAPP bleiben ähnlich kurz wie die Vorlage.
|
||
|
||
WICHTIG unbedingt einhalten:
|
||
- Ungefähr gleicher Umfang wie die ursprüngliche KG (keine langen Absätze, keine Aufblähung).
|
||
- Nur das ändern, was die Kritik ausdrücklich verlangt (z. B. ICD-Code präzisieren, fehlende Angabe ergänzen). Keine zusätzlichen Details erfinden (keine konkreten mm-Werte, keine ausformulierten ABCDE-Scores, keine langen Aufklärungs- oder Wundmanagement-Texte, wenn sie nicht in der Vorlage standen).
|
||
- Struktur der Vorlage beibehalten (gleiche Überschriften, Stichpunkte). Keine neuen Abschnitte erfinden, nur vorhandene präzisieren.
|
||
- Ausgabe NUR die korrigierte KG keine Sterne, keine Meta-Kommentare, keine Wiederholung der Kritik. Diagnosen mit ICD-10-GM in Klammern."""
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
user_content = (
|
||
"AKTUELLE KRANKENGESCHICHTE:\n" + (original_kg or "") + "\n\n"
|
||
"KRITIK / HINWEISE DER KI-PRFUNG:\n" + (result or "").strip()
|
||
)
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": prompt},
|
||
{"role": "user", "content": user_content},
|
||
],
|
||
)
|
||
corrected = resp.choices[0].message.content.strip()
|
||
corrected = re.sub(r"\*+", "", corrected)
|
||
corrected = re.sub(r"#+", "", corrected)
|
||
for prefix in ("Aktuelle Krankengeschichte:", "Aktuelle Krankengeschichte", "Korrigierte Krankengeschichte:", "Korrigierte Krankengeschichte"):
|
||
if corrected.startswith(prefix):
|
||
corrected = corrected[len(prefix):].strip()
|
||
break
|
||
self.after(0, lambda: _apply_corrected(corrected))
|
||
self.after(0, lambda: self.set_status("Korrektur übernommen."))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("KI-Kontrolle", str(e)))
|
||
|
||
def _apply_corrected(text):
|
||
try:
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", text)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
import threading
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
RoundedButton(
|
||
btn_row, "Übernehme Korrektur",
|
||
command=do_uebernehme_korrektur,
|
||
width=180, height=28, canvas_bg="#B9ECFA",
|
||
).pack(side="left")
|
||
|
||
def open_pruefen_window(self):
|
||
"""Öffnet das Korrektur-Fenster mit Korrekturen und Interaktionscheck."""
|
||
kg = self.txt_output.get("1.0", "end").strip()
|
||
transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
raw = ""
|
||
if kg:
|
||
raw += "KRANKENGESCHICHTE:\n" + kg + "\n\n"
|
||
if transcript:
|
||
raw += "TRANSKRIPT:\n" + transcript + "\n\n"
|
||
raw = raw.strip() or "(Kein Text zum Prüfen)"
|
||
|
||
korrekturen = load_korrekturen()
|
||
corrected, applied = apply_korrekturen(raw, korrekturen)
|
||
display_text = extract_diagnosen_therapie_procedere(corrected)
|
||
|
||
win = tk.Toplevel(self)
|
||
win.title("Korrektur")
|
||
win.transient(self)
|
||
win.attributes("-topmost", True)
|
||
self._register_window(win)
|
||
default_pruefen_w, default_pruefen_h = 480, 550
|
||
|
||
# Fensterposition: gespeichert laden oder zentrieren
|
||
pruefen_geo = load_pruefen_geometry()
|
||
if pruefen_geo:
|
||
if len(pruefen_geo) >= 4:
|
||
w0, h0, x0, y0 = pruefen_geo[0], pruefen_geo[1], pruefen_geo[2], pruefen_geo[3]
|
||
win.geometry(f"{max(default_pruefen_w, w0)}x{max(default_pruefen_h, h0)}+{x0}+{y0}")
|
||
else:
|
||
w0, h0 = pruefen_geo[0], pruefen_geo[1]
|
||
win.geometry(f"{max(default_pruefen_w, w0)}x{max(default_pruefen_h, h0)}")
|
||
center_window(win, max(default_pruefen_w, w0), max(default_pruefen_h, h0))
|
||
else:
|
||
# Keine gespeicherte Position zentrieren
|
||
win.geometry(f"{default_pruefen_w}x{default_pruefen_h}")
|
||
center_window(win, default_pruefen_w, default_pruefen_h)
|
||
|
||
win.minsize(520, 560)
|
||
win.configure(bg="#B9ECFA")
|
||
|
||
def save_pruefen_size():
|
||
try:
|
||
w, h = win.winfo_width(), win.winfo_height()
|
||
x, y = win.winfo_x(), win.winfo_y()
|
||
if w > 200 and h > 150:
|
||
save_pruefen_geometry(w, h, x, y)
|
||
except Exception:
|
||
pass
|
||
|
||
win.bind("<Configure>", lambda e: win.after(500, save_pruefen_size) if e.widget is win else None)
|
||
add_resize_grip(win, 520, 560)
|
||
add_font_scale_control(win)
|
||
|
||
main_f = ttk.Frame(win, padding=12)
|
||
main_f.pack(fill="both", expand=True)
|
||
|
||
full_corrected = [corrected]
|
||
|
||
inter_header = ttk.Frame(main_f)
|
||
inter_header.pack(fill="x", anchor="w")
|
||
ttk.Label(inter_header, text="Geprüfter Text (Diagnosen, Procedere, Therapie):").pack(side="left")
|
||
txt = ScrolledText(main_f, wrap="word", font=self._text_font, bg="#F5FCFF", height=6)
|
||
txt.pack(fill="both", expand=True, pady=(0, 8))
|
||
add_text_font_size_control(inter_header, txt, initial_size=10, bg_color="#B9ECFA", save_key="interaktionscheck")
|
||
txt.insert("1.0", display_text)
|
||
self._bind_text_context_menu(txt)
|
||
|
||
def on_txt_double_click(evt):
|
||
def grab_selection():
|
||
try:
|
||
selected = txt.get("sel.first", "sel.last").strip()
|
||
if selected:
|
||
wrong_inline.set(selected)
|
||
except tk.TclError:
|
||
pass
|
||
txt.after(10, grab_selection)
|
||
|
||
txt.bind("<Double-Button-1>", on_txt_double_click, add="+")
|
||
|
||
korr_frame = tk.Frame(main_f, bg="#D6EAF8", bd=1, relief="groove")
|
||
korr_frame.pack(fill="x", pady=(4, 8), ipady=4)
|
||
tk.Label(korr_frame, text="\u270E Korrektur eingeben:",
|
||
font=("Segoe UI", 9, "bold"), bg="#D6EAF8", fg="#1a4d6d"
|
||
).pack(anchor="w", padx=8, pady=(4, 0))
|
||
tk.Label(korr_frame, text="Doppelklick auf ein Wort oben befuellt 'Falsch' automatisch.",
|
||
font=("Segoe UI", 8, "italic"), bg="#D6EAF8", fg="#666"
|
||
).pack(anchor="w", padx=8)
|
||
fields_row = tk.Frame(korr_frame, bg="#D6EAF8")
|
||
fields_row.pack(fill="x", padx=8, pady=(4, 6))
|
||
wrong_inline = tk.StringVar()
|
||
right_inline = tk.StringVar()
|
||
tk.Label(fields_row, text="Falsch:", font=("Segoe UI", 10, "bold"),
|
||
bg="#D6EAF8", fg="#C0392B").pack(side="left")
|
||
wrong_entry = tk.Entry(fields_row, textvariable=wrong_inline, width=18,
|
||
font=("Segoe UI", 10), bg="#FFF", relief="solid", bd=1)
|
||
wrong_entry.pack(side="left", padx=(4, 12))
|
||
tk.Label(fields_row, text="Richtig:", font=("Segoe UI", 10, "bold"),
|
||
bg="#D6EAF8", fg="#27AE60").pack(side="left")
|
||
right_entry = tk.Entry(fields_row, textvariable=right_inline, width=18,
|
||
font=("Segoe UI", 10), bg="#FFF", relief="solid", bd=1)
|
||
right_entry.pack(side="left", padx=(4, 8))
|
||
|
||
editing_entry = [None]
|
||
|
||
listbox_corrections = []
|
||
|
||
def refresh_all_list():
|
||
k = load_korrekturen()
|
||
listbox.delete(0, "end")
|
||
listbox_corrections.clear()
|
||
for cat in ("medikamente", "diagnosen"):
|
||
for f, r in (k.get(cat) or {}).items():
|
||
listbox.insert("end", f"«{f}» «{r}» ({cat})")
|
||
listbox_corrections.append((f, r, cat))
|
||
|
||
def refresh_display():
|
||
new_text, _ = apply_korrekturen(raw, load_korrekturen())
|
||
full_corrected[0] = new_text
|
||
disp = extract_diagnosen_therapie_procedere(new_text)
|
||
txt.delete("1.0", "end")
|
||
txt.insert("1.0", disp)
|
||
refresh_all_list()
|
||
|
||
ttk.Label(main_f, text="Alle gespeicherten Korrekturen (werden automatisch bei neuer KG angewendet):").pack(anchor="w", pady=(4, 0))
|
||
list_f = ttk.Frame(main_f)
|
||
list_f.pack(fill="both", expand=True, pady=(0, 8))
|
||
list_scrollbar = tk.Scrollbar(list_f, orient="vertical")
|
||
list_scrollbar.pack(side="right", fill="y")
|
||
listbox = tk.Listbox(list_f, height=8, font=("Segoe UI", 10),
|
||
yscrollcommand=list_scrollbar.set)
|
||
listbox.pack(side="left", fill="both", expand=True)
|
||
list_scrollbar.config(command=listbox.yview)
|
||
refresh_all_list()
|
||
|
||
def on_listbox_double_click(evt):
|
||
sel = listbox.curselection()
|
||
if not sel or sel[0] >= len(listbox_corrections):
|
||
return
|
||
f, r, cat = listbox_corrections[sel[0]]
|
||
wrong_inline.set(f)
|
||
right_inline.set(r)
|
||
editing_entry[0] = (f, cat)
|
||
|
||
listbox.bind("<Double-Button-1>", on_listbox_double_click)
|
||
|
||
btn_row = ttk.Frame(main_f)
|
||
btn_row.pack(fill="x", pady=8)
|
||
|
||
def do_export():
|
||
from tkinter import filedialog
|
||
dest = filedialog.asksaveasfilename(
|
||
title="Korrekturen exportieren",
|
||
defaultextension=".json",
|
||
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
|
||
)
|
||
if not dest:
|
||
return
|
||
try:
|
||
with open(dest, "w", encoding="utf-8") as f:
|
||
json.dump(load_korrekturen(), f, ensure_ascii=False, indent=2)
|
||
self.set_status(f"Exportiert: {dest}")
|
||
messagebox.showinfo("Export", f"Korrekturen exportiert nach:\n{dest}")
|
||
except Exception as e:
|
||
messagebox.showerror("Export fehlgeschlagen", str(e))
|
||
|
||
def do_import():
|
||
from tkinter import filedialog
|
||
p = filedialog.askopenfilename(
|
||
title="Korrekturen importieren",
|
||
filetypes=[("JSON", "*.json"), ("Alle", "*.*")],
|
||
)
|
||
if not p:
|
||
return
|
||
try:
|
||
with open(p, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
if isinstance(data, dict):
|
||
existing = load_korrekturen()
|
||
for cat, mapping in data.items():
|
||
if isinstance(mapping, dict) and cat in existing:
|
||
existing[cat].update(mapping)
|
||
elif isinstance(mapping, dict):
|
||
existing[cat] = mapping
|
||
save_korrekturen(existing)
|
||
refresh_display()
|
||
self.set_status("Importiert.")
|
||
messagebox.showinfo("Import", "Korrekturen importiert.")
|
||
else:
|
||
messagebox.showerror("Import", "Ungültiges Format.")
|
||
except Exception as e:
|
||
messagebox.showerror("Import fehlgeschlagen", str(e))
|
||
|
||
def extract_meds_from_text(text: str):
|
||
meds = []
|
||
stopwords = ("dass", "diese", "oder", "und", "eine", "der", "die", "das", "therapie", "procedere", "diagnose", "keine", "sowie")
|
||
for m in re.findall(r"[A-Za-zäöü][A-Za-zäöü0-9\-]{3,}", text):
|
||
w = m.strip()
|
||
if w.lower() not in stopwords and not w.isdigit():
|
||
meds.append(w)
|
||
return list(dict.fromkeys(meds))[:12]
|
||
|
||
def do_interaktion():
|
||
text = full_corrected[0]
|
||
meds = extract_meds_from_text(text)
|
||
if not meds:
|
||
messagebox.showinfo("Interaktionscheck", "Keine Medikamentennamen erkannt. Bitte manuell prüfen.")
|
||
return
|
||
if not self._check_ai_consent():
|
||
return
|
||
if not self.ensure_ready():
|
||
import webbrowser
|
||
q = "+".join(meds[:6]) + "+Interaktion"
|
||
webbrowser.open(f"https://www.google.com/search?q={q}")
|
||
self.set_status("Websuche geöffnet (KI-Verbindung fehlt).")
|
||
return
|
||
self.set_status("Prüfe Interaktionen")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
med_list = ", ".join(meds)
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": INTERACTION_PROMPT},
|
||
{"role": "user", "content": f"Medikamente/Therapien: {med_list}"},
|
||
],
|
||
)
|
||
result = resp.choices[0].message.content.strip()
|
||
self.after(0, lambda: self._show_interaktion_window(meds, result))
|
||
self.after(0, lambda: self.set_status("Fertig."))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler Websuche als Fallback."))
|
||
self.after(0, lambda: messagebox.showwarning("Interaktionscheck", f"KI-Prüfung fehlgeschlagen.\n{str(e)}\n\nWebsuche wird geöffnet."))
|
||
self.after(0, lambda: _fallback_google(meds))
|
||
|
||
def _fallback_google(meds):
|
||
import webbrowser
|
||
q = "+".join(meds[:6]) + "+Interaktion"
|
||
webbrowser.open(f"https://www.google.com/search?q={q}")
|
||
self.set_status("Websuche geöffnet (Fallback).")
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _apply_to_main(t):
|
||
"""Übernimmt den korrigierten Text ins Hauptfenster."""
|
||
kg_dirty = False
|
||
if "KRANKENGESCHICHTE:" in t:
|
||
parts = t.split("TRANSKRIPT:")
|
||
kg_part = parts[0].replace("KRANKENGESCHICHTE:", "").strip()
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", kg_part)
|
||
kg_dirty = True
|
||
if "TRANSKRIPT:" in t:
|
||
trans_part = t.split("TRANSKRIPT:")[1].split("VORSICHT:")[0].strip()
|
||
self.txt_transcript.delete("1.0", "end")
|
||
self.txt_transcript.insert("1.0", trans_part)
|
||
if t and "KRANKENGESCHICHTE:" not in t and "TRANSKRIPT:" not in t and t != "(Kein Text zum Prüfen)":
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", t)
|
||
kg_dirty = True
|
||
if kg_dirty:
|
||
self._sync_empfang_after_kg_change(context="Korrektur")
|
||
|
||
def do_korrigieren():
|
||
w_val = wrong_inline.get().strip()
|
||
r_val = right_inline.get().strip()
|
||
if w_val and r_val:
|
||
k = load_korrekturen()
|
||
if "diagnosen" not in k:
|
||
k["diagnosen"] = {}
|
||
if editing_entry[0]:
|
||
old_f, cat = editing_entry[0]
|
||
if cat in k and old_f in k[cat]:
|
||
del k[cat][old_f]
|
||
k[cat][w_val] = r_val
|
||
editing_entry[0] = None
|
||
else:
|
||
k["diagnosen"][w_val] = r_val
|
||
save_korrekturen(k)
|
||
corrected_text, _ = apply_korrekturen(raw, k)
|
||
full_corrected[0] = corrected_text
|
||
disp = extract_diagnosen_therapie_procedere(corrected_text)
|
||
txt.delete("1.0", "end")
|
||
txt.insert("1.0", disp)
|
||
refresh_all_list()
|
||
_apply_to_main(corrected_text)
|
||
wrong_inline.set("")
|
||
right_inline.set("")
|
||
self.set_status(f"Korrektur gespeichert: «{w_val}» → «{r_val}» – direkt in KG angewendet.")
|
||
return
|
||
t = full_corrected[0]
|
||
_apply_to_main(t)
|
||
self.set_status("Korrekturen in KG übernommen.")
|
||
|
||
RoundedButton(btn_row, "Korrigieren", command=do_korrigieren, width=100, height=28, canvas_bg="#B9ECFA").pack(side="left", padx=(0, 8))
|
||
|
||
btn_row2 = ttk.Frame(main_f, padding=(0, 4, 0, 0))
|
||
btn_row2.pack(fill="x")
|
||
RoundedButton(btn_row2, "Export", command=do_export, width=80, height=26, canvas_bg="#B9ECFA").pack(side="left", padx=(0, 8))
|
||
RoundedButton(btn_row2, "Import", command=do_import, width=80, height=26, canvas_bg="#B9ECFA").pack(side="left")
|
||
|
||
def add_inline_and_update():
|
||
w, r = wrong_inline.get().strip(), right_inline.get().strip()
|
||
if not w or not r:
|
||
messagebox.showinfo("Hinweis", "Bitte Falsch- und Richtig-Feld ausfüllen.", parent=win)
|
||
return
|
||
k = load_korrekturen()
|
||
if "medikamente" not in k:
|
||
k["medikamente"] = {}
|
||
if "diagnosen" not in k:
|
||
k["diagnosen"] = {}
|
||
if editing_entry[0]:
|
||
old_f, cat = editing_entry[0]
|
||
if cat in k and old_f in k[cat]:
|
||
del k[cat][old_f]
|
||
k[cat][w] = r
|
||
editing_entry[0] = None
|
||
else:
|
||
k["diagnosen"][w] = r
|
||
save_korrekturen(k)
|
||
corrected_text, _ = apply_korrekturen(raw, k)
|
||
full_corrected[0] = corrected_text
|
||
disp = extract_diagnosen_therapie_procedere(corrected_text)
|
||
txt.delete("1.0", "end")
|
||
txt.insert("1.0", disp)
|
||
refresh_all_list()
|
||
_apply_to_main(corrected_text)
|
||
wrong_inline.set("")
|
||
right_inline.set("")
|
||
self.set_status(f"Korrektur gespeichert: «{w}» → «{r}» – direkt in KG angewendet.")
|
||
|
||
btn_save = tk.Button(
|
||
fields_row, text="\u2714 Übernehmen", font=("Segoe UI", 10, "bold"),
|
||
bg="#27AE60", fg="white", activebackground="#1E8449",
|
||
relief="flat", padx=14, pady=4, cursor="hand2",
|
||
command=add_inline_and_update,
|
||
)
|
||
btn_save.pack(side="left", padx=(8, 0))
|
||
|
||
def _next_phase(self, phase: str):
|
||
self._phase = phase
|
||
self._timer_sec = 0
|
||
|
||
def _stop_timer(self):
|
||
self._timer_running = False
|
||
|
||
def _autocopy_kg(self, text: str):
|
||
if not text or not text.strip():
|
||
return
|
||
if not self._is_autocopy_enabled():
|
||
return
|
||
if not _win_clipboard_set(text):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(text))
|
||
|
||
def _fill_transcript(self, transcript: str):
|
||
korrekturen = load_korrekturen()
|
||
corrected_transcript, _ = apply_korrekturen(transcript, korrekturen)
|
||
self.txt_transcript.delete("1.0", "end")
|
||
self.txt_transcript.insert("1.0", corrected_transcript)
|
||
if corrected_transcript and corrected_transcript.strip():
|
||
try:
|
||
save_to_ablage("Transkript", corrected_transcript.strip())
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self._sync_empfang_after_kg_change(context="Neues Transkript")
|
||
except Exception:
|
||
pass
|
||
|
||
def _fill_kg_and_finish(self, kg: str):
|
||
self._stop_timer()
|
||
self.set_status("Fertig.")
|
||
korrekturen = load_korrekturen()
|
||
kg_corrected, _ = apply_korrekturen(kg, korrekturen)
|
||
cleaned_kg, comments_text = extract_kg_comments(kg_corrected)
|
||
cleaned_kg = strip_kg_warnings(cleaned_kg)
|
||
if cleaned_kg and cleaned_kg.strip():
|
||
try:
|
||
save_to_ablage("KG", cleaned_kg)
|
||
self.set_status("Fertig. KG automatisch gespeichert.")
|
||
except Exception:
|
||
pass
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", cleaned_kg)
|
||
self._autocopy_kg(cleaned_kg)
|
||
if cleaned_kg and cleaned_kg.strip():
|
||
self._increment_demo_usage_if_needed()
|
||
self._auto_refresh_kommentare()
|
||
self._auto_open_empfang_if_enabled()
|
||
try:
|
||
self._sync_empfang_after_kg_change(context="Neue KG")
|
||
except Exception:
|
||
pass
|
||
|
||
def _import_and_transcribe_audio(self):
|
||
"""Audiodatei manuell auswählen, an Backend senden, Transkript anzeigen."""
|
||
if not self.ensure_ready():
|
||
return
|
||
if not self._check_ai_consent():
|
||
return
|
||
|
||
from tkinter import filedialog
|
||
audio_path = filedialog.askopenfilename(
|
||
title="Audiodatei zum Transkribieren auswählen",
|
||
filetypes=[
|
||
("Audio-Dateien", "*.m4a *.wav *.mp3 *.ogg *.webm"),
|
||
("M4A (AAC)", "*.m4a"),
|
||
("WAV", "*.wav"),
|
||
("MP3", "*.mp3"),
|
||
("Alle Dateien", "*.*"),
|
||
],
|
||
initialdir=get_audio_backup_dir(),
|
||
)
|
||
if not audio_path:
|
||
return
|
||
|
||
if not os.path.isfile(audio_path):
|
||
messagebox.showerror("Fehler", f"Datei nicht gefunden:\n{audio_path}")
|
||
return
|
||
|
||
size_mb = os.path.getsize(audio_path) / (1024 * 1024)
|
||
self.set_status(f"Importiere Audio ({size_mb:.1f} MB) – Transkription startet…")
|
||
|
||
def worker():
|
||
def _safe_after(fn):
|
||
try:
|
||
if self.winfo_exists():
|
||
self.after(0, fn)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
transcript = self.transcribe_wav(audio_path)
|
||
if not transcript or not transcript.strip():
|
||
raise RuntimeError("Transkription ergab keinen Text.")
|
||
|
||
def show_result():
|
||
self._fill_transcript(transcript)
|
||
self.set_status("Audio-Import: Transkript erfolgreich erstellt.")
|
||
if messagebox.askyesno(
|
||
"Audio-Import",
|
||
"Transkript erfolgreich erstellt.\n\n"
|
||
"Soll auch eine KG daraus generiert werden?",
|
||
):
|
||
self._start_timer("kg")
|
||
def kg_worker():
|
||
try:
|
||
kg = strip_kg_warnings(self.summarize_text(transcript))
|
||
_safe_after(lambda: self._fill_kg_and_finish(kg))
|
||
except Exception as kg_err:
|
||
_safe_after(lambda: self._stop_timer())
|
||
_safe_after(lambda err=str(kg_err): messagebox.showerror(
|
||
"KG-Fehler", f"KG-Generierung fehlgeschlagen:\n{err}"
|
||
))
|
||
threading.Thread(target=kg_worker, daemon=True).start()
|
||
|
||
_safe_after(show_result)
|
||
|
||
except Exception as e:
|
||
try:
|
||
self._debug_log(f"AUDIO_IMPORT_ERROR file={audio_path} error={repr(e)}")
|
||
except Exception:
|
||
pass
|
||
_safe_after(lambda: self._stop_timer())
|
||
_safe_after(lambda err=str(e): messagebox.showerror(
|
||
"Audio-Import fehlgeschlagen",
|
||
f"Transkription der importierten Datei fehlgeschlagen:\n{err}\n\n"
|
||
f"Die Originaldatei bleibt erhalten unter:\n{audio_path}",
|
||
))
|
||
_safe_after(lambda: self.set_status("Audio-Import fehlgeschlagen."))
|
||
|
||
self._start_timer("transcribe")
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
_EMPFANG_SECTION_KEYWORDS = {
|
||
"medikamente": ("medikamente", "medication", "medikation", "aktuelle medikation",
|
||
"laufende medikation"),
|
||
"therapieplan": ("therapie", "therapieplan", "therapieempfehlung",
|
||
"therapeutisches vorgehen", "behandlungsplan",
|
||
"aktuelle therapie", "behandlung"),
|
||
"procedere": ("procedere", "weiteres procedere", "weiteres vorgehen",
|
||
"nächste schritte", "nachste schritte",
|
||
"empfohlenes procedere",
|
||
"geplante massnahmen", "geplante maßnahmen",
|
||
"nachfolgendes procedere"),
|
||
}
|
||
|
||
def _provision_empfang_account(self):
|
||
"""Erstellt/aktualisiert automatisch einen Empfang-Server-Account
|
||
basierend auf dem lokalen Desktop-Profil. Laeuft einmal beim Start.
|
||
Speichert die practice_id vom Server im lokalen Profil."""
|
||
def _do():
|
||
try:
|
||
name = self._user_profile.get("name", "").strip()
|
||
email = self._user_profile.get("email", "").strip()
|
||
clinic = self._user_profile.get("clinic", "").strip()
|
||
if not name or name == "Benutzer":
|
||
return
|
||
bu = self.get_backend_url()
|
||
pw = email.split("@")[0] if email else name.lower()
|
||
if len(pw) < 4:
|
||
pw = pw + "1234"
|
||
payload = {"name": name, "email": email, "password": pw}
|
||
if clinic:
|
||
payload["practice_name"] = clinic
|
||
existing_pid = self.get_practice_id()
|
||
if existing_pid:
|
||
payload["practice_id"] = existing_pid
|
||
r = requests.post(
|
||
f"{bu}/empfang/auth/provision",
|
||
json=payload,
|
||
headers=self._empfang_headers(),
|
||
timeout=8,
|
||
)
|
||
if r.status_code == 200:
|
||
d = r.json()
|
||
action = d.get("action", "")
|
||
role = d.get("role", "")
|
||
server_pid = (d.get("practice_id") or "").strip()
|
||
srv_disp = (d.get("display_name") or "").strip()
|
||
srv_uid = (d.get("user_id") or "").strip()
|
||
oldp = (self._user_profile.get("practice_id") or "").strip()
|
||
changed = False
|
||
if server_pid and server_pid != oldp:
|
||
self._user_profile["practice_id"] = server_pid
|
||
self._invalidate_empfang_prefs_for_new_practice()
|
||
changed = True
|
||
print(f"[PROVISION] practice_id gespeichert: {server_pid}")
|
||
if srv_disp:
|
||
cur = (self._user_profile.get("empfang_display_name") or "").strip()
|
||
if srv_disp != cur:
|
||
self._user_profile["empfang_display_name"] = srv_disp
|
||
changed = True
|
||
if srv_uid:
|
||
cur_u = (self._user_profile.get("empfang_user_id") or "").strip()
|
||
if srv_uid != cur_u:
|
||
self._user_profile["empfang_user_id"] = srv_uid
|
||
changed = True
|
||
if changed:
|
||
save_user_profile(self._user_profile)
|
||
if action == "created":
|
||
print(f"[PROVISION] Empfang-Account erstellt: {name} ({role})")
|
||
elif action == "updated":
|
||
print(f"[PROVISION] Empfang-Account aktualisiert: {name} ({role})")
|
||
except Exception as exc:
|
||
print(f"[PROVISION] Empfang-Provisioning uebersprungen: {exc}")
|
||
threading.Thread(target=_do, daemon=True).start()
|
||
|
||
def _extract_kg_sections(self) -> dict:
|
||
"""Extrahiert Medikamente, Therapieplan und Procedere aus der KG."""
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
result = {"medikamente": "", "therapieplan": "", "procedere": ""}
|
||
if not kg_text:
|
||
return result
|
||
|
||
def _norm_heading(raw_line: str) -> str:
|
||
"""Ueberschriften aus Rich-Text/Klammern/Zaehlung normalisieren."""
|
||
s = (raw_line or "").strip()
|
||
while s.startswith("*"):
|
||
s = s.lstrip("*").strip()
|
||
while s.endswith("*"):
|
||
s = s.rstrip("*").strip()
|
||
s = re.sub(r"^[\-\#\u2022\u25CF\u25AA\s]+", "", s)
|
||
s = re.sub(r"^\d+[\.\)]\s*", "", s)
|
||
return s.lower().rstrip(":").rstrip(".").strip()
|
||
|
||
lines = kg_text.splitlines()
|
||
sections: list[tuple[str, int]] = []
|
||
for i, line in enumerate(lines):
|
||
lower = _norm_heading(line)
|
||
if not lower:
|
||
continue
|
||
for key, keywords in self._EMPFANG_SECTION_KEYWORDS.items():
|
||
if any(lower.startswith(k) or lower == k for k in keywords):
|
||
sections.append((key, i))
|
||
break
|
||
|
||
if not sections:
|
||
paragraphs = kg_text.split("\n\n")
|
||
for para in paragraphs:
|
||
plines = para.strip().split("\n")
|
||
if not plines:
|
||
continue
|
||
first_line = _norm_heading(plines[0])
|
||
for key, keywords in self._EMPFANG_SECTION_KEYWORDS.items():
|
||
if any(first_line.startswith(k) or first_line == k for k in keywords):
|
||
if not result[key]:
|
||
result[key] = para.strip()
|
||
break
|
||
return result
|
||
|
||
for idx, (key, start_line) in enumerate(sections):
|
||
end_line = sections[idx + 1][1] if idx + 1 < len(sections) else len(lines)
|
||
content = "\n".join(lines[start_line:end_line]).strip()
|
||
if not result[key]:
|
||
result[key] = content
|
||
|
||
return result
|
||
|
||
def _sync_empfang_after_kg_change(self, *, context: str = "Korrektur") -> None:
|
||
"""Empfang-Dialog: Felder aus aktueller KG fuellen.
|
||
|
||
Bei aktiviertem Autocopy (Therapie / Procedere) wird nur die untere
|
||
Nachrichten-Box befuellt — nicht der Chat-Verlauf.
|
||
|
||
Wird nach «Korrigieren», neuem Transkript und neuer KG aufgerufen.
|
||
Das Argument ``context`` bleibt fuer bestehende Aufrufer erhalten.
|
||
"""
|
||
if context in ("Neues Transkript", "Neue KG"):
|
||
try:
|
||
_prefs = self._autotext_data.get("empfang_prefs", {}) or {}
|
||
if _prefs.get("last_patient"):
|
||
_prefs["last_patient"] = ""
|
||
self._autotext_data["empfang_prefs"] = _prefs
|
||
save_autotext(self._autotext_data)
|
||
except Exception:
|
||
pass
|
||
dlg = getattr(self, "_empfang_dlg", None)
|
||
if dlg is None:
|
||
return
|
||
try:
|
||
if not dlg.winfo_exists():
|
||
return
|
||
except tk.TclError:
|
||
return
|
||
refs = getattr(self, "_empfang_live_refs", None)
|
||
if not isinstance(refs, dict):
|
||
return
|
||
fw = refs.get("field_widgets") or {}
|
||
ph = refs.get("placeholder") or "Text eingeben oder diktieren..."
|
||
auto_vars = refs.get("auto_copy_vars") or {}
|
||
copy_fns = refs.get("copy_to_chat_fns") or {}
|
||
if context in ("Neues Transkript", "Neue KG"):
|
||
reset_pat_fn = refs.get("reset_patient_fn") if isinstance(refs, dict) else None
|
||
if callable(reset_pat_fn):
|
||
try:
|
||
reset_pat_fn()
|
||
except Exception:
|
||
pass
|
||
extracted = self._extract_kg_sections()
|
||
|
||
def _push_field(key: str, val: str) -> None:
|
||
w = fw.get(key)
|
||
if w is None or isinstance(w, tk.StringVar):
|
||
return
|
||
if key == "kom":
|
||
return
|
||
try:
|
||
w.configure(state="normal")
|
||
w.delete("1.0", "end")
|
||
vv = (val or "").strip()
|
||
if vv:
|
||
w.insert("1.0", vv)
|
||
w.configure(fg="#1a2a3a")
|
||
else:
|
||
w.insert("1.0", ph)
|
||
w.configure(fg="#aaa")
|
||
except Exception:
|
||
pass
|
||
|
||
section_values = {
|
||
"ther": extracted.get("therapieplan") or "",
|
||
"proc": extracted.get("procedere") or "",
|
||
}
|
||
for k, v in section_values.items():
|
||
_push_field(k, v)
|
||
|
||
def _sync_fmt_ther(val: str) -> str:
|
||
t = (val or "").strip()
|
||
if not t:
|
||
return ""
|
||
lines = t.split("\n")
|
||
first = lines[0].strip().lower().rstrip(":").rstrip(".")
|
||
if first in ("therapie", "therapieplan"):
|
||
rest = "\n".join(lines[1:]).strip()
|
||
return f"Therapie\n{rest}" if rest else "Therapie"
|
||
return f"Therapie\n{t}"
|
||
|
||
def _sync_fmt_proc(val: str) -> str:
|
||
t = (val or "").strip()
|
||
if not t:
|
||
return ""
|
||
lines = t.split("\n")
|
||
first = lines[0].strip().lower().rstrip(":").rstrip(".")
|
||
if first == "procedere":
|
||
rest = "\n".join(lines[1:]).strip()
|
||
return f"Procedere\n{rest}" if rest else "Procedere"
|
||
return f"Procedere\n{t}"
|
||
|
||
push_fn = copy_fns.get("push") if isinstance(copy_fns, dict) else None
|
||
if callable(push_fn):
|
||
for key in ("ther", "proc"):
|
||
var = auto_vars.get(key)
|
||
if var is None or not var.get():
|
||
continue
|
||
val = (section_values.get(key) or "").strip()
|
||
if not val:
|
||
continue
|
||
try:
|
||
if key == "ther":
|
||
push_fn(_sync_fmt_ther(val), source=key)
|
||
else:
|
||
push_fn(_sync_fmt_proc(val), source=key)
|
||
except Exception:
|
||
pass
|
||
|
||
def _empfang_background_poll(self):
|
||
"""Prueft im Hintergrund auf neue Empfangs-Nachrichten und zeigt Toast."""
|
||
if getattr(self, "_shutdown_in_progress", False):
|
||
return
|
||
|
||
def _check():
|
||
try:
|
||
bu = self.get_backend_url()
|
||
r = requests.get(f"{bu}/empfang/messages",
|
||
headers=self._empfang_headers(), timeout=8)
|
||
if r.status_code == 200:
|
||
msgs = r.json().get("messages", [])
|
||
current_ids = {m["id"] for m in msgs if m.get("status") == "offen"}
|
||
new_ids = current_ids - self._empfang_last_seen_ids
|
||
if self._empfang_last_seen_ids and new_ids:
|
||
new_msgs = [m for m in msgs if m["id"] in new_ids
|
||
and "Empfang" in m.get("absender", "")]
|
||
if new_msgs:
|
||
m = new_msgs[0]
|
||
self.after(0, lambda: self._show_empfang_toast(
|
||
m.get("kommentar", "")[:80],
|
||
m.get("absender", "Empfang")))
|
||
self._empfang_last_seen_ids = current_ids
|
||
except Exception:
|
||
pass
|
||
threading.Thread(target=_check, daemon=True).start()
|
||
self.after(15000, self._empfang_background_poll)
|
||
|
||
def _show_empfang_toast(self, text: str, sender: str):
|
||
"""Zeigt ein kleines Toast-Fenster unten rechts."""
|
||
toast = tk.Toplevel(self)
|
||
toast.overrideredirect(True)
|
||
toast.attributes("-topmost", True)
|
||
toast.configure(bg="#5B8DB3")
|
||
sw = self.winfo_screenwidth()
|
||
sh = self.winfo_screenheight()
|
||
tw, th = 320, 80
|
||
toast.geometry(f"{tw}x{th}+{sw - tw - 20}+{sh - th - 60}")
|
||
|
||
f = tk.Frame(toast, bg="#5B8DB3", padx=10, pady=8)
|
||
f.pack(fill="both", expand=True)
|
||
tk.Label(f, text=f"\U0001f4e8 {sender}", font=("Segoe UI", 9, "bold"),
|
||
bg="#5B8DB3", fg="white").pack(anchor="w")
|
||
tk.Label(f, text=text, font=("Segoe UI", 8), bg="#5B8DB3",
|
||
fg="#e0eef8", wraplength=280).pack(anchor="w", pady=(2, 0))
|
||
|
||
def _on_click(e):
|
||
toast.destroy()
|
||
self._send_to_empfang()
|
||
|
||
toast.bind("<Button-1>", _on_click)
|
||
for c in f.winfo_children():
|
||
c.bind("<Button-1>", _on_click)
|
||
|
||
toast.after(8000, lambda: toast.destroy() if toast.winfo_exists() else None)
|
||
|
||
def _empfang_send_document(self, doc_type: str, doc_text: str):
|
||
"""Oeffnet Empfang-Fenster mit vorgefuelltem Dokument-Inhalt."""
|
||
self._empfang_prefill = {"doc_type": doc_type, "text": doc_text}
|
||
self._send_to_empfang()
|
||
|
||
def _send_to_empfang(self):
|
||
"""Sammelt KG-Inhalte und sendet sie an den Empfang."""
|
||
existing_dlg = getattr(self, "_empfang_dlg", None)
|
||
if existing_dlg:
|
||
try:
|
||
if existing_dlg.winfo_exists():
|
||
existing_dlg.lift()
|
||
existing_dlg.focus_force()
|
||
self.after_idle(
|
||
lambda: self._sync_empfang_after_kg_change(context="wiedereroeffnen")
|
||
)
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
prefs = self._autotext_data.get("empfang_prefs", {})
|
||
extracted = self._extract_kg_sections()
|
||
kommentar_text = getattr(self, "_last_kommentar_result", "") or ""
|
||
|
||
prefill = getattr(self, "_empfang_prefill", None)
|
||
self._empfang_prefill = None
|
||
if prefill:
|
||
doc_type = prefill.get("doc_type", "")
|
||
doc_text = prefill.get("text", "")
|
||
if doc_type == "Kostengutsprache":
|
||
extracted["therapieplan"] = doc_text
|
||
else:
|
||
kommentar_text = f"[{doc_type}]\n{doc_text}"
|
||
|
||
dlg = tk.Toplevel(self)
|
||
self._empfang_dlg = dlg
|
||
self._empfang_last_thread_messages = []
|
||
self._empfang_local_chat_overlay = []
|
||
self._empfang_live_refs = None
|
||
self._empfang_update_chat_fn = None
|
||
self._autotext_data["empfang_was_open"] = True
|
||
save_autotext(self._autotext_data)
|
||
dlg.title("An Empfang senden")
|
||
dlg.transient(self)
|
||
|
||
saved_geom = prefs.get("geometry", "")
|
||
if saved_geom:
|
||
dlg.geometry(saved_geom)
|
||
else:
|
||
kw = getattr(self, "_kommentare_win", None)
|
||
if kw and kw.winfo_exists():
|
||
kx = kw.winfo_x()
|
||
ky = kw.winfo_y()
|
||
kh = kw.winfo_height()
|
||
dlg.geometry(f"520x640+{kx}+{ky + kh + 10}")
|
||
else:
|
||
mx = self.winfo_x() + self.winfo_width() + 10
|
||
my = self.winfo_y()
|
||
dlg.geometry(f"520x640+{mx}+{my + 530}")
|
||
|
||
dlg.minsize(420, 520)
|
||
_dlg_bg = "#eef2f7"
|
||
dlg.configure(bg=_dlg_bg)
|
||
dlg.resizable(True, True)
|
||
self._register_window(dlg)
|
||
|
||
_empfang_font_size = [load_text_font_size("empfang_dlg", 9)]
|
||
_empfang_text_widgets: list = []
|
||
|
||
def _save_prefs():
|
||
for key, var in toggle_vars.items():
|
||
prefs[f"show_{key}"] = var.get()
|
||
prefs["last_patient"] = _get_text("patient")
|
||
prefs["geometry"] = dlg.geometry()
|
||
try:
|
||
prefs["rcpt_broadcast"] = bool(_broadcast_rcpt.get())
|
||
prefs["rcpt_selected_names"] = _selected_rcpt_names()
|
||
except NameError:
|
||
pass
|
||
self._autotext_data["empfang_prefs"] = prefs
|
||
save_autotext(self._autotext_data)
|
||
|
||
def _on_close():
|
||
try:
|
||
_save_prefs()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
_stop_repeat()
|
||
except Exception:
|
||
pass
|
||
self._autotext_data["empfang_was_open"] = False
|
||
save_autotext(self._autotext_data)
|
||
self._empfang_dlg = None
|
||
self._empfang_live_refs = None
|
||
self._empfang_update_chat_fn = None
|
||
self._empfang_last_thread_messages = None
|
||
self._empfang_local_chat_overlay = None
|
||
dlg.destroy()
|
||
|
||
dlg.protocol("WM_DELETE_WINDOW", _on_close)
|
||
add_resize_grip(dlg, 380, 350)
|
||
|
||
outer = tk.Frame(dlg, bg=_dlg_bg)
|
||
outer.pack(fill="both", expand=True)
|
||
|
||
def _compact_nr(s: str) -> str:
|
||
return re.sub(r"\s+", "", (s or "").strip())
|
||
|
||
# --- Smart-Pick (Pinsel): vor Patienten-Zeile benoetigt ---
|
||
_pick_listener = [None]
|
||
|
||
def _do_smart_pick(btn, on_result):
|
||
if _pick_listener[0] is not None:
|
||
try:
|
||
_pick_listener[0].stop()
|
||
except Exception:
|
||
pass
|
||
_pick_listener[0] = None
|
||
btn.configure(text="\U0001f58c Pinsel", bg="#dde8f0", fg="#1a4d6d")
|
||
return
|
||
|
||
try:
|
||
_old_clip = ""
|
||
if sys.platform == "win32":
|
||
_old_clip = (_win_clipboard_get() or "").strip()
|
||
if not _old_clip:
|
||
_old_clip = dlg.clipboard_get().strip()
|
||
except tk.TclError:
|
||
_old_clip = ""
|
||
|
||
btn.configure(text="\u23f9 Text markieren...", bg="#f8d7da", fg="#721c24")
|
||
_started = [time.time()]
|
||
_mouse_was_pressed = [False]
|
||
|
||
_press_time = [0.0]
|
||
|
||
def _on_click(x, y, button, pressed):
|
||
if button != MouseButton.left:
|
||
return
|
||
if pressed:
|
||
if time.time() - _started[0] > 0.3:
|
||
_mouse_was_pressed[0] = True
|
||
_press_time[0] = time.time()
|
||
return
|
||
if not _mouse_was_pressed[0]:
|
||
return
|
||
_mouse_was_pressed[0] = False
|
||
hold_duration = time.time() - _press_time[0]
|
||
was_drag = hold_duration > 0.20
|
||
|
||
time.sleep(0.05)
|
||
try:
|
||
kbd = KbdController()
|
||
if not was_drag:
|
||
from pynput.mouse import Controller as MController
|
||
mc = MController()
|
||
mc.click(MouseButton.left, 2)
|
||
time.sleep(0.1)
|
||
with kbd.pressed(Key.ctrl):
|
||
kbd.tap(KeyCode.from_char('c'))
|
||
except Exception:
|
||
pass
|
||
time.sleep(0.38)
|
||
|
||
def _grab():
|
||
cur = ""
|
||
try:
|
||
for _attempt in range(8):
|
||
cur = ""
|
||
if sys.platform == "win32":
|
||
cur = (_win_clipboard_get() or "").strip()
|
||
if not cur:
|
||
try:
|
||
cur = dlg.clipboard_get().strip()
|
||
except tk.TclError:
|
||
cur = ""
|
||
if cur and cur != _old_clip:
|
||
break
|
||
time.sleep(0.06)
|
||
if cur and cur != _old_clip:
|
||
on_result(cur)
|
||
except Exception:
|
||
pass
|
||
btn.configure(text="\U0001f58c Pinsel", bg="#dde8f0", fg="#1a4d6d")
|
||
_pick_listener[0] = None
|
||
|
||
self.after(0, _grab)
|
||
return False
|
||
|
||
if _HAS_PYNPUT_MOUSE:
|
||
ml = MouseListener(on_click=_on_click)
|
||
_pick_listener[0] = ml
|
||
ml.start()
|
||
else:
|
||
btn.configure(text="\U0001f58c Pinsel", bg="#dde8f0", fg="#1a4d6d")
|
||
|
||
# --- Kompakter Kopfbalken (Browser-„Empfang“-Anmutung) ---
|
||
hdr = tk.Frame(
|
||
outer,
|
||
bg="#ffffff",
|
||
highlightthickness=1,
|
||
highlightbackground="#d4e5f5",
|
||
)
|
||
hdr.pack(fill="x", padx=12, pady=(10, 4))
|
||
|
||
hdr_row_title = tk.Frame(hdr, bg="#ffffff")
|
||
hdr_row_title.pack(fill="x", padx=12, pady=(12, 2))
|
||
tk.Label(
|
||
hdr_row_title,
|
||
text="An Empfang senden",
|
||
font=("Segoe UI", 14, "bold"),
|
||
bg="#ffffff",
|
||
fg="#163d5f",
|
||
).pack(side="left")
|
||
|
||
identity_lbl = tk.Label(
|
||
hdr,
|
||
text="Sendet als: \u2026",
|
||
font=("Segoe UI", 9),
|
||
bg="#ffffff",
|
||
fg="#2c4a60",
|
||
justify="left",
|
||
anchor="w",
|
||
padx=12,
|
||
pady=(0, 2),
|
||
)
|
||
identity_lbl.pack(fill="x")
|
||
|
||
def _refresh_identity_bar() -> None:
|
||
try:
|
||
disp = (self._empfang_self_display_name() or "").strip()
|
||
uid = (self._empfang_self_user_id() or "").strip()
|
||
role = ""
|
||
if not disp and not uid:
|
||
identity_lbl.config(
|
||
text=(
|
||
"Angemeldet als / sendet als: technisch noch nicht zugeordnet \u2014 "
|
||
"bitte kurz warten oder \u00abAktualisieren\u00bb klicken."
|
||
),
|
||
bg="#fff4e5",
|
||
fg="#8a3a10",
|
||
)
|
||
else:
|
||
short = uid[:6] if uid else "?"
|
||
identity_lbl.config(
|
||
text=(
|
||
f"Angemeldet als / sendet als: "
|
||
f"{disp or 'unbekannt'} (uid {short})"
|
||
),
|
||
bg="#ffffff",
|
||
fg="#2c4a60",
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
_refresh_identity_bar()
|
||
except Exception:
|
||
pass
|
||
|
||
# Identitaetsbar regelmaessig auffrischen, damit nach erfolgreichem
|
||
# Self-UID-Resolve sofort der korrekte Status (statt "noch nicht zugeordnet")
|
||
# angezeigt wird.
|
||
def _identity_bar_periodic():
|
||
try:
|
||
if dlg.winfo_exists():
|
||
_refresh_identity_bar()
|
||
dlg.after(1500, _identity_bar_periodic)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
dlg.after(1500, _identity_bar_periodic)
|
||
except Exception:
|
||
pass
|
||
|
||
practice_strip_lbl = tk.Label(
|
||
hdr,
|
||
text="Praxis-Chat wird geladen\u2026",
|
||
font=("Segoe UI", 8),
|
||
bg="#ffffff",
|
||
fg="#5c7288",
|
||
justify="left",
|
||
anchor="w",
|
||
wraplength=720,
|
||
)
|
||
practice_strip_lbl.pack(fill="x", padx=22, pady=(0, 12))
|
||
|
||
def _refresh_practice_strip() -> None:
|
||
try:
|
||
bu = self.get_backend_url()
|
||
r = requests.get(
|
||
f"{bu}/empfang/practice/info",
|
||
headers=self._empfang_headers(),
|
||
timeout=8,
|
||
)
|
||
if r.status_code != 200:
|
||
self.after(
|
||
0,
|
||
lambda: practice_strip_lbl.config(
|
||
text="Praxis-Information konnte nicht geladen werden. "
|
||
"Bitte Netzwerk, Anmeldung und Server-URL pr\u00fcfen."
|
||
),
|
||
)
|
||
return
|
||
j = r.json() or {}
|
||
pname = str(j.get("practice_name") or "").strip()
|
||
pid = str(j.get("practice_id") or "").strip()
|
||
inv = str(j.get("invite_code") or "").strip()
|
||
p_short = (pid if len(pid) <= 22 else pid[:20] + "\u2026") if pid else ""
|
||
inv_bit = ""
|
||
if inv:
|
||
inv_bit = (
|
||
inv
|
||
if len(inv) <= 14
|
||
else inv[:12] + "\u2026"
|
||
)
|
||
praxis_kurz = pname or "Praxis-Chat"
|
||
line1 = f"{praxis_kurz} \u00b7 mit Praxis-Chat verbunden"
|
||
detail_bits = []
|
||
if p_short:
|
||
detail_bits.append(f"Practice-ID {p_short}")
|
||
if inv_bit:
|
||
detail_bits.append(f"Einladung {inv_bit}")
|
||
if detail_bits:
|
||
line1 += "\n" + " \u00b7 ".join(detail_bits)
|
||
line2 = (
|
||
"Gleicher Chat wie unter AzA-Empfang im Browser "
|
||
"(Kontakte: /empfang/users)."
|
||
)
|
||
self.after(
|
||
0,
|
||
lambda a=line1, b=line2: practice_strip_lbl.config(text=a + "\n" + b),
|
||
)
|
||
except Exception:
|
||
self.after(
|
||
0,
|
||
lambda: practice_strip_lbl.config(
|
||
text="Praxis-Streifen: Server vor\u00fcbergehend nicht erreichbar.",
|
||
),
|
||
)
|
||
|
||
threading.Thread(target=_refresh_practice_strip, daemon=True).start()
|
||
|
||
toggle_vars: dict[str, tk.BooleanVar] = {}
|
||
field_widgets: dict = {}
|
||
|
||
# Patientenkontext: kompakter Streifen unter dem Kopf
|
||
patient_ctx = tk.Frame(
|
||
outer,
|
||
bg="#fafcfe",
|
||
highlightthickness=1,
|
||
highlightbackground="#e6edf7",
|
||
)
|
||
patient_ctx.pack(fill="x", padx=12, pady=(0, 8))
|
||
nr_banner_row = tk.Frame(patient_ctx, bg="#fafcfe")
|
||
nr_banner_row.pack(fill="x", padx=10, pady=8)
|
||
tk.Label(nr_banner_row, text="Patienten-Nr.:",
|
||
font=("Segoe UI", 9), bg="#fafcfe", fg="#4a7394").pack(side="left")
|
||
|
||
_pat_ph = "(keine)"
|
||
patient_sv = tk.StringVar()
|
||
_lp_init = (prefs.get("last_patient") or "").strip()
|
||
if _lp_init:
|
||
patient_sv.set(_lp_init)
|
||
else:
|
||
patient_sv.set(_pat_ph)
|
||
|
||
patient_ent = tk.Entry(
|
||
nr_banner_row,
|
||
textvariable=patient_sv,
|
||
font=("Consolas", 11),
|
||
bg="white",
|
||
fg="#aaa" if not _lp_init else "#1a4d6d",
|
||
relief="solid",
|
||
bd=1,
|
||
width=24,
|
||
)
|
||
patient_ent.pack(side="left", padx=(8, 4), fill="x", expand=True)
|
||
|
||
field_widgets["patient"] = patient_sv
|
||
toggle_vars["patient"] = tk.BooleanVar(value=True)
|
||
copy_to_chat_fns: dict = {}
|
||
|
||
def _pat_focus_in(_e=None):
|
||
if patient_sv.get().strip() == _pat_ph:
|
||
patient_sv.set("")
|
||
patient_ent.configure(fg="#1a4d6d")
|
||
|
||
def _pat_focus_out(_e=None):
|
||
if not patient_sv.get().strip():
|
||
patient_sv.set(_pat_ph)
|
||
patient_ent.configure(fg="#aaa")
|
||
else:
|
||
patient_ent.configure(fg="#1a4d6d")
|
||
|
||
patient_ent.bind("<FocusIn>", _pat_focus_in)
|
||
patient_ent.bind("<FocusOut>", _pat_focus_out)
|
||
|
||
def _patient_entry_copy_compact(_e=None):
|
||
raw = patient_sv.get().strip()
|
||
if raw == _pat_ph:
|
||
return "break"
|
||
nn = _compact_nr(raw)
|
||
if nn:
|
||
dlg.clipboard_clear()
|
||
dlg.clipboard_append(nn)
|
||
dlg.update_idletasks()
|
||
try:
|
||
self.set_status("Nr. kopiert (ohne Leerzeichen).")
|
||
except Exception:
|
||
pass
|
||
return "break"
|
||
|
||
patient_ent.bind("<Double-Button-1>", _patient_entry_copy_compact)
|
||
|
||
def _patient_paste_from_clipboard(_e=None):
|
||
try:
|
||
txt = ""
|
||
if sys.platform == "win32":
|
||
txt = (_win_clipboard_get() or "").strip()
|
||
if not txt:
|
||
txt = dlg.clipboard_get().strip()
|
||
nn = _compact_nr(txt)
|
||
if len(nn) < 2:
|
||
return None
|
||
patient_sv.set(nn)
|
||
patient_ent.configure(fg="#1a4d6d")
|
||
return "break"
|
||
except Exception:
|
||
return None
|
||
|
||
patient_ent.bind("<Control-v>", _patient_paste_from_clipboard)
|
||
patient_ent.bind("<Control-V>", _patient_paste_from_clipboard)
|
||
|
||
def _clear_nr_top(_e=None):
|
||
patient_sv.set(_pat_ph)
|
||
patient_ent.configure(fg="#aaa")
|
||
|
||
tk.Label(nr_banner_row, text="\u2715", font=("Segoe UI", 9),
|
||
bg="#fafcfe", fg="#aaa", cursor="hand2").pack(side="left", padx=(2, 2))
|
||
nr_banner_row.winfo_children()[-1].bind("<Button-1>", _clear_nr_top)
|
||
|
||
pick_btn_nr_top = tk.Button(
|
||
nr_banner_row,
|
||
text="\U0001f58c Pinsel",
|
||
font=("Segoe UI", 8),
|
||
bg="#dde8f0",
|
||
fg="#1a4d6d",
|
||
relief="flat",
|
||
cursor="hand2",
|
||
bd=0,
|
||
padx=6,
|
||
pady=1,
|
||
)
|
||
pick_btn_nr_top.pack(side="left", padx=(2, 0))
|
||
|
||
def _start_pick_nr_top():
|
||
def _on_pick(t):
|
||
nn = _compact_nr(t)
|
||
if nn:
|
||
patient_sv.set(nn)
|
||
patient_ent.configure(fg="#1a4d6d")
|
||
|
||
def _follow():
|
||
fn = copy_to_chat_fns.get("push")
|
||
if callable(fn):
|
||
fn(f"Nr.: {nn}", source="patient")
|
||
|
||
dlg.after(120, _follow)
|
||
|
||
_do_smart_pick(pick_btn_nr_top, _on_pick)
|
||
|
||
pick_btn_nr_top.configure(command=_start_pick_nr_top)
|
||
add_tooltip(
|
||
pick_btn_nr_top,
|
||
"Text woanders markieren – Nr. erscheint hier und oben in der Nachrichten-Box.",
|
||
)
|
||
add_tooltip(
|
||
patient_ent,
|
||
"Patienten-Nr.; Doppelklick: ohne Leerzeichen kopieren.",
|
||
)
|
||
|
||
# Section-Sichtbarkeit (immer alle an, ohne Zahnrad-Menue)
|
||
gear_vis = {
|
||
"ther": tk.BooleanVar(value=True),
|
||
"proc": tk.BooleanVar(value=True),
|
||
}
|
||
_section_rows: dict[str, tk.Frame] = {}
|
||
|
||
# -- Ton-System (15 Presets) --
|
||
_tone_defs = [
|
||
("Sanftes Glockenspiel", [(523,0.15),(659,0.15),(784,0.3)]),
|
||
("Zwei-Ton Harmonisch", [(392,0.2),(523,0.3)]),
|
||
("Drei-Ton Melodie", [(523,0.12),(587,0.12),(659,0.28)]),
|
||
("Kristallklar", [(1319,0.5)]),
|
||
("Warmer Akkord", [(262,0.4)]),
|
||
("Aufstieg", [(262,0.09),(330,0.09),(392,0.09),(523,0.22)]),
|
||
("Sanfte Welle", [(440,0.55)]),
|
||
("Tropfen", [(659,0.12),(587,0.12),(523,0.28)]),
|
||
("Morgengruss", [(523,0.14),(392,0.14),(523,0.28)]),
|
||
("Zephyr", [(880,0.45)]),
|
||
("Bambus", [(330,0.18),(440,0.28)]),
|
||
("Silberglocke", [(988,0.45)]),
|
||
("Meditation", [(262,0.65)]),
|
||
("Horizont", [(587,0.18),(880,0.32)]),
|
||
("Stille Post", [(784,0.4)]),
|
||
]
|
||
_sound_idx_var = tk.IntVar(value=prefs.get("sound_idx", 0))
|
||
# Sound-Modus:
|
||
# "wiederholend" -> Standard: alle paar Sekunden weiter piepen,
|
||
# bis der Benutzer die neue Nachricht oeffnet.
|
||
# "einmalig" -> nur einmal piepen pro neuer Nachricht.
|
||
# Migration: alter Eintrag "sound_repeat" (bool) wird beim ersten Lesen
|
||
# uebernommen; ohne Eintrag ist der Standard "wiederholend".
|
||
_legacy_repeat = prefs.get("sound_repeat", None)
|
||
if "sound_mode" in prefs:
|
||
_sm_init = (prefs.get("sound_mode") or "wiederholend").strip().lower()
|
||
elif _legacy_repeat is False:
|
||
_sm_init = "einmalig"
|
||
else:
|
||
_sm_init = "wiederholend"
|
||
if _sm_init not in ("wiederholend", "einmalig"):
|
||
_sm_init = "wiederholend"
|
||
_sound_mode_var = tk.StringVar(value=_sm_init)
|
||
_sound_repeat_var = tk.BooleanVar(value=(_sm_init == "wiederholend"))
|
||
|
||
def _on_sound_mode_change(*_):
|
||
_sound_repeat_var.set(_sound_mode_var.get() == "wiederholend")
|
||
_save_sound_prefs()
|
||
|
||
_sound_mode_var.trace_add("write", _on_sound_mode_change)
|
||
_sound_enabled_var = tk.BooleanVar(value=prefs.get("sound_enabled", True))
|
||
_repeat_job = [None]
|
||
|
||
def _save_sound_prefs():
|
||
prefs["sound_idx"] = _sound_idx_var.get()
|
||
prefs["sound_mode"] = _sound_mode_var.get()
|
||
prefs["sound_repeat"] = _sound_repeat_var.get()
|
||
prefs["sound_enabled"] = _sound_enabled_var.get()
|
||
self._autotext_data["empfang_prefs"] = prefs
|
||
save_autotext(self._autotext_data)
|
||
|
||
def _gen_tone_wav(notes, sr=22050, vol=0.25):
|
||
import math as _m, struct as _s, io as _io, wave as _wv
|
||
frames = bytearray()
|
||
for freq, dur in notes:
|
||
n = int(sr * dur)
|
||
for i in range(n):
|
||
a = min(1.0, i / max(1, sr * 0.005))
|
||
r2 = min(1.0, (n - i) / max(1, sr * 0.02))
|
||
s = vol * a * r2 * _m.sin(2 * _m.pi * freq * i / sr)
|
||
frames += _s.pack('<h', max(-32768, min(32767, int(s * 32767))))
|
||
buf = _io.BytesIO()
|
||
with _wv.open(buf, 'wb') as w:
|
||
w.setnchannels(1); w.setsampwidth(2); w.setframerate(sr)
|
||
w.writeframes(bytes(frames))
|
||
return buf.getvalue()
|
||
|
||
if not hasattr(self, '_empfang_tone_cache'):
|
||
self._empfang_tone_cache = {}
|
||
|
||
def _play_tone(idx=None):
|
||
if idx is None:
|
||
idx = _sound_idx_var.get()
|
||
idx = idx % len(_tone_defs)
|
||
if idx not in self._empfang_tone_cache:
|
||
self._empfang_tone_cache[idx] = _gen_tone_wav(_tone_defs[idx][1])
|
||
try:
|
||
import winsound as _ws
|
||
_ws.PlaySound(self._empfang_tone_cache[idx],
|
||
_ws.SND_MEMORY | _ws.SND_ASYNC)
|
||
except Exception:
|
||
pass
|
||
|
||
def _stop_repeat():
|
||
if _repeat_job[0]:
|
||
try:
|
||
dlg.after_cancel(_repeat_job[0])
|
||
except Exception:
|
||
pass
|
||
_repeat_job[0] = None
|
||
|
||
def _start_repeat():
|
||
_stop_repeat()
|
||
if _sound_repeat_var.get() and _sound_enabled_var.get():
|
||
def _tick():
|
||
_play_tone()
|
||
_repeat_job[0] = dlg.after(30000, _tick)
|
||
_repeat_job[0] = dlg.after(30000, _tick)
|
||
|
||
# Schriftgröße-Controls
|
||
def _apply_empfang_font(new_size):
|
||
new_size = max(5, min(20, new_size))
|
||
_empfang_font_size[0] = new_size
|
||
_fs_lbl.configure(text=str(new_size))
|
||
for tw in _empfang_text_widgets:
|
||
try:
|
||
tw.configure(font=("Segoe UI", new_size))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
_configure_chat_strip_tags()
|
||
except Exception:
|
||
pass
|
||
save_text_font_size("empfang_dlg", new_size)
|
||
|
||
# --- Ton / Schrift: eine Zeile mit dem Titel (rechts kompakt) ---
|
||
_snd_frame = tk.Frame(hdr_row_title, bg="#ffffff")
|
||
_snd_frame.pack(side="right", padx=(0, 4))
|
||
tk.Label(_snd_frame, text="Ton:", font=("Segoe UI", 8),
|
||
bg="#ffffff", fg="#6a8daa").pack(side="left", padx=(0, 3))
|
||
|
||
def _snd_on_off(_=None):
|
||
_save_sound_prefs()
|
||
|
||
_snd_chk = tk.Checkbutton(
|
||
_snd_frame, text="an", variable=_sound_enabled_var,
|
||
font=("Segoe UI", 8), bg="#ffffff", fg="#1a4d6d",
|
||
activebackground="#ffffff", selectcolor="#ffffff",
|
||
command=_snd_on_off,
|
||
)
|
||
_snd_chk.pack(side="left")
|
||
|
||
_snd_rb_rep = tk.Radiobutton(
|
||
_snd_frame, text="wiederholend", variable=_sound_mode_var,
|
||
value="wiederholend", font=("Segoe UI", 8),
|
||
bg="#ffffff", fg="#1a4d6d", activebackground="#ffffff",
|
||
selectcolor="#ffffff",
|
||
)
|
||
_snd_rb_rep.pack(side="left", padx=(5, 0))
|
||
_snd_rb_one = tk.Radiobutton(
|
||
_snd_frame, text="einmalig", variable=_sound_mode_var,
|
||
value="einmalig", font=("Segoe UI", 8),
|
||
bg="#ffffff", fg="#1a4d6d", activebackground="#ffffff",
|
||
selectcolor="#ffffff",
|
||
)
|
||
_snd_rb_one.pack(side="left", padx=(2, 0))
|
||
|
||
def _snd_test():
|
||
try:
|
||
_play_tone()
|
||
except Exception:
|
||
pass
|
||
|
||
tk.Label(_snd_frame, text="\u266B", font=("Segoe UI", 9, "bold"),
|
||
bg="#ffffff", fg="#6a8daa", cursor="hand2"
|
||
).pack(side="left", padx=(6, 0))
|
||
_snd_frame.winfo_children()[-1].bind("<Button-1>", lambda e: _snd_test())
|
||
|
||
_fs_frame = tk.Frame(hdr_row_title, bg="#ffffff")
|
||
_fs_frame.pack(side="right", padx=(8, 0))
|
||
tk.Label(_fs_frame, text="Aa", font=("Segoe UI", 8), bg="#ffffff", fg="#8AAFC0").pack(side="left")
|
||
_fs_lbl = tk.Label(_fs_frame, text=str(_empfang_font_size[0]), font=("Segoe UI", 8),
|
||
bg="#ffffff", fg="#8AAFC0", width=2, anchor="center")
|
||
_fs_lbl.pack(side="left")
|
||
_fs_up = tk.Label(_fs_frame, text="\u25B2", font=("Segoe UI", 7), bg="#ffffff",
|
||
fg="#8AAFC0", cursor="hand2")
|
||
_fs_up.pack(side="left", padx=(2, 0))
|
||
_fs_down = tk.Label(_fs_frame, text="\u25BC", font=("Segoe UI", 7), bg="#ffffff",
|
||
fg="#8AAFC0", cursor="hand2")
|
||
_fs_down.pack(side="left")
|
||
_fs_up.bind("<Button-1>", lambda e: _apply_empfang_font(_empfang_font_size[0] + 1))
|
||
_fs_down.bind("<Button-1>", lambda e: _apply_empfang_font(_empfang_font_size[0] - 1))
|
||
for _w in (_fs_up, _fs_down):
|
||
_w.bind("<Enter>", lambda e, ww=_w: ww.configure(fg="#1a4d6d"))
|
||
_w.bind("<Leave>", lambda e, ww=_w: ww.configure(fg="#8AAFC0"))
|
||
|
||
# Untere Aktionsleiste (wird am Ende gepackt, damit der Chat flexibel wachsen kann)
|
||
bottom_bar = tk.Frame(outer, bg=_dlg_bg)
|
||
|
||
# Klinischer Kontext (Textbausteine + Anhänge), kompakt oben
|
||
clinical_outer = tk.Frame(outer, bg=_dlg_bg)
|
||
clinical_outer.pack(fill="x", padx=12, pady=(0, 6))
|
||
inner = tk.Frame(clinical_outer, bg=_dlg_bg, padx=2, pady=4)
|
||
inner.pack(fill="x")
|
||
|
||
# Hauptbereich: linke Kontaktliste Browser-Style + rechter Messenger
|
||
chat_shell = tk.Frame(outer, bg=_dlg_bg)
|
||
chat_shell.pack(fill="both", expand=True, padx=12, pady=(0, 6))
|
||
|
||
main_hpane = ttk.PanedWindow(chat_shell, orient="horizontal")
|
||
main_hpane.pack(fill="both", expand=True)
|
||
|
||
rcpt_sidebar = tk.Frame(main_hpane, bg="#fafcfe", highlightthickness=1,
|
||
highlightbackground="#e8eef8")
|
||
chat_right = tk.Frame(main_hpane, bg=_dlg_bg)
|
||
main_hpane.add(rcpt_sidebar, weight=1)
|
||
main_hpane.add(chat_right, weight=5)
|
||
|
||
def _empfang_sidebar_sash():
|
||
try:
|
||
main_hpane.sashpos(0, max(164, rcpt_sidebar.winfo_reqwidth() + 8))
|
||
except Exception:
|
||
pass
|
||
|
||
dlg.after_idle(_empfang_sidebar_sash)
|
||
|
||
composer_strip = tk.Frame(outer, bg=_dlg_bg)
|
||
|
||
_field_defs_raw = [
|
||
("ther", "Therapieplan", extracted["therapieplan"]),
|
||
("proc", "Procedere", extracted["procedere"]),
|
||
]
|
||
field_defs = list(_field_defs_raw)
|
||
|
||
tk.Label(
|
||
inner,
|
||
text="Textbausteine & Anh\u00e4nge \u2014 Kopieren/Autocopy f\u00fcllt die Nachricht unten",
|
||
font=("Segoe UI", 8),
|
||
bg=_dlg_bg,
|
||
fg="#6a7f92",
|
||
anchor="w",
|
||
).pack(fill="x", pady=(0, 6))
|
||
|
||
content_frames: dict[str, tk.Frame] = {}
|
||
auto_copy_vars: dict[str, tk.BooleanVar] = {
|
||
k: tk.BooleanVar(value=bool(prefs.get(f"auto_copy_{k}", False)))
|
||
for k, _, _ in _field_defs_raw
|
||
}
|
||
|
||
def _format_ther_for_push(raw: str) -> str:
|
||
t = (raw or "").strip()
|
||
if not t:
|
||
return ""
|
||
lines = t.split("\n")
|
||
first = lines[0].strip().lower().rstrip(":").rstrip(".")
|
||
if first in ("therapie", "therapieplan"):
|
||
rest = "\n".join(lines[1:]).strip()
|
||
return f"Therapie\n{rest}" if rest else "Therapie"
|
||
return f"Therapie\n{t}"
|
||
|
||
def _format_proc_for_push(raw: str) -> str:
|
||
t = (raw or "").strip()
|
||
if not t:
|
||
return ""
|
||
lines = t.split("\n")
|
||
first = lines[0].strip().lower().rstrip(":").rstrip(".")
|
||
if first == "procedere":
|
||
rest = "\n".join(lines[1:]).strip()
|
||
return f"Procedere\n{rest}" if rest else "Procedere"
|
||
return f"Procedere\n{t}"
|
||
|
||
for key, label, initial_val in field_defs:
|
||
has_content = bool(initial_val and initial_val.strip())
|
||
saved_state = prefs.get(f"show_{key}")
|
||
default_on = True if has_content else False
|
||
|
||
var = tk.BooleanVar(value=default_on)
|
||
toggle_vars[key] = var
|
||
|
||
row = tk.Frame(inner, bg="#f0f4f8")
|
||
_section_rows[key] = row
|
||
|
||
visible = gear_vis.get(key, tk.BooleanVar(value=True)).get()
|
||
if visible:
|
||
row.pack(fill="x", pady=(0, 2))
|
||
|
||
def make_toggle(k=key, v=var, r=row, lbl_text=label, init=initial_val):
|
||
header = tk.Frame(r, bg="#f0f4f8", cursor="hand2")
|
||
header.pack(fill="x")
|
||
arrow_lbl = tk.Label(header, text="\u25BC" if v.get() else "\u25B6",
|
||
font=("Segoe UI", 8), bg="#f0f4f8", fg="#5B8DB3", width=2)
|
||
arrow_lbl.pack(side="left", padx=(6, 0))
|
||
title_lbl = tk.Label(
|
||
header, text=lbl_text, font=("Segoe UI", 9, "bold"),
|
||
bg="#f0f4f8", fg="#1a4d6d", width=14, anchor="w",
|
||
)
|
||
title_lbl.pack(side="left", padx=(2, 0), pady=4)
|
||
|
||
def _copy_section_to_chat(_k=k):
|
||
fn = copy_to_chat_fns.get("push")
|
||
if not callable(fn):
|
||
return
|
||
val = _get_text(_k)
|
||
if not val:
|
||
return
|
||
if _k == "ther":
|
||
ft = _format_ther_for_push(val)
|
||
if ft:
|
||
fn(ft, source=_k)
|
||
return
|
||
fp = _format_proc_for_push(val)
|
||
if fp:
|
||
fn(fp, source=_k)
|
||
|
||
def _on_copy_link_click(_e, _k=k):
|
||
_copy_section_to_chat(_k)
|
||
return "break"
|
||
|
||
copy_lbl = tk.Label(
|
||
header,
|
||
text="Kopieren",
|
||
font=("Segoe UI", 9, "underline"),
|
||
bg="#f0f4f8",
|
||
fg="#2563ab",
|
||
cursor="hand2",
|
||
)
|
||
copy_lbl.pack(side="left", padx=(8, 6))
|
||
copy_lbl.bind("<Button-1>", _on_copy_link_click)
|
||
add_tooltip(
|
||
copy_lbl,
|
||
"Inhalt in die Nachrichten-Box unten einfuegen (nicht in den Chat-Verlauf).",
|
||
)
|
||
|
||
def _on_auto_change(_k=k):
|
||
acv = auto_copy_vars.get(_k)
|
||
if acv is None:
|
||
return
|
||
prefs[f"auto_copy_{_k}"] = acv.get()
|
||
self._autotext_data["empfang_prefs"] = prefs
|
||
save_autotext(self._autotext_data)
|
||
if acv.get():
|
||
_copy_section_to_chat(_k)
|
||
|
||
auto_cb = tk.Checkbutton(
|
||
header,
|
||
text="Autocopy",
|
||
font=("Segoe UI", 9),
|
||
variable=auto_copy_vars[k],
|
||
bg="#f0f4f8",
|
||
fg="#1a4d6d",
|
||
activebackground="#f0f4f8",
|
||
activeforeground="#1a4d6d",
|
||
selectcolor="#FFFFFF",
|
||
highlightthickness=0,
|
||
bd=0,
|
||
cursor="hand2",
|
||
padx=4,
|
||
command=_on_auto_change,
|
||
)
|
||
auto_cb.pack(side="left")
|
||
add_tooltip(
|
||
auto_cb,
|
||
"Wenn aktiv: Abschnitt bei KG-Updates automatisch in die Nachrichten-Box.",
|
||
)
|
||
|
||
body = tk.Frame(r, bg="#f0f4f8")
|
||
content_frames[k] = body
|
||
|
||
t = tk.Text(body, height=3, wrap="word",
|
||
font=("Segoe UI", _empfang_font_size[0]),
|
||
bg="white", fg="#1a2a3a", relief="solid", bd=1,
|
||
insertbackground="#1a4d6d", padx=4, pady=4)
|
||
t.pack(fill="x", padx=4, pady=(2, 4))
|
||
_ph = "Text eingeben oder diktieren..."
|
||
if init:
|
||
t.insert("1.0", init)
|
||
else:
|
||
t.insert("1.0", _ph)
|
||
t.configure(fg="#aaa")
|
||
|
||
def _ph_focus_in(e, _t=t, _p=_ph):
|
||
if _t.get("1.0", "end").strip() == _p:
|
||
_t.delete("1.0", "end")
|
||
_t.configure(fg="#1a2a3a")
|
||
|
||
def _ph_focus_out(e, _t=t, _p=_ph):
|
||
if not _t.get("1.0", "end").strip():
|
||
_t.insert("1.0", _p)
|
||
_t.configure(fg="#aaa")
|
||
|
||
t.bind("<FocusIn>", _ph_focus_in, add="+")
|
||
t.bind("<FocusOut>", _ph_focus_out, add="+")
|
||
field_widgets[k] = t
|
||
_empfang_text_widgets.append(t)
|
||
|
||
if v.get():
|
||
body.pack(fill="x")
|
||
|
||
def _click(event=None, _v=v, _b=body, _a=arrow_lbl):
|
||
_v.set(not _v.get())
|
||
if _v.get():
|
||
_b.pack(fill="x")
|
||
_a.configure(text="\u25BC")
|
||
else:
|
||
_b.pack_forget()
|
||
_a.configure(text="\u25B6")
|
||
|
||
header.bind("<Button-1>", _click)
|
||
arrow_lbl.bind("<Button-1>", _click)
|
||
title_lbl.bind("<Button-1>", _click)
|
||
|
||
make_toggle()
|
||
|
||
_placeholder_text = "Text eingeben oder diktieren..."
|
||
_empty_placeholders = {
|
||
_placeholder_text,
|
||
"Antwort eingeben oder diktieren...",
|
||
}
|
||
def _reset_patient_field():
|
||
try:
|
||
patient_sv.set(_pat_ph)
|
||
patient_ent.configure(fg="#aaa")
|
||
except Exception:
|
||
pass
|
||
|
||
self._empfang_live_refs = {
|
||
"field_widgets": field_widgets,
|
||
"placeholder": _placeholder_text,
|
||
"auto_copy_vars": auto_copy_vars,
|
||
"copy_to_chat_fns": copy_to_chat_fns,
|
||
"reset_patient_fn": _reset_patient_field,
|
||
}
|
||
self.after_idle(lambda: self._sync_empfang_after_kg_change(context="dialog_open"))
|
||
|
||
def _get_text(key):
|
||
w = field_widgets.get(key)
|
||
if w is None:
|
||
return ""
|
||
if isinstance(w, tk.StringVar):
|
||
tt = w.get().strip()
|
||
if tt == _pat_ph:
|
||
return ""
|
||
return tt
|
||
val = w.get("1.0", "end").strip()
|
||
if val in _empty_placeholders:
|
||
return ""
|
||
return val
|
||
|
||
# --- Datei-Anhang ---
|
||
_attached_files = []
|
||
|
||
attach_frame = tk.Frame(inner, bg="#f0f4f8")
|
||
attach_frame.pack(fill="x", pady=(0, 4))
|
||
|
||
_attach_label = tk.Label(attach_frame, text="", font=("Segoe UI", 8),
|
||
bg="#f0f4f8", fg="#6a8a9a")
|
||
_attach_label.pack(side="left", padx=(4, 0))
|
||
|
||
def _update_attach_label():
|
||
if _attached_files:
|
||
names = ", ".join(os.path.basename(f) for f in _attached_files[:3])
|
||
if len(_attached_files) > 3:
|
||
names += f" (+{len(_attached_files) - 3})"
|
||
_attach_label.configure(text=f"\U0001f4ce {names}")
|
||
else:
|
||
_attach_label.configure(text="")
|
||
|
||
def _attach_file():
|
||
from tkinter import filedialog
|
||
paths = filedialog.askopenfilenames(
|
||
title="Dateien anhaengen",
|
||
parent=dlg,
|
||
filetypes=[
|
||
("Bilder", "*.jpg *.jpeg *.png *.gif *.bmp *.webp"),
|
||
("PDF", "*.pdf"),
|
||
("Alle Dateien", "*.*"),
|
||
],
|
||
)
|
||
for p in paths:
|
||
if p and p not in _attached_files:
|
||
_attached_files.append(p)
|
||
_update_attach_label()
|
||
|
||
def _clear_attachments():
|
||
_attached_files.clear()
|
||
_update_attach_label()
|
||
|
||
def _paste_image(event=None):
|
||
try:
|
||
from PIL import ImageGrab
|
||
img = ImageGrab.grabclipboard()
|
||
if img:
|
||
import tempfile
|
||
fd, path = tempfile.mkstemp(suffix=".png", prefix="aza_paste_")
|
||
os.close(fd)
|
||
img.save(path, "PNG")
|
||
if path not in _attached_files:
|
||
_attached_files.append(path)
|
||
_update_attach_label()
|
||
return "break"
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
dlg.bind("<Control-v>", _paste_image, add="+")
|
||
|
||
tk.Button(attach_frame, text="\U0001f4ce Datei",
|
||
font=("Segoe UI", 9), bg="#f0f4f8", fg="#5B8DB3",
|
||
relief="flat", cursor="hand2", bd=0, padx=6,
|
||
command=_attach_file).pack(side="right", padx=(0, 4))
|
||
tk.Button(attach_frame, text="\U0001f4f7 Bild",
|
||
font=("Segoe UI", 9), bg="#f0f4f8", fg="#5B8DB3",
|
||
relief="flat", cursor="hand2", bd=0, padx=6,
|
||
command=_paste_image).pack(side="right", padx=(0, 2))
|
||
|
||
def _drop_handler(event):
|
||
try:
|
||
files = dlg.tk.splitlist(event.data)
|
||
for f in files:
|
||
f = f.strip().strip("{}")
|
||
if os.path.isfile(f) and f not in _attached_files:
|
||
_attached_files.append(f)
|
||
_update_attach_label()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
dlg.tk.call("package", "require", "tkdnd")
|
||
for widget in (dlg, outer, clinical_outer, chat_shell):
|
||
widget.tk.call("tkdnd::drop_target", "register", widget._w, "DND_Files")
|
||
widget.bind("<<Drop>>", _drop_handler)
|
||
except Exception:
|
||
pass
|
||
|
||
# --- Pastellfarben-Feedback ---
|
||
_flash_snap = [{"outer": None, "patient_ctx": None}]
|
||
|
||
def _flash_bg(success):
|
||
color = "#e8f5e9" if success else "#fde8ee"
|
||
try:
|
||
if _flash_snap[0]["outer"] is None:
|
||
_flash_snap[0]["outer"] = outer.cget("bg")
|
||
_flash_snap[0]["patient_ctx"] = patient_ctx.cget("bg")
|
||
outer.configure(bg=color)
|
||
patient_ctx.configure(bg=color)
|
||
|
||
def _restore():
|
||
try:
|
||
outer.configure(bg=_flash_snap[0]["outer"])
|
||
patient_ctx.configure(bg=_flash_snap[0]["patient_ctx"])
|
||
except Exception:
|
||
pass
|
||
|
||
dlg.after(1800, _restore)
|
||
except Exception:
|
||
pass
|
||
|
||
# --- Chat-Verlauf ---
|
||
_active_thread = [None]
|
||
_known_ids = [set()]
|
||
_poll_job = [None]
|
||
|
||
chat_sep = ttk.Separator(chat_right, orient="horizontal")
|
||
chat_sep.pack(fill="x", pady=(0, 12))
|
||
|
||
_CHAT_CARD_BG = "#f6fafe"
|
||
chat_hdr = tk.Frame(
|
||
chat_right,
|
||
bg=_CHAT_CARD_BG,
|
||
highlightthickness=1,
|
||
highlightbackground="#dce8f4",
|
||
)
|
||
chat_hdr.pack(fill="x", pady=(0, 6))
|
||
chat_hdr_top = tk.Frame(chat_hdr, bg=_CHAT_CARD_BG)
|
||
chat_hdr_top.pack(fill="x", padx=(12, 12), pady=(10, 2))
|
||
_chat_heading = tk.Label(
|
||
chat_hdr_top,
|
||
text="Unterhaltung",
|
||
font=("Segoe UI", 12, "bold"),
|
||
bg=_CHAT_CARD_BG,
|
||
fg="#1a3f55",
|
||
anchor="w",
|
||
)
|
||
_chat_heading.pack(side="left", fill="x", expand=True)
|
||
|
||
_conv_refresh_ref: dict = {}
|
||
|
||
def _do_manual_chat_refresh():
|
||
try:
|
||
fn = _conv_refresh_ref.get("refresh_users")
|
||
if callable(fn):
|
||
threading.Thread(target=fn, daemon=True).start()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
fn2 = _conv_refresh_ref.get("load_conv")
|
||
if callable(fn2):
|
||
fn2(True)
|
||
except Exception:
|
||
pass
|
||
|
||
tk.Button(
|
||
chat_hdr_top,
|
||
text="\u21bb Aktualisieren",
|
||
font=("Segoe UI", 9),
|
||
bg="#5B8DB3",
|
||
fg="white",
|
||
activebackground="#4a7aa0",
|
||
activeforeground="white",
|
||
relief="flat",
|
||
cursor="hand2",
|
||
bd=0,
|
||
padx=12,
|
||
pady=4,
|
||
command=_do_manual_chat_refresh,
|
||
).pack(side="right")
|
||
_chat_scope_hint = tk.Label(
|
||
chat_hdr,
|
||
text="",
|
||
font=("Segoe UI", 8),
|
||
bg=_CHAT_CARD_BG,
|
||
fg="#597792",
|
||
wraplength=480,
|
||
justify="left",
|
||
anchor="w",
|
||
)
|
||
_chat_scope_hint.pack(fill="x", padx=(12, 12), pady=(0, 12))
|
||
|
||
chat_wrap = tk.Frame(chat_right, bg="#eef4f9")
|
||
chat_wrap.pack(fill="both", expand=True, pady=(0, 0))
|
||
|
||
chat_sb = ttk.Scrollbar(chat_wrap, orient="vertical")
|
||
chat_display = tk.Text(
|
||
chat_wrap, height=13, wrap="word", state="disabled",
|
||
font=("Segoe UI", _empfang_font_size[0]),
|
||
bg="#eef4f9", fg="#1a2a3a", relief="flat", bd=0,
|
||
padx=10, pady=12, highlightthickness=0, insertwidth=0,
|
||
)
|
||
chat_display.configure(yscrollcommand=chat_sb.set)
|
||
chat_sb.config(command=chat_display.yview)
|
||
chat_display.pack(side="left", fill="both", expand=True)
|
||
chat_sb.pack(side="right", fill="y")
|
||
_empfang_text_widgets.append(chat_display)
|
||
|
||
def _cv_font_small():
|
||
return max(7, _empfang_font_size[0] - 1)
|
||
|
||
def _configure_chat_strip_tags():
|
||
"""Messenger-Bubbles: Browser-Empfang nachempfunden (links eingehend, rechts ausgehend)."""
|
||
sz = _empfang_font_size[0]
|
||
sm = max(7, sz - 1)
|
||
xs = max(7, sm - 1)
|
||
# Dezente Hilfs- / Leer-Zustaende (keine Patientendaten)
|
||
chat_display.tag_configure(
|
||
"hint_empty",
|
||
background="#dfeaf4",
|
||
foreground="#374e60",
|
||
font=("Segoe UI", sz),
|
||
lmargin1=16,
|
||
lmargin2=16,
|
||
rmargin=16,
|
||
spacing1=8,
|
||
spacing3=14,
|
||
selectbackground="#c8dae8",
|
||
)
|
||
chat_display.tag_configure(
|
||
"date_sep",
|
||
font=("Segoe UI", xs, "bold"),
|
||
foreground="#7a92a8",
|
||
background="#eef4f9",
|
||
spacing1=12,
|
||
spacing3=8,
|
||
justify="center",
|
||
)
|
||
# Eingehend: helle Bubble links
|
||
chat_display.tag_configure(
|
||
"in_blk",
|
||
background="#ffffff",
|
||
lmargin1=12,
|
||
lmargin2=12,
|
||
rmargin=88,
|
||
spacing1=8,
|
||
spacing3=4,
|
||
selectbackground="#d8e6f2",
|
||
borderwidth=0,
|
||
relief="flat",
|
||
)
|
||
chat_display.tag_configure(
|
||
"in_meta_name",
|
||
font=("Segoe UI", sm, "bold"),
|
||
foreground="#1a4d6d",
|
||
background="#ffffff",
|
||
)
|
||
chat_display.tag_configure(
|
||
"in_meta_time",
|
||
font=("Segoe UI", xs),
|
||
foreground="#8a9aaa",
|
||
background="#ffffff",
|
||
)
|
||
chat_display.tag_configure(
|
||
"in_body",
|
||
font=("Segoe UI", sz),
|
||
foreground="#1a2a3a",
|
||
background="#ffffff",
|
||
spacing1=4,
|
||
spacing3=14,
|
||
lmargin1=12,
|
||
lmargin2=12,
|
||
rmargin=88,
|
||
selectbackground="#d8e6f2",
|
||
)
|
||
# Ausgehend: blasse AzA-Blau-Bubble rechts (grosses linkes Einruecken)
|
||
chat_display.tag_configure(
|
||
"out_blk",
|
||
background="#c8dff0",
|
||
lmargin1=72,
|
||
lmargin2=72,
|
||
rmargin=14,
|
||
spacing1=8,
|
||
spacing3=4,
|
||
selectbackground="#a8cae0",
|
||
)
|
||
chat_display.tag_configure(
|
||
"out_meta_name",
|
||
font=("Segoe UI", sm, "bold"),
|
||
foreground="#0f3850",
|
||
background="#c8dff0",
|
||
)
|
||
chat_display.tag_configure(
|
||
"out_meta_time",
|
||
font=("Segoe UI", xs),
|
||
foreground="#3d6a85",
|
||
background="#c8dff0",
|
||
)
|
||
chat_display.tag_configure(
|
||
"out_body",
|
||
font=("Segoe UI", sz),
|
||
foreground="#0f2740",
|
||
background="#c8dff0",
|
||
spacing1=4,
|
||
spacing3=14,
|
||
lmargin1=72,
|
||
lmargin2=72,
|
||
rmargin=14,
|
||
selectbackground="#a8cae0",
|
||
)
|
||
|
||
_configure_chat_strip_tags()
|
||
|
||
def _chat_display_wheel(_e):
|
||
chat_display.yview_scroll(int(-1 * (_e.delta / 120)), "units")
|
||
return "break"
|
||
|
||
chat_display.bind("<MouseWheel>", _chat_display_wheel)
|
||
|
||
reply_holder = tk.Frame(composer_strip, bg=_dlg_bg)
|
||
reply_holder.pack(fill="both", expand=True, pady=(0, 2))
|
||
|
||
reply_entry = tk.Text(reply_holder, height=4, wrap="word",
|
||
font=("Segoe UI", _empfang_font_size[0]),
|
||
bg="white", fg="#1a2a3a",
|
||
relief="flat", bd=0,
|
||
highlightthickness=1, highlightbackground="#c5d8ea",
|
||
highlightcolor="#5B8DB3",
|
||
padx=10, pady=8)
|
||
reply_sb = ttk.Scrollbar(reply_holder, orient="vertical",
|
||
command=reply_entry.yview)
|
||
reply_entry.configure(yscrollcommand=reply_sb.set)
|
||
reply_entry.pack(side="left", fill="both", expand=True)
|
||
reply_sb.pack(side="right", fill="y")
|
||
_empfang_text_widgets.append(reply_entry)
|
||
|
||
def _reply_wheel(_e):
|
||
reply_entry.yview_scroll(int(-1 * (_e.delta / 120)), "units")
|
||
return "break"
|
||
|
||
reply_entry.bind("<MouseWheel>", _reply_wheel)
|
||
|
||
_reply_placeholder = (
|
||
"Nachricht schreiben\u2026 (Enter/Umschalt+Enter neue Zeile, Senden rechts)"
|
||
)
|
||
reply_entry.insert("1.0", _reply_placeholder)
|
||
reply_entry.configure(fg="#aaa")
|
||
|
||
def _reply_focus_in(e):
|
||
if reply_entry.get("1.0", "end").strip() == _reply_placeholder:
|
||
reply_entry.delete("1.0", "end")
|
||
reply_entry.configure(fg="#1a2a3a")
|
||
|
||
def _reply_focus_out(e):
|
||
if not reply_entry.get("1.0", "end").strip():
|
||
reply_entry.insert("1.0", _reply_placeholder)
|
||
reply_entry.configure(fg="#aaa")
|
||
|
||
reply_entry.bind("<FocusIn>", _reply_focus_in)
|
||
reply_entry.bind("<FocusOut>", _reply_focus_out)
|
||
try:
|
||
self._bind_autotext(reply_entry)
|
||
except Exception:
|
||
pass
|
||
|
||
def _strip_reply_placeholder_if_shown(target_widget):
|
||
"""Platzhalter in der Sende-Box entfernen und normale Schriftfarbe setzen."""
|
||
try:
|
||
cur = target_widget.get("1.0", "end").strip()
|
||
if cur == _reply_placeholder or not cur:
|
||
target_widget.delete("1.0", "end")
|
||
target_widget.configure(fg="#1a2a3a")
|
||
except Exception:
|
||
pass
|
||
|
||
def _apply_diktat_to_reply(target_widget, txt: str):
|
||
"""Transkript einfügen: Platzhalter ersetzen, keine graue Hilfstext-Farbe."""
|
||
if not txt:
|
||
return
|
||
try:
|
||
cur = target_widget.get("1.0", "end").strip()
|
||
if cur == _reply_placeholder or not cur:
|
||
target_widget.delete("1.0", "end")
|
||
target_widget.insert("1.0", txt)
|
||
else:
|
||
if not cur.endswith("\n"):
|
||
target_widget.insert("end", "\n")
|
||
target_widget.insert("end", txt)
|
||
target_widget.configure(fg="#1a2a3a")
|
||
target_widget.see("end")
|
||
except Exception:
|
||
pass
|
||
|
||
def _restore_reply_placeholder():
|
||
"""Sende-Box nach erfolgreichem Versand leeren und Hinweistext wieder anzeigen."""
|
||
try:
|
||
reply_entry.delete("1.0", "end")
|
||
reply_entry.insert("1.0", _reply_placeholder)
|
||
reply_entry.configure(fg="#aaa")
|
||
except Exception:
|
||
pass
|
||
|
||
# Ctrl+V im Chat-Eingabefeld: Bild aus Zwischenablage als Anhang nehmen,
|
||
# sonst normaler Text-Paste.
|
||
def _reply_paste(event=None):
|
||
try:
|
||
from PIL import ImageGrab
|
||
img = ImageGrab.grabclipboard()
|
||
except Exception:
|
||
img = None
|
||
if img is None:
|
||
return None
|
||
try:
|
||
if isinstance(img, list):
|
||
added = 0
|
||
for fp in img:
|
||
if isinstance(fp, str) and os.path.isfile(fp):
|
||
if fp not in _attached_files:
|
||
_attached_files.append(fp)
|
||
added += 1
|
||
if added:
|
||
_update_attach_label()
|
||
_flash_bg(True)
|
||
return "break"
|
||
return None
|
||
import tempfile
|
||
fd, path = tempfile.mkstemp(suffix=".png", prefix="aza_paste_")
|
||
os.close(fd)
|
||
img.save(path, "PNG")
|
||
if path not in _attached_files:
|
||
_attached_files.append(path)
|
||
_update_attach_label()
|
||
_flash_bg(True)
|
||
return "break"
|
||
except Exception:
|
||
return None
|
||
|
||
reply_entry.bind("<Control-v>", _reply_paste, add="+")
|
||
reply_entry.bind("<Control-V>", _reply_paste, add="+")
|
||
reply_entry.bind("<<Paste>>", _reply_paste, add="+")
|
||
|
||
# reply_entry uebernimmt die Rolle des frueheren "Kommentar/Chat"-Feldes
|
||
field_widgets["kom"] = reply_entry
|
||
toggle_vars["kom"] = tk.BooleanVar(value=True)
|
||
|
||
def _push_to_chat_input(text: str, *, source: str = "") -> None:
|
||
"""Fuegt Text in das Chat-Eingabefeld unten ein (Platzhalter wird ersetzt)."""
|
||
try:
|
||
chunk = text.strip()
|
||
if source == "patient":
|
||
line = chunk if chunk.lower().startswith("nr.:") else f"Nr.: {_compact_nr(chunk)}"
|
||
cur = reply_entry.get("1.0", "end").strip()
|
||
empty = cur == _reply_placeholder or not cur
|
||
if empty:
|
||
reply_entry.delete("1.0", "end")
|
||
reply_entry.insert("1.0", line)
|
||
else:
|
||
reply_entry.insert("1.0", line + "\n")
|
||
reply_entry.configure(fg="#1a2a3a")
|
||
reply_entry.see("1.0")
|
||
return
|
||
cur = reply_entry.get("1.0", "end").strip()
|
||
if cur == _reply_placeholder or not cur:
|
||
reply_entry.delete("1.0", "end")
|
||
reply_entry.insert("1.0", chunk)
|
||
else:
|
||
if not cur.endswith("\n"):
|
||
reply_entry.insert("end", "\n")
|
||
reply_entry.insert("end", chunk)
|
||
reply_entry.configure(fg="#1a2a3a")
|
||
reply_entry.see("end")
|
||
except Exception:
|
||
pass
|
||
|
||
copy_to_chat_fns["push"] = _push_to_chat_input
|
||
|
||
def _reply_box_plain_text():
|
||
"""Einzige Quelle fuer ausgehenden Chat-Text: untere Sende-Box."""
|
||
t = reply_entry.get("1.0", "end").strip()
|
||
if not t or t == _reply_placeholder:
|
||
return ""
|
||
return t
|
||
|
||
_me_name = self._empfang_self_display_name()
|
||
_rcpt_uid_for_name: dict[str, str] = {}
|
||
_conv_status_msg = [""]
|
||
|
||
_conv_state = {
|
||
"tick": -1,
|
||
"audience": None,
|
||
"first_load_done": False,
|
||
}
|
||
_pulse_job = [None]
|
||
_seen_conv_ids: set = set()
|
||
_rcpt_callbacks_ready = [False]
|
||
|
||
_raw_saved_rcpt = prefs.get("rcpt_selected_names")
|
||
if not isinstance(_raw_saved_rcpt, list):
|
||
_raw_saved_rcpt = []
|
||
_rcpt_saved_list = [str(x).strip() for x in _raw_saved_rcpt if str(x).strip()]
|
||
_rcpt_saved_set = set(_rcpt_saved_list)
|
||
_rcpt_saved_lc = {x.lower() for x in _rcpt_saved_list}
|
||
_rcpt_apply_saved_once = [True]
|
||
|
||
_broadcast_rcpt = tk.BooleanVar(value=bool(prefs.get("rcpt_broadcast", False)))
|
||
|
||
tk.Label(
|
||
rcpt_sidebar,
|
||
text="Kontakte",
|
||
font=("Segoe UI", 11, "bold"),
|
||
bg="#fafcfe",
|
||
fg="#1f3f55",
|
||
).pack(anchor="w", padx=12, pady=(12, 2))
|
||
tk.Label(
|
||
rcpt_sidebar,
|
||
text="Zeile aktivieren oder klicken f\u00fcr Direkt-Chat-Verlauf",
|
||
font=("Segoe UI", 8),
|
||
bg="#fafcfe",
|
||
fg="#8499aa",
|
||
wraplength=226,
|
||
justify="left",
|
||
).pack(anchor="w", padx=12, pady=(0, 10))
|
||
|
||
_rcpt_lb_wrap = tk.Frame(rcpt_sidebar, bg="#fafcfe")
|
||
_rcpt_lb_wrap.pack(fill="both", expand=True, padx=8, pady=(0, 8))
|
||
|
||
_rcpt_lb_scroll = ttk.Scrollbar(_rcpt_lb_wrap, orient="vertical")
|
||
_rcpt_listbox = tk.Listbox(
|
||
_rcpt_lb_wrap,
|
||
height=18,
|
||
font=("Segoe UI", 10),
|
||
bg="#ffffff",
|
||
fg="#1a314c",
|
||
selectbackground="#cde7f9",
|
||
selectforeground="#0f3850",
|
||
activestyle="none",
|
||
highlightthickness=1,
|
||
highlightbackground="#e3eaf4",
|
||
highlightcolor="#cdddf0",
|
||
relief="flat",
|
||
bd=0,
|
||
exportselection=False,
|
||
)
|
||
_rcpt_listbox.configure(yscrollcommand=_rcpt_lb_scroll.set)
|
||
_rcpt_lb_scroll.config(command=_rcpt_listbox.yview)
|
||
_rcpt_listbox.pack(side="left", fill="both", expand=True)
|
||
_rcpt_lb_scroll.pack(side="right", fill="y")
|
||
|
||
tk.Frame(rcpt_sidebar, bg="#e4edf8", height=1).pack(fill="x", padx=8, pady=(0, 6))
|
||
|
||
_broadcast_chip = tk.Checkbutton(
|
||
rcpt_sidebar,
|
||
text=(
|
||
"Allgemein \u2039An alle\u203a (kein Direkt-Verlauf in dieser Ansicht)"
|
||
),
|
||
variable=_broadcast_rcpt,
|
||
font=("Segoe UI", 8),
|
||
bg="#fafcfe",
|
||
fg="#7f8ea0",
|
||
activebackground="#fafcfe",
|
||
selectcolor="#ffffff",
|
||
anchor="w",
|
||
wraplength=220,
|
||
justify="left",
|
||
highlightthickness=0,
|
||
bd=0,
|
||
)
|
||
_broadcast_chip.pack(fill="x", padx=(8, 10), pady=(0, 12))
|
||
|
||
def _selected_rcpt_names():
|
||
if _broadcast_rcpt.get():
|
||
return []
|
||
try:
|
||
sel = _rcpt_listbox.curselection()
|
||
except Exception:
|
||
return []
|
||
if not sel:
|
||
return []
|
||
nm = (_rcpt_listbox.get(sel[0]) or "").strip()
|
||
return [nm] if nm else []
|
||
|
||
def _persist_rcpt_prefs():
|
||
try:
|
||
prefs["rcpt_broadcast"] = bool(_broadcast_rcpt.get())
|
||
prefs["rcpt_selected_names"] = list(_selected_rcpt_names())
|
||
self._autotext_data["empfang_prefs"] = prefs
|
||
save_autotext(self._autotext_data)
|
||
except Exception:
|
||
pass
|
||
|
||
def _sync_rcpt_sidebar_state():
|
||
try:
|
||
st = tk.DISABLED if _broadcast_rcpt.get() else tk.NORMAL
|
||
_rcpt_listbox.configure(state=st)
|
||
except Exception:
|
||
pass
|
||
|
||
def _audience_query_string() -> str:
|
||
if _broadcast_rcpt.get():
|
||
return ""
|
||
names = _selected_rcpt_names()
|
||
if not names:
|
||
# Kein Empfaenger: NICHT mit leerem Query = Broadcast verwechseln
|
||
return "__noop__"
|
||
if len(names) == 1:
|
||
return names[0]
|
||
# Mehrere Einzelempfaenger: kein gemischter Pseudodirekt-Verlauf
|
||
return "__multi__"
|
||
|
||
def _extras_from_rcpt() -> dict:
|
||
if _broadcast_rcpt.get():
|
||
return {"audience": "all", "rcpt_broadcast": True}
|
||
names = _selected_rcpt_names()
|
||
if not names:
|
||
return {}
|
||
if len(names) == 1:
|
||
ex1: dict = {
|
||
"recipient": names[0],
|
||
"audience": "direct",
|
||
"rcpt_broadcast": False,
|
||
}
|
||
u1 = (_rcpt_uid_for_name.get(names[0]) or "").strip()
|
||
if u1:
|
||
ex1["recipient_user_id"] = u1
|
||
return ex1
|
||
return {
|
||
"recipients": sorted(names),
|
||
"recipient": ", ".join(sorted(names)),
|
||
}
|
||
|
||
def _me_id_short() -> str:
|
||
uid = self._empfang_self_user_id()
|
||
return uid[:6] if uid else "?"
|
||
|
||
def _refresh_chat_scope_hint():
|
||
try:
|
||
me_disp = (_me_name or "").strip() or "\u2026"
|
||
me_short = _me_id_short()
|
||
if _broadcast_rcpt.get():
|
||
_chat_heading.configure(text="Praxischat (Allgemein)")
|
||
_chat_scope_hint.configure(
|
||
text=(
|
||
f"Sie sind angemeldet als {me_disp}. "
|
||
"Fuer den Direktverlauf \u2014 Kontaktliste nutzen oder \u201eAn alle\u201c deaktivieren. "
|
||
f"({me_short})"
|
||
),
|
||
)
|
||
return
|
||
sns = _selected_rcpt_names()
|
||
if len(sns) == 1:
|
||
peer_uid = (_rcpt_uid_for_name.get(sns[0]) or "").strip()
|
||
peer_short = peer_uid[:6] if peer_uid else "?"
|
||
_chat_heading.configure(text=f"Direkt: {sns[0]}")
|
||
_chat_scope_hint.configure(
|
||
text=(
|
||
f"Sie als {me_disp} ({me_short}) \u00b7 Gegen\u00fcber "
|
||
f"{sns[0]} ({peer_short})"
|
||
),
|
||
)
|
||
return
|
||
if len(sns) >= 2:
|
||
_chat_heading.configure(text="Mehrere Ziele ausgewählt")
|
||
_chat_scope_hint.configure(
|
||
text=(
|
||
"Nur eine Person aus der Liste aktivieren "
|
||
"(Direktverlauf entspricht dem Browser-Chat)."
|
||
),
|
||
)
|
||
return
|
||
_chat_heading.configure(text="Direkt auswählen")
|
||
_chat_scope_hint.configure(
|
||
text=(
|
||
"Bitte links einen Kontakt w\u00e4hlen, um die Unterhaltung zu laden. "
|
||
f"Sie senden als {me_disp} ({me_short})."
|
||
),
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _on_rcpt_change(_=None):
|
||
_seen_conv_ids.clear()
|
||
_conv_state["tick"] = -1
|
||
_conv_state["first_load_done"] = False
|
||
_conv_state["audience"] = None
|
||
try:
|
||
_stop_repeat()
|
||
except Exception:
|
||
pass
|
||
if _rcpt_callbacks_ready[0]:
|
||
try:
|
||
_load_conversation(force=True)
|
||
except Exception:
|
||
pass
|
||
_refresh_chat_scope_hint()
|
||
_persist_rcpt_prefs()
|
||
|
||
def _lb_select_chat_peer(_evt=None):
|
||
_on_rcpt_change()
|
||
|
||
def _rebuild_rcpt_list(names_sorted):
|
||
prev_nm = ""
|
||
try:
|
||
tsel = _rcpt_listbox.curselection()
|
||
if tsel:
|
||
prev_nm = (_rcpt_listbox.get(tsel[0]) or "").strip()
|
||
except Exception:
|
||
prev_nm = ""
|
||
try:
|
||
_rcpt_listbox.unbind("<<ListboxSelect>>")
|
||
except Exception:
|
||
pass
|
||
_rcpt_listbox.delete(0, tk.END)
|
||
use_saved = _rcpt_apply_saved_once[0]
|
||
names_clean = [(n or "").strip() for n in names_sorted if (n or "").strip()]
|
||
sort_names = sorted(names_clean, key=lambda s: s.lower())
|
||
pick_i = None
|
||
for i, nm in enumerate(sort_names):
|
||
_rcpt_listbox.insert(tk.END, nm)
|
||
if pick_i is None:
|
||
if use_saved:
|
||
if nm in _rcpt_saved_set or nm.lower() in _rcpt_saved_lc:
|
||
pick_i = i
|
||
elif prev_nm and nm.strip().lower() == prev_nm.lower():
|
||
pick_i = i
|
||
_rcpt_apply_saved_once[0] = False
|
||
if pick_i is not None:
|
||
try:
|
||
_rcpt_listbox.selection_clear(0, tk.END)
|
||
_rcpt_listbox.selection_set(pick_i)
|
||
_rcpt_listbox.activate(pick_i)
|
||
_rcpt_listbox.see(pick_i)
|
||
except Exception:
|
||
pass
|
||
elif sort_names and not _broadcast_rcpt.get():
|
||
try:
|
||
_rcpt_listbox.selection_set(0)
|
||
_rcpt_listbox.activate(0)
|
||
_rcpt_listbox.see(0)
|
||
except Exception:
|
||
pass
|
||
_rcpt_listbox.bind("<<ListboxSelect>>", _lb_select_chat_peer)
|
||
_sync_rcpt_sidebar_state()
|
||
|
||
_rcpt_refresh_job = [None]
|
||
|
||
_broadcast_rcpt.trace_add(
|
||
"write",
|
||
lambda *_: (_sync_rcpt_sidebar_state(), _on_rcpt_change()),
|
||
)
|
||
|
||
def _refresh_recipients():
|
||
try:
|
||
bu = self.get_backend_url()
|
||
r = requests.get(f"{bu}/empfang/users",
|
||
headers=self._empfang_headers(), timeout=5)
|
||
if r.status_code == 200:
|
||
dj = r.json() or {}
|
||
names = dj.get("users") or []
|
||
uid_map: dict[str, str] = {}
|
||
full = dj.get("users_full")
|
||
if isinstance(full, list):
|
||
for u in full:
|
||
if not isinstance(u, dict):
|
||
continue
|
||
dn = (u.get("display_name") or "").strip()
|
||
uu = str(u.get("user_id") or "").strip()
|
||
if dn and uu:
|
||
uid_map[dn] = uu
|
||
# Eigene UID nachziehen, falls Profil noch keine hat: das
|
||
# macht den Direktverlauf direkt nach dem Login zuverlaessig.
|
||
try:
|
||
if not self._empfang_self_user_id() and isinstance(full, list):
|
||
self._empfang_self_user_id_resolve_now(full)
|
||
except Exception:
|
||
pass
|
||
me_low = (_me_name or "").strip().lower()
|
||
cleaned = [
|
||
n for n in names
|
||
if (n or "").strip().lower() != me_low
|
||
]
|
||
me_uid_now = self._empfang_self_user_id()
|
||
|
||
def _apply(lst: list, mapping: dict[str, str], my_uid: str):
|
||
def _inner():
|
||
_rcpt_uid_for_name.clear()
|
||
_rcpt_uid_for_name.update(mapping)
|
||
_rebuild_rcpt_list(lst)
|
||
_refresh_chat_scope_hint()
|
||
# Wenn beim Oeffnen noch keine UID bekannt war,
|
||
# jetzt einmal frisch laden, damit der Direktchat
|
||
# nicht "leer" stehen bleibt.
|
||
if my_uid and not _conv_state.get("first_load_done"):
|
||
try:
|
||
_load_conversation(force=True)
|
||
except Exception:
|
||
pass
|
||
|
||
self.after(0, _inner)
|
||
|
||
self.after(0, lambda c=sorted(cleaned), m=dict(uid_map), u=me_uid_now: _apply(c, m, u))
|
||
except Exception:
|
||
pass
|
||
|
||
def _periodic_refresh_recipients():
|
||
threading.Thread(target=_refresh_recipients, daemon=True).start()
|
||
try:
|
||
if dlg.winfo_exists():
|
||
_rcpt_refresh_job[0] = dlg.after(15000, _periodic_refresh_recipients)
|
||
except Exception:
|
||
pass
|
||
|
||
_conv_refresh_ref["refresh_users"] = _refresh_recipients
|
||
_periodic_refresh_recipients()
|
||
|
||
def _update_chat(messages, hint: Optional[str] = None):
|
||
try:
|
||
base_msgs = list(messages or [])
|
||
except Exception:
|
||
base_msgs = []
|
||
self._empfang_last_thread_messages = base_msgs
|
||
|
||
def _sender_core_line(absender):
|
||
return (absender or "").split("(", 1)[0].strip()
|
||
|
||
try:
|
||
_configure_chat_strip_tags()
|
||
except Exception:
|
||
pass
|
||
|
||
# Vor dem Rendern Scroll-Position konservieren, damit ein
|
||
# automatischer Refresh den Benutzer nicht nach oben reisst.
|
||
try:
|
||
_near_bottom = float(chat_display.yview()[1]) >= 0.97
|
||
except Exception:
|
||
_near_bottom = True
|
||
|
||
chat_display.configure(state="normal")
|
||
chat_display.delete("1.0", "end")
|
||
if hint == "__noop__":
|
||
chat_display.insert(
|
||
"end",
|
||
(
|
||
"\nBitte links in der Kontaktliste eine Person ausw\u00e4hlen, "
|
||
"um den Direktverlauf zu sehen.\n\n"
|
||
"\u00abAllgemein \u2013 An alle\u00bb zeigt in dieser Ansicht keinen "
|
||
"Personenchat.\n"
|
||
),
|
||
"hint_empty",
|
||
)
|
||
chat_display.configure(state="disabled")
|
||
if _near_bottom:
|
||
chat_display.see("end")
|
||
return
|
||
if hint == "__multi__":
|
||
chat_display.insert(
|
||
"end",
|
||
"\nMehrere Empfaenger ausgewaehlt.\n"
|
||
"Der Verlauf zeigt nur bei genau einem Benutzer den Direktchat.\n",
|
||
"hint_empty",
|
||
)
|
||
chat_display.configure(state="disabled")
|
||
if _near_bottom:
|
||
chat_display.see("end")
|
||
return
|
||
|
||
if not base_msgs:
|
||
err_txt = (_conv_status_msg[0] or "").strip()
|
||
if err_txt:
|
||
chat_display.insert("end", "\n" + err_txt + "\n", "hint_empty")
|
||
else:
|
||
sns_now = [] if _broadcast_rcpt.get() else _selected_rcpt_names()
|
||
if _broadcast_rcpt.get():
|
||
chat_display.insert(
|
||
"end",
|
||
"\nAllgemein-Chat: Bitte Empfaenger waehlen oder Server pruefen.\n",
|
||
"hint_empty",
|
||
)
|
||
elif len(sns_now) == 1:
|
||
peer_uid_now = (_rcpt_uid_for_name.get(sns_now[0]) or "").strip()
|
||
if not self._empfang_self_user_id():
|
||
chat_display.insert(
|
||
"end",
|
||
"\nDer angemeldete Desktop-Benutzer ist fuer den Chat "
|
||
"noch nicht eindeutig zugeordnet.\n"
|
||
"Bitte kurz warten oder \u00abAktualisieren\u00bb.\n",
|
||
"hint_empty",
|
||
)
|
||
elif not peer_uid_now:
|
||
chat_display.insert(
|
||
"end",
|
||
"\nEmpfaenger konnte technisch noch nicht zugeordnet werden.\n"
|
||
"Bitte \u00abAktualisieren\u00bb.\n",
|
||
"hint_empty",
|
||
)
|
||
else:
|
||
chat_display.insert(
|
||
"end",
|
||
f"\nNoch keine Nachrichten in diesem Direktchat mit {sns_now[0]}.\n"
|
||
"\nNachricht unten schreiben und Senden.",
|
||
"hint_empty",
|
||
)
|
||
else:
|
||
chat_display.insert(
|
||
"end",
|
||
"\nKein Unterhaltungsverlauf angezeigt.\n",
|
||
"hint_empty",
|
||
)
|
||
chat_display.configure(state="disabled")
|
||
if _near_bottom:
|
||
chat_display.see("end")
|
||
return
|
||
|
||
def _fmt_msg_clock(z_raw: str) -> str:
|
||
z = (z_raw or "").strip()
|
||
if not z:
|
||
return ""
|
||
zc = z.replace(" ", "T", 1)[:19]
|
||
try:
|
||
dtp = datetime.fromisoformat(zc)
|
||
today = datetime.now().date()
|
||
if dtp.date() == today:
|
||
return dtp.strftime("%H:%M")
|
||
return dtp.strftime("%d.%m. %H:%M")
|
||
except Exception:
|
||
return z[11:16] if len(z) >= 16 else z[:16]
|
||
|
||
me_l = (_me_name or "").strip().lower()
|
||
last_sep_day = ""
|
||
|
||
def _coerce_chat_row(mm) -> dict:
|
||
m = mm if isinstance(mm, dict) else {}
|
||
snd = m.get("absender")
|
||
if snd is None:
|
||
snd = (
|
||
m.get("sender_display_name")
|
||
or m.get("sender_name")
|
||
or m.get("sender")
|
||
or m.get("from")
|
||
)
|
||
zt = m.get("zeitstempel")
|
||
if zt is None:
|
||
zt = (
|
||
m.get("empfangen")
|
||
or m.get("created_at")
|
||
or m.get("ts")
|
||
or m.get("sent_at")
|
||
)
|
||
kb = m.get("kommentar")
|
||
if kb is None:
|
||
kb = m.get("text") or m.get("body") or m.get("content")
|
||
if kb is None:
|
||
kb = ""
|
||
if isinstance(kb, (dict, list)):
|
||
kb = str(kb)
|
||
mid = m.get("id")
|
||
if mid is None:
|
||
mid = m.get("message_id")
|
||
zt_s = "" if zt is None else str(zt)
|
||
return {
|
||
"absender": "" if snd is None else str(snd),
|
||
"zeitstempel": zt_s,
|
||
"empfangen": zt_s,
|
||
"kommentar": str(kb),
|
||
"id": mid,
|
||
}
|
||
|
||
def _day_key(ts: str) -> str:
|
||
t = (ts or "").strip()
|
||
return t[:10] if len(t) >= 10 else ""
|
||
|
||
for raw in base_msgs:
|
||
msg = _coerce_chat_row(raw)
|
||
dk = _day_key(
|
||
msg.get("empfangen", "") or msg.get("zeitstempel", ""),
|
||
)
|
||
if dk and dk != last_sep_day:
|
||
last_sep_day = dk
|
||
try:
|
||
dlab = datetime.fromisoformat(dk.replace(" ", "T"))
|
||
sep_txt = (
|
||
"Heute"
|
||
if dlab.date() == datetime.now().date()
|
||
else dlab.strftime("%d.%m.%Y")
|
||
)
|
||
except Exception:
|
||
sep_txt = dk
|
||
chat_display.insert("end", "\n\u200b \u2500 " + sep_txt + " \u2500\u200b\n", "date_sep")
|
||
|
||
sender = msg.get("absender", "") or ""
|
||
zeit_raw = msg.get("zeitstempel", msg.get("empfangen", ""))
|
||
raw_k = msg.get("kommentar", "") or ""
|
||
st = raw_k.strip()
|
||
if st == "\u200b":
|
||
body_text = "(Anhang)"
|
||
elif not st:
|
||
body_text = "(leer)"
|
||
else:
|
||
body_text = raw_k
|
||
name_core = _sender_core_line(sender).strip()
|
||
outgoing = name_core.strip().lower() == me_l
|
||
z_disp = _fmt_msg_clock(str(zeit_raw))
|
||
lbl = name_core or sender.strip() or "\u2013"
|
||
|
||
if outgoing:
|
||
chat_display.insert(
|
||
"end",
|
||
lbl + " \u00b7 ",
|
||
("out_blk", "out_meta_name"),
|
||
)
|
||
chat_display.insert(
|
||
"end",
|
||
z_disp + "\n",
|
||
("out_blk", "out_meta_time"),
|
||
)
|
||
else:
|
||
chat_display.insert(
|
||
"end",
|
||
lbl + " \u00b7 ",
|
||
("in_blk", "in_meta_name"),
|
||
)
|
||
chat_display.insert(
|
||
"end",
|
||
z_disp + "\n",
|
||
("in_blk", "in_meta_time"),
|
||
)
|
||
|
||
body_tag = "out_body" if outgoing else "in_body"
|
||
for seg in body_text.splitlines(True):
|
||
chat_display.insert("end", seg, body_tag)
|
||
chat_display.insert("end", "\n", body_tag)
|
||
mid = msg.get("id")
|
||
if mid:
|
||
_known_ids[0].add(mid)
|
||
chat_display.configure(state="disabled")
|
||
if _near_bottom:
|
||
chat_display.see("end")
|
||
|
||
self._empfang_update_chat_fn = _update_chat
|
||
|
||
# ----------------------------------------------------------------
|
||
# Conversation laden (zielabhaengig, serverseitig gefiltert).
|
||
# Eine Quelle fuer Browser, Hülle und Desktop-Dialog.
|
||
# ----------------------------------------------------------------
|
||
|
||
def _load_conversation(force: bool = False):
|
||
try:
|
||
if not dlg.winfo_exists():
|
||
return
|
||
except Exception:
|
||
return
|
||
try:
|
||
backend_url = self.get_backend_url()
|
||
_hdrs = self._empfang_headers()
|
||
except Exception:
|
||
return
|
||
audience = _audience_query_string()
|
||
|
||
if audience in ("__noop__", "__multi__"):
|
||
def _apply_local():
|
||
try:
|
||
audience_changed = _conv_state["audience"] != audience
|
||
if audience_changed:
|
||
_known_ids[0].clear()
|
||
_update_chat([], hint=audience)
|
||
_conv_state["audience"] = audience
|
||
_conv_state["first_load_done"] = True
|
||
except Exception:
|
||
pass
|
||
|
||
self.after(0, _apply_local)
|
||
return
|
||
|
||
def _fetch():
|
||
try:
|
||
me_uid = self._empfang_self_user_id()
|
||
if not me_uid:
|
||
me_uid = self._empfang_self_user_id_resolve_now()
|
||
pid_ld = (self.get_practice_id() or "").strip()
|
||
if (
|
||
not _broadcast_rcpt.get()
|
||
and pid_ld
|
||
and me_uid
|
||
):
|
||
sns_dm = _selected_rcpt_names()
|
||
if len(sns_dm) == 1:
|
||
puid = (_rcpt_uid_for_name.get(sns_dm[0]) or "").strip()
|
||
if puid and me_uid != puid:
|
||
rdm = requests.get(
|
||
f"{backend_url}/empfang/dm/conversation",
|
||
params={
|
||
"practice_id": pid_ld,
|
||
"sender_user_id": me_uid,
|
||
"recipient_user_id": puid,
|
||
},
|
||
headers=_hdrs,
|
||
timeout=8,
|
||
)
|
||
if rdm.status_code != 200:
|
||
_conv_status_msg[0] = (
|
||
f"DM-Verlauf HTTP {rdm.status_code}"
|
||
)
|
||
self.after(0, lambda: _update_chat([]))
|
||
return
|
||
_conv_status_msg[0] = ""
|
||
dmj = rdm.json()
|
||
dm_msgs = dmj.get("messages", []) or []
|
||
dm_tick = int(dmj.get("tick", 0))
|
||
dm_aud = f"__dm_v2__{puid}"
|
||
|
||
def _apply_dm():
|
||
try:
|
||
ids = {m.get("id") for m in dm_msgs if m.get("id")}
|
||
new_msg_ids = ids - _seen_conv_ids
|
||
had_first = _conv_state["first_load_done"]
|
||
audience_changed = _conv_state["audience"] != dm_aud
|
||
if audience_changed:
|
||
_known_ids[0].clear()
|
||
_update_chat(dm_msgs)
|
||
if had_first and not audience_changed:
|
||
incoming_new = []
|
||
for m in dm_msgs:
|
||
if m.get("id") not in new_msg_ids:
|
||
continue
|
||
sender = (m.get("absender") or "")
|
||
sender_core = sender.split("(")[0].strip()
|
||
if (
|
||
sender_core
|
||
and _me_name
|
||
and sender_core.lower() == _me_name.lower()
|
||
):
|
||
continue
|
||
incoming_new.append(m)
|
||
if incoming_new and _sound_enabled_var.get():
|
||
_play_tone()
|
||
_start_repeat()
|
||
_seen_conv_ids.update(ids)
|
||
_conv_state["tick"] = dm_tick
|
||
_conv_state["audience"] = dm_aud
|
||
_conv_state["first_load_done"] = True
|
||
except Exception:
|
||
pass
|
||
|
||
self.after(0, _apply_dm)
|
||
return
|
||
|
||
peer_uid = ""
|
||
if not _broadcast_rcpt.get():
|
||
sns = _selected_rcpt_names()
|
||
if len(sns) == 1:
|
||
peer_uid = (_rcpt_uid_for_name.get(sns[0]) or "").strip()
|
||
params = {
|
||
"audience": audience,
|
||
"me": _me_name,
|
||
"me_user_id": me_uid,
|
||
"peer_user_id": peer_uid,
|
||
}
|
||
if pid_ld:
|
||
params["practice_id"] = pid_ld
|
||
r = requests.get(
|
||
f"{backend_url}/empfang/conversation",
|
||
params=params, headers=_hdrs, timeout=8,
|
||
)
|
||
if r.status_code != 200:
|
||
_conv_status_msg[0] = (
|
||
f"Verlauf konnte nicht geladen werden (HTTP {r.status_code})."
|
||
)
|
||
self.after(0, lambda: _update_chat([]))
|
||
return
|
||
_conv_status_msg[0] = ""
|
||
data = r.json()
|
||
msgs = data.get("messages", []) or []
|
||
server_tick = int(data.get("tick", 0))
|
||
|
||
def _apply():
|
||
try:
|
||
ids = {m.get("id") for m in msgs if m.get("id")}
|
||
new_msg_ids = ids - _seen_conv_ids
|
||
had_first = _conv_state["first_load_done"]
|
||
audience_changed = (
|
||
_conv_state["audience"] != audience
|
||
)
|
||
|
||
# Bei Wechsel des Ziels Anzeige komplett neu aufbauen
|
||
if audience_changed:
|
||
_known_ids[0].clear()
|
||
|
||
_update_chat(msgs)
|
||
|
||
# Signal nur bei *eingehender* neuer Nachricht von
|
||
# einem fremden Absender (nicht eigene Sendungen
|
||
# und nicht der erste Initial-Load).
|
||
if had_first and not audience_changed:
|
||
incoming_new = []
|
||
for m in msgs:
|
||
if m.get("id") not in new_msg_ids:
|
||
continue
|
||
sender = (m.get("absender") or "")
|
||
sender_core = sender.split("(")[0].strip()
|
||
if (
|
||
sender_core
|
||
and _me_name
|
||
and sender_core.lower() == _me_name.lower()
|
||
):
|
||
continue
|
||
incoming_new.append(m)
|
||
if incoming_new and _sound_enabled_var.get():
|
||
_play_tone()
|
||
_start_repeat()
|
||
|
||
_seen_conv_ids.update(ids)
|
||
_conv_state["tick"] = server_tick
|
||
_conv_state["audience"] = audience
|
||
_conv_state["first_load_done"] = True
|
||
except Exception:
|
||
pass
|
||
|
||
self.after(0, _apply)
|
||
except requests.exceptions.ConnectionError:
|
||
_conv_status_msg[0] = (
|
||
"Backend nicht erreichbar. Bitte Verbindung pruefen."
|
||
)
|
||
self.after(0, lambda: _update_chat([]))
|
||
except Exception as exc:
|
||
_conv_status_msg[0] = f"Verlauf-Ladefehler: {type(exc).__name__}"
|
||
self.after(0, lambda: _update_chat([]))
|
||
|
||
threading.Thread(target=_fetch, daemon=True).start()
|
||
|
||
# Live-Pulse: alle 800 ms ein winziger Request an /pulse.
|
||
# Wechselt der Tick -> sofort die volle Conversation neu laden.
|
||
# Dadurch fuehlt sich das Signal in <1 s sofort an, statt erst
|
||
# nach 5–10 s wie beim alten /thread-Polling.
|
||
def _poll_pulse():
|
||
try:
|
||
if not dlg.winfo_exists():
|
||
return
|
||
except Exception:
|
||
return
|
||
try:
|
||
backend_url = self.get_backend_url()
|
||
_hdrs = self._empfang_headers()
|
||
except Exception:
|
||
_pulse_job[0] = dlg.after(2000, _poll_pulse)
|
||
return
|
||
|
||
def _ping():
|
||
try:
|
||
r = requests.get(
|
||
f"{backend_url}/empfang/pulse",
|
||
headers=_hdrs, timeout=4,
|
||
)
|
||
if r.status_code != 200:
|
||
return
|
||
new_tick = int((r.json() or {}).get("tick", 0))
|
||
if new_tick != _conv_state["tick"]:
|
||
self.after(0, _load_conversation)
|
||
except Exception:
|
||
pass
|
||
|
||
threading.Thread(target=_ping, daemon=True).start()
|
||
try:
|
||
if dlg.winfo_exists():
|
||
_pulse_job[0] = dlg.after(800, _poll_pulse)
|
||
except Exception:
|
||
pass
|
||
|
||
# Kompatibilitaets-Aliasse: alter Code ruft _poll_thread auf.
|
||
# Wir lenken alles auf das neue Modell um.
|
||
def _poll_thread():
|
||
_load_conversation()
|
||
|
||
_rcpt_callbacks_ready[0] = True
|
||
_refresh_chat_scope_hint()
|
||
_conv_refresh_ref["load_conv"] = _load_conversation
|
||
|
||
# Bevor der erste Verlauf gezogen wird, einmal versuchen, die eigene
|
||
# UID aus /empfang/users abzuleiten, falls das Profil noch keine hat
|
||
# (z. B. erstes Provisioning steht noch aus).
|
||
def _bootstrap_self_uid_then_load():
|
||
try:
|
||
if not self._empfang_self_user_id():
|
||
self._empfang_self_user_id_resolve_now()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.after(0, lambda: _load_conversation(force=True))
|
||
except Exception:
|
||
pass
|
||
|
||
threading.Thread(target=_bootstrap_self_uid_then_load, daemon=True).start()
|
||
_poll_pulse()
|
||
|
||
# --- Senden (nur Direct/v2: /empfang/dm/send). Kein Allgemein/Broadcast. ---
|
||
def do_send():
|
||
try:
|
||
backend_url = self.get_backend_url()
|
||
_send_hdrs = self._empfang_headers()
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", f"Backend nicht konfiguriert:\n{e}", parent=dlg)
|
||
return
|
||
|
||
if _broadcast_rcpt.get():
|
||
messagebox.showwarning(
|
||
"Allgemein deaktiviert",
|
||
"Allgemein-Versand ist im neuen Personenchat deaktiviert.\n"
|
||
"Bitte \u00abAn alle\u00bb deaktivieren und genau einen Empf\u00e4nger anhaken.",
|
||
parent=dlg,
|
||
)
|
||
return
|
||
|
||
rcpt_list = _selected_rcpt_names()
|
||
if not rcpt_list:
|
||
messagebox.showwarning(
|
||
"Empf\u00e4nger",
|
||
"Bitte genau einen Benutzer in der Liste anhaken.",
|
||
parent=dlg,
|
||
)
|
||
return
|
||
if len(rcpt_list) > 1:
|
||
messagebox.showwarning(
|
||
"Empf\u00e4nger",
|
||
"Mehrere Empf\u00e4nger ausgew\u00e4hlt.\n"
|
||
"Direktantwort nur an genau einen Benutzer m\u00f6glich.",
|
||
parent=dlg,
|
||
)
|
||
return
|
||
|
||
peer_name = rcpt_list[0]
|
||
msg_body = _reply_box_plain_text()
|
||
if not msg_body and not _attached_files:
|
||
messagebox.showwarning(
|
||
"Nachricht",
|
||
"Bitte Text in der unteren Sende-Box eingeben\n"
|
||
"oder einen Anhang hinzufuegen.",
|
||
parent=dlg,
|
||
)
|
||
return
|
||
|
||
pid = (self.get_practice_id() or "").strip()
|
||
if not pid:
|
||
messagebox.showwarning(
|
||
"Praxis",
|
||
"practice_id fehlt im lokalen Profil.\n"
|
||
"Bitte einmal ab- und wieder anmelden.",
|
||
parent=dlg,
|
||
)
|
||
return
|
||
|
||
su = (self._empfang_self_user_id() or "").strip()
|
||
if not su:
|
||
su = (self._empfang_self_user_id_resolve_now() or "").strip()
|
||
ru = (_rcpt_uid_for_name.get(peer_name) or "").strip()
|
||
if not su or not ru:
|
||
messagebox.showwarning(
|
||
"Direktchat",
|
||
"Direktnachricht konnte nicht gesendet werden:\n"
|
||
"technische Benutzer-ID fehlt (Sender oder Empf\u00e4nger).\n"
|
||
"Bitte \u00abAktualisieren\u00bb bei den Empf\u00e4ngern oder neu anmelden.",
|
||
parent=dlg,
|
||
)
|
||
return
|
||
if su == ru:
|
||
messagebox.showwarning(
|
||
"Direktchat",
|
||
"Selbstchat ist nicht erlaubt.",
|
||
parent=dlg,
|
||
)
|
||
return
|
||
|
||
att_list = []
|
||
if _attached_files:
|
||
import base64
|
||
|
||
for fp in _attached_files:
|
||
try:
|
||
with open(fp, "rb") as _af:
|
||
raw = _af.read(2 * 1024 * 1024)
|
||
att_list.append({
|
||
"name": os.path.basename(fp),
|
||
"size": os.path.getsize(fp),
|
||
"data": base64.b64encode(raw).decode("ascii"),
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
dm_txt = (msg_body or "").strip()
|
||
if not dm_txt and att_list:
|
||
dm_txt = ""
|
||
|
||
def worker():
|
||
try:
|
||
diag_h = f"desk_dm {su[:8]}\u2192{ru[:8]}"
|
||
self.after(0, lambda h=diag_h: self.set_status(
|
||
f"Sende direct {h} \u2192 POST /empfang/dm/send"
|
||
))
|
||
payload_dm = {
|
||
"practice_id": pid,
|
||
"sender_user_id": su,
|
||
"recipient_user_id": ru,
|
||
"text": dm_txt,
|
||
"attachments": att_list,
|
||
"client_msg_id": f"desk-{uuid.uuid4().hex[:12]}",
|
||
}
|
||
r = requests.post(
|
||
f"{backend_url}/empfang/dm/send",
|
||
json=payload_dm,
|
||
headers=_send_hdrs,
|
||
timeout=(8, 30),
|
||
)
|
||
detail = ""
|
||
try:
|
||
jerr = r.json()
|
||
detail = (jerr.get("detail") if isinstance(jerr, dict) else "") or ""
|
||
except Exception:
|
||
detail = (r.text or "")[:180]
|
||
if r.status_code != 200:
|
||
self.after(0, lambda: _flash_bg(False))
|
||
self.after(
|
||
0,
|
||
lambda sc=r.status_code, d=detail: messagebox.showerror(
|
||
"Direktversand",
|
||
f"HTTP {sc}\n{d or 'Keine Detailmeldung.'}",
|
||
parent=dlg,
|
||
),
|
||
)
|
||
self.after(0, lambda: self.set_status(
|
||
f"Direktversand fehlgeschlagen HTTP {r.status_code}"
|
||
))
|
||
return
|
||
data = r.json()
|
||
if not isinstance(data, dict) or not data.get("success"):
|
||
self.after(0, lambda: _flash_bg(False))
|
||
self.after(
|
||
0,
|
||
lambda: messagebox.showerror(
|
||
"Direktversand",
|
||
"Unerwartete Serverantwort (success fehlt).",
|
||
parent=dlg,
|
||
),
|
||
)
|
||
return
|
||
if str(data.get("mode") or "") != "direct":
|
||
self.after(0, lambda: _flash_bg(False))
|
||
self.after(
|
||
0,
|
||
lambda m=data.get("mode"): messagebox.showerror(
|
||
"Direktversand",
|
||
f"Server mode ist nicht direct: {m!r}",
|
||
parent=dlg,
|
||
),
|
||
)
|
||
return
|
||
mid = str(data.get("message_id") or "").strip()
|
||
ck = str(data.get("conversation_key") or "").strip()
|
||
if not mid:
|
||
self.after(0, lambda: _flash_bg(False))
|
||
self.after(
|
||
0,
|
||
lambda: messagebox.showerror(
|
||
"Direktversand",
|
||
"Server lieferte keine message_id.",
|
||
parent=dlg,
|
||
),
|
||
)
|
||
return
|
||
|
||
ver_url = (
|
||
f"{backend_url}/empfang/dm/conversation?"
|
||
f"practice_id={requests.utils.quote(pid, safe='')}"
|
||
f"&sender_user_id={requests.utils.quote(su, safe='')}"
|
||
f"&recipient_user_id={requests.utils.quote(ru, safe='')}"
|
||
)
|
||
rv = requests.get(ver_url, headers=_send_hdrs, timeout=8)
|
||
ok_reload = False
|
||
n_ct = -1
|
||
if rv.status_code == 200:
|
||
vj = rv.json()
|
||
vmsgs = vj.get("messages", []) or []
|
||
n_ct = len(vmsgs)
|
||
ok_reload = any(
|
||
(m.get("id") or "") == mid for m in vmsgs if isinstance(m, dict)
|
||
)
|
||
if not ok_reload:
|
||
self.after(0, lambda: _flash_bg(False))
|
||
self.after(
|
||
0,
|
||
lambda: messagebox.showwarning(
|
||
"Verifikation",
|
||
"Nachricht wurde gespeichert, aber im Direktverlauf "
|
||
"nicht wiedergefunden.\n"
|
||
f"msg_id={mid}\nconv={ck}\nreload_count={n_ct}\n"
|
||
"Bitte Serverlog (AZA_CHAT_*) pr\u00fcfen.",
|
||
parent=dlg,
|
||
),
|
||
)
|
||
self.after(0, lambda: self.set_status(
|
||
f"Verifikation fehlgeschlagen msg={mid[:8]} load={n_ct}"
|
||
))
|
||
return
|
||
|
||
_active_thread[0] = mid
|
||
_known_ids[0].add(mid)
|
||
_seen_conv_ids.add(mid)
|
||
self.after(0, lambda: _save_prefs())
|
||
self.after(0, lambda: _clear_attachments())
|
||
self.after(0, lambda: _restore_reply_placeholder())
|
||
self.after(0, lambda: self.set_status(
|
||
f"Direkt gesendet msg={mid[:8]} conv={ck[-16:]}"
|
||
))
|
||
self.after(0, lambda: _flash_bg(True))
|
||
self.after(0, lambda: _load_conversation(force=True))
|
||
self.after(800, lambda: _load_conversation(force=True))
|
||
except requests.exceptions.ConnectionError:
|
||
self.after(0, lambda: _flash_bg(False))
|
||
self.after(0, lambda: messagebox.showerror(
|
||
"Nicht erreichbar",
|
||
"Backend ist nicht erreichbar.\n"
|
||
"Bitte Netzwerk und Backend pruefen.",
|
||
parent=dlg,
|
||
))
|
||
except Exception as e:
|
||
self.after(0, lambda: _flash_bg(False))
|
||
err = str(e)
|
||
self.after(0, lambda m=err: messagebox.showerror(
|
||
"Sendefehler", f"Senden fehlgeschlagen:\n{m}", parent=dlg))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def do_reply():
|
||
if not _active_thread[0]:
|
||
messagebox.showinfo("Hinweis",
|
||
"Bitte zuerst eine Nachricht senden,\n"
|
||
"um einen Chat-Thread zu starten.",
|
||
parent=dlg)
|
||
return
|
||
text = _reply_box_plain_text()
|
||
if not text:
|
||
return
|
||
try:
|
||
backend_url = self.get_backend_url()
|
||
_reply_hdrs = self._empfang_headers()
|
||
except Exception:
|
||
return
|
||
reply_extras = {"reply_to": _active_thread[0]}
|
||
reply_extras.update(_extras_from_rcpt())
|
||
if not _broadcast_rcpt.get():
|
||
su2 = self._empfang_self_user_id()
|
||
if su2:
|
||
reply_extras["sender_user_id"] = su2
|
||
_names_r = _selected_rcpt_names()
|
||
if len(_names_r) == 1:
|
||
_ru2 = (_rcpt_uid_for_name.get(_names_r[0]) or "").strip()
|
||
if not _ru2 or not su2:
|
||
messagebox.showwarning(
|
||
"Direktchat",
|
||
"Antwort kann nicht gesendet werden: Direktchat ist "
|
||
"technisch noch nicht eindeutig zugeordnet.\n"
|
||
"Bitte kurz auf \u00abAktualisieren\u00bb warten.",
|
||
parent=dlg,
|
||
)
|
||
return
|
||
reply_extras["recipient_user_id"] = _ru2
|
||
reply_payload = {
|
||
"medikamente": "", "therapieplan": "", "procedere": "",
|
||
"kommentar": text, "patient": "",
|
||
"absender": f"{self._empfang_self_display_name() or 'Benutzer'} ({platform.node()})",
|
||
"zeitstempel": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"extras": reply_extras,
|
||
}
|
||
|
||
def _reply_worker():
|
||
try:
|
||
r = requests.post(
|
||
f"{backend_url}/empfang/send",
|
||
json=reply_payload,
|
||
headers=_reply_hdrs,
|
||
timeout=(5, 15),
|
||
)
|
||
r.raise_for_status()
|
||
data = r.json()
|
||
if data.get("success"):
|
||
_known_ids[0].add(data.get("id"))
|
||
_seen_conv_ids.add(data.get("id"))
|
||
self.after(0, lambda: reply_entry.delete("1.0", "end"))
|
||
self.after(0, lambda: _load_conversation(force=True))
|
||
except Exception:
|
||
pass
|
||
|
||
threading.Thread(target=_reply_worker, daemon=True).start()
|
||
|
||
# --- Inline-Diktat (Toggle-Stil, kein separates Fenster) ---
|
||
_dik = {"active": False, "rec": None, "btn": None}
|
||
|
||
def _toggle_dik(btn, target_widget):
|
||
if _dik["active"] and _dik["btn"] is btn:
|
||
_dik["active"] = False
|
||
btn.configure(text="\u23fa Diktieren", bg="#e0d4f5", fg="#4a1a6d")
|
||
rec = _dik["rec"]
|
||
_dik["rec"] = None
|
||
_dik["btn"] = None
|
||
btn.configure(state="disabled")
|
||
|
||
def _transcribe():
|
||
try:
|
||
wav = rec.stop_and_save_wav()
|
||
safe = persist_audio_safe(wav)
|
||
txt = self.transcribe_wav(safe)
|
||
txt = self._diktat_apply_punctuation(txt)
|
||
if safe != wav:
|
||
try:
|
||
os.remove(wav)
|
||
except Exception:
|
||
pass
|
||
if txt:
|
||
self.after(
|
||
0,
|
||
lambda t=txt, w=target_widget: _apply_diktat_to_reply(w, t),
|
||
)
|
||
except Exception as exc:
|
||
self.after(0, lambda m=str(exc): messagebox.showerror(
|
||
"Diktat", m, parent=dlg))
|
||
finally:
|
||
self.after(0, lambda: btn.configure(state="normal"))
|
||
|
||
threading.Thread(target=_transcribe, daemon=True).start()
|
||
return
|
||
|
||
if _dik["active"]:
|
||
try:
|
||
_dik["rec"].stop_and_save_wav()
|
||
except Exception:
|
||
pass
|
||
if _dik["btn"]:
|
||
_dik["btn"].configure(text="\u23fa Diktieren",
|
||
bg="#e0d4f5", fg="#4a1a6d")
|
||
_dik["active"] = False
|
||
|
||
if not self.ensure_ready() or not self._check_ai_consent():
|
||
return
|
||
if not self._ensure_microphone_ready():
|
||
return
|
||
try:
|
||
_strip_reply_placeholder_if_shown(target_widget)
|
||
new_rec = AudioRecorder()
|
||
new_rec.start()
|
||
_dik["active"] = True
|
||
_dik["rec"] = new_rec
|
||
_dik["btn"] = btn
|
||
btn.configure(text="\u23f9 Stoppen", bg="#f8d7da", fg="#721c24")
|
||
except Exception as exc:
|
||
messagebox.showwarning("Aufnahme", str(exc), parent=dlg)
|
||
|
||
# --- Chat-Verlauf-Fenster ---
|
||
def _open_chat_verlauf():
|
||
cv = tk.Toplevel(dlg)
|
||
cv.title("Chat-Verlauf")
|
||
cv.geometry("520x450")
|
||
cv.configure(bg="#f0f4f8")
|
||
cv.minsize(360, 300)
|
||
cv.transient(dlg)
|
||
self._register_window(cv)
|
||
|
||
cv_hdr = tk.Frame(cv, bg="#5B8DB3")
|
||
cv_hdr.pack(fill="x")
|
||
tk.Label(cv_hdr, text="Chat-Verlauf", font=("Segoe UI", 11, "bold"),
|
||
bg="#5B8DB3", fg="white").pack(side="left", padx=12, pady=8)
|
||
|
||
cv_canvas = tk.Canvas(cv, bg="#f0f4f8", highlightthickness=0)
|
||
cv_vsb = ttk.Scrollbar(cv, orient="vertical", command=cv_canvas.yview)
|
||
cv_canvas.configure(yscrollcommand=cv_vsb.set)
|
||
cv_vsb.pack(side="right", fill="y")
|
||
cv_canvas.pack(fill="both", expand=True)
|
||
cv_inner = tk.Frame(cv_canvas, bg="#f0f4f8", padx=8, pady=8)
|
||
cv_wid = cv_canvas.create_window((0, 0), window=cv_inner, anchor="nw")
|
||
cv_inner.bind("<Configure>",
|
||
lambda e: cv_canvas.configure(scrollregion=cv_canvas.bbox("all")))
|
||
cv_canvas.bind("<Configure>",
|
||
lambda e: cv_canvas.itemconfigure(cv_wid, width=e.width))
|
||
|
||
def _cv_fetch():
|
||
try:
|
||
bu = self.get_backend_url()
|
||
r = requests.get(f"{bu}/empfang/messages",
|
||
headers=self._empfang_headers(), timeout=10)
|
||
if r.status_code == 200:
|
||
msgs = r.json().get("messages", [])
|
||
threads = {}
|
||
for m in msgs:
|
||
tid = m.get("thread_id", m.get("id"))
|
||
threads.setdefault(tid, []).append(m)
|
||
self.after(0, lambda: _cv_render(cv, cv_inner, threads))
|
||
except Exception:
|
||
pass
|
||
|
||
def _cv_render(win, frame, threads):
|
||
for w in frame.winfo_children():
|
||
w.destroy()
|
||
if not threads:
|
||
tk.Label(frame, text="Keine Chats vorhanden.",
|
||
font=("Segoe UI", 10), bg="#f0f4f8",
|
||
fg="#8a9aaa").pack(pady=20)
|
||
return
|
||
sorted_tids = sorted(threads.keys(),
|
||
key=lambda t: threads[t][0].get("empfangen", ""),
|
||
reverse=True)
|
||
for tid in sorted_tids:
|
||
msgs_t = threads[tid]
|
||
root_m = next((m for m in msgs_t if m.get("id") == tid), msgs_t[0])
|
||
reps = [m for m in msgs_t if m.get("id") != tid]
|
||
st = root_m.get("status", "offen")
|
||
card = tk.Frame(frame, bg="white", relief="solid", bd=1)
|
||
card.pack(fill="x", pady=3)
|
||
top = tk.Frame(card, bg="white")
|
||
top.pack(fill="x", padx=10, pady=6)
|
||
tk.Label(top, text=root_m.get("patient") or "Ohne Nr.",
|
||
font=("Segoe UI", 10, "bold"), bg="white",
|
||
fg="#1a3a5a").pack(side="left")
|
||
if reps:
|
||
tk.Label(top, text=f"({len(reps)} Antw.)",
|
||
font=("Segoe UI", 8), bg="white",
|
||
fg="#8a9aaa").pack(side="left", padx=(6, 0))
|
||
st_bg = "#fff3cd" if st == "offen" else "#d4edda"
|
||
st_fg = "#856404" if st == "offen" else "#155724"
|
||
tk.Label(top, text=st.capitalize(), font=("Segoe UI", 8, "bold"),
|
||
bg=st_bg, fg=st_fg, padx=6, pady=1).pack(side="right")
|
||
bot = tk.Frame(card, bg="white")
|
||
bot.pack(fill="x", padx=10, pady=(0, 6))
|
||
tk.Label(bot, text=f"{root_m.get('absender', '')} \u00b7 "
|
||
f"{root_m.get('zeitstempel', '')}",
|
||
font=("Segoe UI", 8), bg="white",
|
||
fg="#8a9aaa").pack(side="left")
|
||
|
||
def _sel(t=tid, ms=msgs_t, w=win):
|
||
_active_thread[0] = t
|
||
_known_ids[0].clear()
|
||
ms.sort(key=lambda x: x.get("empfangen", ""))
|
||
_update_chat(ms)
|
||
_stop_repeat()
|
||
_poll_thread()
|
||
w.destroy()
|
||
|
||
tk.Button(bot, text="Auswaehlen", font=("Segoe UI", 8),
|
||
bg="#5B8DB3", fg="white", relief="flat",
|
||
padx=8, pady=1, cursor="hand2",
|
||
command=_sel).pack(side="right")
|
||
|
||
tk.Button(cv_hdr, text="\u21bb Laden", font=("Segoe UI", 9),
|
||
bg="white", fg="#5B8DB3", relief="flat", padx=8, pady=2,
|
||
command=lambda: threading.Thread(
|
||
target=_cv_fetch, daemon=True).start()
|
||
).pack(side="right", padx=8, pady=6)
|
||
threading.Thread(target=_cv_fetch, daemon=True).start()
|
||
|
||
def _send_task():
|
||
task_text = _reply_box_plain_text()
|
||
if not task_text:
|
||
messagebox.showinfo("Hinweis",
|
||
"Bitte die Aufgabe in der unteren Sende-Box eingeben.",
|
||
parent=dlg)
|
||
return
|
||
try:
|
||
backend_url = self.get_backend_url()
|
||
_task_hdrs = self._empfang_headers()
|
||
patient_val = _get_text("patient") if toggle_vars["patient"].get() else ""
|
||
if not patient_val:
|
||
if not hasattr(self, '_empfang_chat_nr'):
|
||
self._empfang_chat_nr = 0
|
||
self._empfang_chat_nr += 1
|
||
patient_val = f"Aufgabe {self._empfang_chat_nr}"
|
||
if not _broadcast_rcpt.get() and not _selected_rcpt_names():
|
||
messagebox.showwarning(
|
||
"Empfaenger",
|
||
"Bitte \u00abAn alle\u00bb aktivieren oder mindestens\n"
|
||
"einen Empf\u00e4nger ankreuzen.",
|
||
parent=dlg,
|
||
)
|
||
return
|
||
_task_extras = {"is_task": True}
|
||
_task_extras.update(_extras_from_rcpt())
|
||
if not _broadcast_rcpt.get():
|
||
su3 = self._empfang_self_user_id()
|
||
if su3:
|
||
_task_extras["sender_user_id"] = su3
|
||
payload = {
|
||
"medikamente": "", "therapieplan": "", "procedere": "",
|
||
"kommentar": task_text, "patient": patient_val,
|
||
"absender": f"{self._empfang_self_display_name() or 'Benutzer'} ({platform.node()})",
|
||
"zeitstempel": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"extras": _task_extras,
|
||
}
|
||
|
||
def _w():
|
||
try:
|
||
r = requests.post(
|
||
f"{backend_url}/empfang/send", json=payload,
|
||
headers=_task_hdrs, timeout=(5, 15))
|
||
r.raise_for_status()
|
||
self.after(0, lambda: _flash_bg(True))
|
||
self.after(0, lambda: self.set_status("Aufgabe gesendet."))
|
||
except Exception as exc:
|
||
self.after(0, lambda: _flash_bg(False))
|
||
self.after(0, lambda m=str(exc): messagebox.showerror(
|
||
"Fehler", f"Senden fehlgeschlagen:\n{m}", parent=dlg))
|
||
|
||
threading.Thread(target=_w, daemon=True).start()
|
||
except Exception as e:
|
||
messagebox.showerror("Fehler", str(e), parent=dlg)
|
||
|
||
# --- Aktionsleiste: Werkzeuge links, Senden rechts (Browser-nah) ---
|
||
_BTN_F = ("Segoe UI", 9)
|
||
_BTN_BG = "#dce7f2"
|
||
_BTN_FG = "#1a4d6d"
|
||
_BTN_PAD = dict(padx=10, pady=5, relief="flat", cursor="hand2", bd=0)
|
||
|
||
_tool_left = tk.Frame(bottom_bar, bg=_dlg_bg)
|
||
_tool_left.pack(side="left", fill="x", expand=True)
|
||
|
||
tk.Button(
|
||
_tool_left,
|
||
text="\u2611 Aufgabe",
|
||
font=_BTN_F,
|
||
bg=_BTN_BG,
|
||
fg=_BTN_FG,
|
||
activebackground="#cddff0",
|
||
**_BTN_PAD,
|
||
command=_send_task,
|
||
).pack(side="left", padx=(0, 4))
|
||
|
||
tk.Button(
|
||
_tool_left,
|
||
text="Verlauf",
|
||
font=_BTN_F,
|
||
bg=_BTN_BG,
|
||
fg=_BTN_FG,
|
||
activebackground="#cddff0",
|
||
**_BTN_PAD,
|
||
command=_open_chat_verlauf,
|
||
).pack(side="left", padx=(0, 4))
|
||
|
||
def _start_pick_reply():
|
||
_do_smart_pick(
|
||
_reply_pick_btn,
|
||
lambda t: reply_entry.insert("end", t),
|
||
)
|
||
|
||
_reply_pick_btn = tk.Button(
|
||
_tool_left,
|
||
text="\U0001f58c Pinsel",
|
||
font=_BTN_F,
|
||
bg=_BTN_BG,
|
||
fg=_BTN_FG,
|
||
activebackground="#cddff0",
|
||
**_BTN_PAD,
|
||
command=_start_pick_reply,
|
||
)
|
||
_reply_pick_btn.pack(side="left", padx=(0, 4))
|
||
add_tooltip(
|
||
_reply_pick_btn,
|
||
"Text in anderer App markieren — wird uebernommen",
|
||
)
|
||
|
||
_tool_right = tk.Frame(bottom_bar, bg=_dlg_bg)
|
||
_tool_right.pack(side="right")
|
||
|
||
tk.Button(
|
||
_tool_right,
|
||
text="Schliessen",
|
||
font=_BTN_F,
|
||
bg=_BTN_BG,
|
||
fg=_BTN_FG,
|
||
activebackground="#cddff0",
|
||
**_BTN_PAD,
|
||
command=_on_close,
|
||
).pack(side="right", padx=(14, 0))
|
||
|
||
tk.Button(
|
||
_tool_right,
|
||
text="Senden",
|
||
font=("Segoe UI", 10, "bold"),
|
||
bg="#5B8DB3",
|
||
fg="white",
|
||
activebackground="#4a79a8",
|
||
activeforeground="white",
|
||
relief="flat",
|
||
cursor="hand2",
|
||
bd=0,
|
||
padx=22,
|
||
pady=8,
|
||
command=do_send,
|
||
).pack(side="right", padx=(8, 6))
|
||
|
||
_dik_btn_kom = tk.Button(
|
||
_tool_right,
|
||
text="\u23fa Diktieren",
|
||
font=_BTN_F,
|
||
bg="#e8dff5",
|
||
fg="#3c2560",
|
||
activebackground="#ddd0f0",
|
||
**_BTN_PAD,
|
||
)
|
||
_dik_btn_kom.configure(
|
||
command=lambda b=_dik_btn_kom: _toggle_dik(b, field_widgets.get("kom")),
|
||
)
|
||
_dik_btn_kom.pack(side="right", padx=(0, 4))
|
||
|
||
bottom_bar.pack(side="bottom", fill="x", padx=12, pady=(4, 12))
|
||
composer_strip.pack(side="bottom", fill="both", padx=12, pady=(10, 2))
|
||
|
||
def _diktat_into_widget(self, parent_win, text_widget, status_callback=None):
|
||
"""Öffnet kleines Aufnahme-Fenster, transkribiert und fügt Text an Cursorposition in text_widget ein."""
|
||
if not self.ensure_ready():
|
||
return
|
||
if not self._check_ai_consent():
|
||
return
|
||
rec_win = tk.Toplevel(parent_win)
|
||
rec_win.title("Diktat an Cursorposition einfügen")
|
||
rec_win.transient(parent_win)
|
||
rec_win.geometry("420x150")
|
||
rec_win.configure(bg="#B9ECFA")
|
||
rec_win.minsize(350, 130)
|
||
rec_win.attributes("-topmost", True)
|
||
self._register_window(rec_win)
|
||
add_resize_grip(rec_win, 350, 130)
|
||
apply_initial_scaling_to_window(rec_win)
|
||
rf = ttk.Frame(rec_win, padding=16)
|
||
rf.pack(fill="both", expand=True)
|
||
status_var = tk.StringVar(value="Bereit. Cursor im Text setzen, dann Aufnahme starten.")
|
||
ttk.Label(rf, textvariable=status_var).pack(pady=(0, 4))
|
||
autocopy_var = tk.BooleanVar(value=self._is_autocopy_enabled())
|
||
cb_autocopy = ttk.Checkbutton(rf, text="Autocopy nach Diktat", variable=autocopy_var)
|
||
cb_autocopy.pack(anchor="w", pady=(0, 8))
|
||
|
||
def _save_autocopy_from_cb():
|
||
self._autotext_data["autocopy_after_diktat"] = bool(autocopy_var.get())
|
||
save_autotext(self._autotext_data)
|
||
|
||
cb_autocopy.configure(command=_save_autocopy_from_cb)
|
||
|
||
diktat_rec = [None]
|
||
is_rec = [False]
|
||
|
||
def toggle_rec():
|
||
if not diktat_rec[0]:
|
||
diktat_rec[0] = AudioRecorder()
|
||
rec = diktat_rec[0]
|
||
if not is_rec[0]:
|
||
if not self._ensure_microphone_ready():
|
||
return
|
||
try:
|
||
rec.start()
|
||
is_rec[0] = True
|
||
btn_rec.configure(text="⏹ Stoppen")
|
||
status_var.set("Aufnahme läuft")
|
||
except Exception as e:
|
||
messagebox.showwarning("Aufnahme nicht möglich", str(e))
|
||
rec_win.destroy()
|
||
else:
|
||
is_rec[0] = False
|
||
btn_rec.configure(text="⏺ Aufnahme starten")
|
||
status_var.set("Transkribiere")
|
||
|
||
def worker():
|
||
safe_path = None
|
||
try:
|
||
wav_path = rec.stop_and_save_wav()
|
||
try:
|
||
safe_path = persist_audio_safe(wav_path)
|
||
except Exception:
|
||
safe_path = wav_path
|
||
transcript_text = self.transcribe_wav(safe_path)
|
||
transcript_text = self._diktat_apply_punctuation(transcript_text)
|
||
if safe_path and safe_path != wav_path:
|
||
try:
|
||
os.remove(wav_path)
|
||
except Exception:
|
||
pass
|
||
self.after(0, lambda: _insert_done(transcript_text))
|
||
except Exception as e:
|
||
saved_at = safe_path or (wav_path if 'wav_path' in dir() else None)
|
||
err_msg = (
|
||
f"{e}\n\nAudio gesichert unter:\n{saved_at}\n"
|
||
"Nutzen Sie 'Audio importieren' zum erneuten Versuch."
|
||
if saved_at and os.path.isfile(str(saved_at))
|
||
else str(e)
|
||
)
|
||
self.after(0, lambda msg=err_msg: messagebox.showerror("Fehler", msg))
|
||
self.after(0, lambda: rec_win.destroy())
|
||
|
||
def _insert_done(text):
|
||
diktat_rec[0] = None
|
||
if text:
|
||
idx = text_widget.index(tk.INSERT)
|
||
text_widget.insert(idx, text)
|
||
if autocopy_var.get():
|
||
if not _win_clipboard_set(text):
|
||
try:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(text))
|
||
except Exception:
|
||
pass
|
||
if status_callback:
|
||
status_callback("Diktat an Cursorposition eingefügt.")
|
||
status_var.set("Fertig.")
|
||
rec_win.destroy()
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
btn_rec = RoundedButton(
|
||
rf, "⏺ Aufnahme starten", command=toggle_rec,
|
||
width=160, height=32, canvas_bg="#B9ECFA",
|
||
)
|
||
btn_rec.pack()
|
||
|
||
def _check_microphone_at_startup(self):
|
||
"""Sanfte Mikrofon-Prüfung beim Start – warnt nur, blockiert nicht."""
|
||
try:
|
||
mic = check_microphone()
|
||
if mic["ok"]:
|
||
self.set_status(f"Mikrofon: {mic['device_name']}")
|
||
else:
|
||
self.set_status("Kein Mikrofon – Diktat/Aufnahme nicht verfügbar")
|
||
self.after(500, lambda: messagebox.showwarning(
|
||
"Mikrofon nicht verfügbar",
|
||
mic["message"] + "\n\n"
|
||
"AZA ist weiterhin nutzbar.\n"
|
||
"Aufnahme und Diktat sind jedoch nicht möglich,\n"
|
||
"bis ein Mikrofon angeschlossen wird.",
|
||
))
|
||
except Exception:
|
||
pass
|
||
|
||
def _ensure_microphone_ready(self) -> bool:
|
||
"""Prüft Mikrofon vor Aufnahme. Zeigt Dialog bei Fehler. Gibt True/False zurück."""
|
||
try:
|
||
mic = check_microphone()
|
||
if mic["ok"]:
|
||
return True
|
||
invalidate_mic_cache()
|
||
mic = check_microphone(force=True)
|
||
if mic["ok"]:
|
||
return True
|
||
messagebox.showwarning(
|
||
"Mikrofon nicht verfügbar",
|
||
mic["message"],
|
||
)
|
||
except Exception:
|
||
messagebox.showwarning(
|
||
"Mikrofon nicht verfügbar",
|
||
"Mikrofon-Prüfung fehlgeschlagen.\n\n"
|
||
"Bitte prüfen Sie, ob ein Mikrofon angeschlossen ist:\n"
|
||
" Einstellungen > System > Sound > Eingabe",
|
||
)
|
||
return False
|
||
|
||
def ensure_ready(self):
|
||
if self.client or _has_remote_backend():
|
||
return True
|
||
messagebox.showinfo(
|
||
"KI-Verbindung",
|
||
"Die KI-Verbindung ist noch nicht eingerichtet.\n\n"
|
||
"Bitte richten Sie den Zugang über den Startmenü-Eintrag\n"
|
||
"\"AZA \u2013 OpenAI Schlüssel einrichten\" ein\n"
|
||
"und starten Sie AZA anschliessend neu.",
|
||
)
|
||
return False
|
||
|
||
def toggle_record(self):
|
||
if not self.ensure_ready():
|
||
return
|
||
if not self._check_ai_consent():
|
||
return
|
||
|
||
if not self.is_recording:
|
||
if not self._ensure_microphone_ready():
|
||
return
|
||
self._new_session()
|
||
try:
|
||
self.recorder.start()
|
||
self.is_recording = True
|
||
self._recording_mode = "new"
|
||
self.btn_record.configure(text="⏹ Stopp")
|
||
self.btn_record_append.configure(text="⏺ Korrigieren")
|
||
mini_start = getattr(self, "_mini_btn_start", None)
|
||
if mini_start:
|
||
try:
|
||
if mini_start.winfo_exists():
|
||
mini_start.configure(text="⏹ Stopp")
|
||
except Exception:
|
||
pass
|
||
mini_korr = getattr(self, "_mini_btn_korrigieren", None)
|
||
if mini_korr:
|
||
try:
|
||
if mini_korr.winfo_exists():
|
||
mini_korr.configure(text="⏺ Korrigieren")
|
||
except Exception:
|
||
pass
|
||
self.set_status("Aufnahme läuft")
|
||
except Exception as e:
|
||
messagebox.showwarning("Aufnahme nicht möglich", str(e))
|
||
self.set_status("Bereit.")
|
||
else:
|
||
self._stop_and_process_recording()
|
||
|
||
def _toggle_record_append(self):
|
||
"""Aufnahme korrigieren: ergänzt die bestehende KG, ohne sie zu löschen."""
|
||
if not self.ensure_ready():
|
||
return
|
||
if not self._check_ai_consent():
|
||
return
|
||
|
||
if not self.is_recording:
|
||
if not self._ensure_microphone_ready():
|
||
return
|
||
try:
|
||
self.recorder.start()
|
||
self.is_recording = True
|
||
self._recording_mode = "append"
|
||
self.btn_record_append.configure(text="⏹ Stopp")
|
||
self.btn_record.configure(text="⏺ Start")
|
||
mini_korr = getattr(self, "_mini_btn_korrigieren", None)
|
||
if mini_korr:
|
||
try:
|
||
if mini_korr.winfo_exists():
|
||
mini_korr.configure(text="⏹ Stopp")
|
||
except Exception:
|
||
pass
|
||
mini_start = getattr(self, "_mini_btn_start", None)
|
||
if mini_start:
|
||
try:
|
||
if mini_start.winfo_exists():
|
||
mini_start.configure(text="⏺ Start")
|
||
except Exception:
|
||
pass
|
||
self.set_status("Korrektur-Aufnahme läuft (sprich jetzt)")
|
||
except Exception as e:
|
||
messagebox.showwarning("Aufnahme nicht möglich", str(e))
|
||
self.set_status("Bereit.")
|
||
else:
|
||
self._stop_and_process_recording()
|
||
|
||
def _stop_and_process_recording(self):
|
||
"""Stoppt die Aufnahme und verarbeitet sie (neu oder Korrektur)."""
|
||
self.is_recording = False
|
||
mode = getattr(self, "_recording_mode", "new")
|
||
self.btn_record.configure(text="⏺ Start")
|
||
self.btn_record_append.configure(text="⏺ Korrigieren")
|
||
mini_start = getattr(self, "_mini_btn_start", None)
|
||
if mini_start:
|
||
try:
|
||
if mini_start.winfo_exists():
|
||
mini_start.configure(text="⏺ Start")
|
||
except Exception:
|
||
pass
|
||
mini = getattr(self, "_mini_btn_korrigieren", None)
|
||
if mini:
|
||
try:
|
||
if mini.winfo_exists():
|
||
mini.configure(text="⏺ Korrigieren")
|
||
except Exception:
|
||
pass
|
||
self.set_status("Stoppe Aufnahme – Audio wird gesichert…")
|
||
|
||
existing_transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
existing_kg = self.txt_output.get("1.0", "end").strip()
|
||
|
||
def worker():
|
||
safe_audio_path = None
|
||
temp_path = None
|
||
|
||
def _safe_after(fn):
|
||
try:
|
||
if self.winfo_exists():
|
||
self.after(0, fn)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
temp_path = self.recorder.stop_and_save_wav()
|
||
self.last_wav_path = temp_path
|
||
|
||
try:
|
||
safe_audio_path = persist_audio_safe(temp_path)
|
||
self._last_safe_audio_path = safe_audio_path
|
||
_safe_after(lambda: self.set_status(
|
||
f"Audio gesichert: {os.path.basename(safe_audio_path)} – Transkription läuft…"
|
||
))
|
||
except Exception as cp_err:
|
||
safe_audio_path = temp_path
|
||
self._last_safe_audio_path = temp_path
|
||
try:
|
||
self._debug_log(f"AUDIO_PERSIST_WARN error={repr(cp_err)}")
|
||
except Exception:
|
||
pass
|
||
|
||
new_transcript = self.transcribe_wav(safe_audio_path)
|
||
_safe_after(lambda: self._next_phase("kg"))
|
||
|
||
if not new_transcript or not new_transcript.strip():
|
||
raise RuntimeError("Transkription ergab keinen Text.")
|
||
|
||
if mode == "append" and existing_transcript:
|
||
full_transcript = existing_transcript + "\n\n" + new_transcript
|
||
if existing_kg:
|
||
kg = strip_kg_warnings(self.merge_kg(existing_kg, full_transcript))
|
||
else:
|
||
kg = strip_kg_warnings(self.summarize_text(full_transcript))
|
||
else:
|
||
full_transcript = new_transcript
|
||
kg = strip_kg_warnings(self.summarize_text(full_transcript))
|
||
|
||
_safe_after(lambda: self._fill_transcript(full_transcript))
|
||
_safe_after(lambda: self._fill_kg_and_finish(kg))
|
||
|
||
self._cleanup_temp_audio(temp_path, safe_audio_path)
|
||
|
||
except Exception as e:
|
||
_safe_after(lambda: self._stop_timer())
|
||
try:
|
||
self._debug_log(f"RECORDING_WORKER_ERROR error={repr(e)}\n{traceback.format_exc()}")
|
||
except Exception:
|
||
pass
|
||
|
||
saved_at = safe_audio_path or temp_path
|
||
if saved_at and os.path.isfile(saved_at):
|
||
err_msg = (
|
||
f"Transkription fehlgeschlagen:\n{e}\n\n"
|
||
f"Ihre Aufnahme ist sicher gespeichert unter:\n"
|
||
f"{saved_at}\n\n"
|
||
f"Sie können die Aufnahme jederzeit über\n"
|
||
f"'Audio importieren' erneut transkribieren lassen."
|
||
)
|
||
_safe_after(lambda: self.set_status(
|
||
f"Fehler – Audio gesichert: {saved_at}"
|
||
))
|
||
else:
|
||
err_msg = f"Transkription fehlgeschlagen:\n{e}"
|
||
_safe_after(lambda: self.set_status(f"Fehler: {e}"))
|
||
|
||
_safe_after(lambda msg=err_msg: messagebox.showerror(
|
||
"Transkription fehlgeschlagen", msg
|
||
))
|
||
|
||
self._start_timer("transcribe")
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _cleanup_temp_audio(self, temp_path, safe_path):
|
||
"""Löscht nur die temporäre Datei, nicht das sichere Backup."""
|
||
if temp_path and safe_path and temp_path != safe_path:
|
||
try:
|
||
if os.path.isfile(temp_path):
|
||
os.remove(temp_path)
|
||
except Exception:
|
||
pass
|
||
|
||
_WHISPER_MEDICAL_PROMPT = (
|
||
"Transkribiere ausschliesslich den gesprochenen Inhalt woertlich auf Deutsch. "
|
||
"Antworte niemals auf Fragen, gib keine Erklaerungen, keine Zusammenfassung, keine Interpretation. "
|
||
"Wenn ein Wort unklar ist, schreibe die wahrscheinlichste gehoerte Form. "
|
||
"Medizinische Dokumentation auf Deutsch. "
|
||
"Capillitium, Fotodynamische Therapie, PDT, Basalzellkarzinom, Plattenepithelkarzinom, "
|
||
"Spinaliom, Spinaliom der Haut, Spinalzellkarzinom, "
|
||
"Melanom, Exzision, Biopsie, Kryotherapie, Kürettage, Histologie, Dermatoskopie, "
|
||
# Nävi / Muttermale
|
||
"Nävus, Nävi, Naevus, Naevi, Nävuszellnävus, dysplastischer Nävus, "
|
||
"Compound-Nävus, junktionaler Nävus, dermaler Nävus, Spitz-Nävus, "
|
||
# Effloreszenzen
|
||
"Erythem, Papel, Pustel, Makula, Plaque, Nodulus, Nodus, "
|
||
"Vesikel, Bulla, Erosion, Ulkus, Rhagade, Kruste, Squama, "
|
||
"Effloreszenzen, Lichenifikation, Exkoriation, "
|
||
# Häufige Diagnosen Dermatologie
|
||
"seborrhoische Keratose, Fibrom, Lipom, Atherom, Epidermoidzyste, "
|
||
"Verruca vulgaris, Verrucae, Kondylome, Molluscum contagiosum, "
|
||
"Hämangiom, Angiom, Keloid, hypertrophe Narbe, "
|
||
"Tinea, Mykose, Onychomykose, Herpes simplex, Herpes zoster, "
|
||
"Erysipel, Impetigo, Abszess, Phlegmone, Skabies, "
|
||
"Pemphigus, Pemphigoid, Lichen ruber, Lichen sclerosus, "
|
||
"Vitiligo, Pruritus, Prurigo, Mykosis fungoides, "
|
||
# Eingriffe / Befunde
|
||
"Shave-Biopsie, Stanzbiopsie, Inzisionsbiopsie, "
|
||
"Breslow-Dicke, Clark-Level, Sentinel-Lymphknoten, "
|
||
"Auflichtmikroskopie, Phototherapie, UVB, PUVA, "
|
||
# Allgemeinmedizin
|
||
"Anamnese, Diagnose, Therapie, Procedere, subjektiv, objektiv, "
|
||
"Abdomen, Thorax, Extremitäten, zervikal, lumbal, thorakal, sakral, "
|
||
"Sonographie, Röntgen, MRI, CT, EKG, Laborwerte, Blutbild, "
|
||
"Hypertonie, Diabetes mellitus, Hypercholesterinämie, Hypothyreose, "
|
||
"Antikoagulation, Thrombozytenaggregationshemmer, NSAR, ACE-Hemmer, "
|
||
"Immunsuppression, Kortikosteroide, Biologika, Methotrexat, "
|
||
"Psoriasis, Ekzem, Dermatitis, Urtikaria, Alopezie, Akne, Rosazea, "
|
||
"Aktinische Keratose, Morbus Bowen, Lentigo maligna, "
|
||
"Januar 2026, Februar 2026, März 2026, April 2026, Mai 2026, "
|
||
"Status nach, Z.n., s/p, i.v., p.o., s.c., "
|
||
"ICD-10, SOAP, Krankengeschichte, Kostengutsprache, Arztbrief."
|
||
)
|
||
|
||
_WHISPER_PROMPT_PREFIX = "Medizinische Dokumentation auf Deutsch"
|
||
_WHISPER_GENERAL_PROMPT = (
|
||
"Transkribiere ausschliesslich den gesprochenen Inhalt woertlich auf Deutsch. "
|
||
"Antworte niemals auf Fragen, gib keine Erklaerungen, keine Zusammenfassung, keine Interpretation. "
|
||
"Allgemeines Diktat auf Deutsch mit sinnvoller Zeichensetzung."
|
||
)
|
||
_WHISPER_GENERAL_PROMPT_PREFIX = "Allgemeines Diktat auf Deutsch"
|
||
|
||
def _build_transcribe_domain_toggle(self, parent):
|
||
"""Zwei exklusive Häkchen: Medizinisch vs. Allgemein."""
|
||
domain = str((self._autotext_data or {}).get("transcribe_domain", "medical")).strip().lower()
|
||
if domain not in ("medical", "general"):
|
||
domain = "medical"
|
||
self._transcribe_medical_var = tk.BooleanVar(value=(domain == "medical"))
|
||
self._transcribe_general_var = tk.BooleanVar(value=(domain == "general"))
|
||
self._transcribe_toggle_guard = False
|
||
|
||
box = ttk.Frame(parent)
|
||
box.pack(side="left", padx=(10, 0), anchor="n")
|
||
ttk.Label(box, text="Diktat:").pack(side="left", padx=(0, 4))
|
||
|
||
def _persist(domain_value: str):
|
||
try:
|
||
self._autotext_data["transcribe_domain"] = domain_value
|
||
save_autotext(self._autotext_data)
|
||
except Exception:
|
||
pass
|
||
|
||
def _set_domain(domain_value: str):
|
||
if getattr(self, "_transcribe_toggle_guard", False):
|
||
return
|
||
self._transcribe_toggle_guard = True
|
||
try:
|
||
if domain_value == "general":
|
||
self._transcribe_medical_var.set(False)
|
||
self._transcribe_general_var.set(True)
|
||
else:
|
||
self._transcribe_medical_var.set(True)
|
||
self._transcribe_general_var.set(False)
|
||
_persist(domain_value)
|
||
finally:
|
||
self._transcribe_toggle_guard = False
|
||
|
||
def _on_medical_toggle():
|
||
if self._transcribe_medical_var.get():
|
||
_set_domain("medical")
|
||
elif not self._transcribe_general_var.get():
|
||
_set_domain("medical")
|
||
|
||
def _on_general_toggle():
|
||
if self._transcribe_general_var.get():
|
||
_set_domain("general")
|
||
elif not self._transcribe_medical_var.get():
|
||
_set_domain("medical")
|
||
|
||
tk.Checkbutton(
|
||
box, text="Medizin", variable=self._transcribe_medical_var,
|
||
command=_on_medical_toggle, bg="#B9ECFA", fg="#1a4d6d",
|
||
activebackground="#B9ECFA", selectcolor="#E8F4FA",
|
||
).pack(side="left", padx=(0, 4))
|
||
tk.Checkbutton(
|
||
box, text="Allgemein", variable=self._transcribe_general_var,
|
||
command=_on_general_toggle, bg="#B9ECFA", fg="#1a4d6d",
|
||
activebackground="#B9ECFA", selectcolor="#E8F4FA",
|
||
).pack(side="left")
|
||
|
||
# Persistiert Normalisierung beim Start.
|
||
_persist("general" if domain == "general" else "medical")
|
||
|
||
def _set_transcribe_domain(self, domain_value: str):
|
||
domain = "general" if str(domain_value).strip().lower() == "general" else "medical"
|
||
if getattr(self, "_transcribe_toggle_guard", False):
|
||
return
|
||
self._transcribe_toggle_guard = True
|
||
try:
|
||
if hasattr(self, "_transcribe_medical_var"):
|
||
self._transcribe_medical_var.set(domain == "medical")
|
||
if hasattr(self, "_transcribe_general_var"):
|
||
self._transcribe_general_var.set(domain == "general")
|
||
try:
|
||
self._autotext_data["transcribe_domain"] = domain
|
||
save_autotext(self._autotext_data)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
self._transcribe_toggle_guard = False
|
||
|
||
def _get_transcribe_domain(self) -> str:
|
||
try:
|
||
if hasattr(self, "_transcribe_general_var") and bool(self._transcribe_general_var.get()):
|
||
return "general"
|
||
if hasattr(self, "_transcribe_medical_var") and bool(self._transcribe_medical_var.get()):
|
||
return "medical"
|
||
except Exception:
|
||
pass
|
||
domain = str((self._autotext_data or {}).get("transcribe_domain", "medical")).strip().lower()
|
||
return "general" if domain == "general" else "medical"
|
||
|
||
def _get_transcribe_prompt(self) -> str:
|
||
return self._WHISPER_GENERAL_PROMPT if self._get_transcribe_domain() == "general" else self._WHISPER_MEDICAL_PROMPT
|
||
|
||
def _transcribe_local(self, wav_path: str) -> str:
|
||
"""Lokale OpenAI-Transkription."""
|
||
with open(wav_path, "rb") as f:
|
||
is_gpt_transcribe = "gpt-" in TRANSCRIBE_MODEL
|
||
params = dict(model=TRANSCRIBE_MODEL, file=f, language="de")
|
||
selected_prompt = self._get_transcribe_prompt()
|
||
if is_gpt_transcribe:
|
||
params["prompt"] = selected_prompt
|
||
else:
|
||
params["prompt"] = selected_prompt
|
||
params["temperature"] = 0.0
|
||
resp = self.client.audio.transcriptions.create(**params)
|
||
text = getattr(resp, "text", "")
|
||
if text is None:
|
||
text = ""
|
||
stripped = text.strip()
|
||
if stripped.startswith(self._WHISPER_PROMPT_PREFIX) or stripped.startswith(self._WHISPER_GENERAL_PROMPT_PREFIX):
|
||
text = ""
|
||
estimated_tokens = len(text) // 4
|
||
add_token_usage(estimated_tokens)
|
||
self.after(0, self.update_token_display)
|
||
return text
|
||
|
||
_TRANSCRIBE_BACKEND_TIMEOUT_BASE = (5, 300)
|
||
_TRANSCRIBE_MAX_RETRIES = 3
|
||
|
||
@staticmethod
|
||
def _calc_transcribe_timeout(audio_path: str) -> tuple:
|
||
"""Dynamischer Timeout: längere Dateien bekommen mehr Zeit."""
|
||
try:
|
||
size_mb = os.path.getsize(audio_path) / (1024 * 1024)
|
||
except Exception:
|
||
size_mb = 0
|
||
extra_seconds = int(size_mb / 10) * 120
|
||
read_timeout = min(300 + extra_seconds, 900)
|
||
return (10, read_timeout)
|
||
|
||
@staticmethod
|
||
def _read_backend_value_file(filename: str):
|
||
def _read_at(p: str):
|
||
try:
|
||
with open(p, "r", encoding="utf-8-sig") as f:
|
||
v = (f.read() or "").replace("\ufeff", "").strip(" \t\r\n")
|
||
return v if v else None
|
||
except Exception:
|
||
return None
|
||
|
||
search_dirs: list[str] = []
|
||
if getattr(sys, "frozen", False):
|
||
_exe = os.path.dirname(os.path.abspath(sys.executable))
|
||
search_dirs.append(_exe)
|
||
search_dirs.append(os.path.join(_exe, "_internal"))
|
||
_src = os.path.dirname(os.path.abspath(__file__))
|
||
search_dirs.append(_src)
|
||
search_dirs.append(os.path.join(_src, "_internal"))
|
||
search_dirs.append(os.getcwd())
|
||
|
||
seen: set[str] = set()
|
||
for base in search_dirs:
|
||
if not base or base in seen:
|
||
continue
|
||
seen.add(base)
|
||
p = os.path.join(base, filename)
|
||
if os.path.isfile(p):
|
||
v = _read_at(p)
|
||
if v:
|
||
return v
|
||
return None
|
||
|
||
@staticmethod
|
||
def _clean_backend_value(value: str):
|
||
if value is None:
|
||
return None
|
||
v = str(value).replace("\ufeff", "").strip(" \t\r\n")
|
||
return v if v else None
|
||
|
||
def get_backend_url(self):
|
||
url = self._clean_backend_value(os.getenv("MEDWORK_BACKEND_URL"))
|
||
if url:
|
||
return url.rstrip("/")
|
||
url = self._read_backend_value_file("backend_url.txt")
|
||
if url:
|
||
return url.rstrip("/")
|
||
raise RuntimeError("Backend-URL fehlt: MEDWORK_BACKEND_URL oder backend_url.txt setzen.")
|
||
|
||
def get_backend_token(self):
|
||
# backend_token.txt hat Vorrang (vermeidet Konflikte mit Umgebungsvariablen)
|
||
token = self._read_backend_value_file("backend_token.txt")
|
||
if token:
|
||
return token
|
||
tokens_env = os.getenv("MEDWORK_API_TOKENS")
|
||
if tokens_env and tokens_env.strip():
|
||
token = self._clean_backend_value(tokens_env.split(",")[0].strip())
|
||
else:
|
||
token = self._clean_backend_value(os.getenv("MEDWORK_API_TOKEN"))
|
||
if token:
|
||
return token
|
||
raise RuntimeError("Backend-Token fehlt: backend_token.txt oder MEDWORK_API_TOKEN setzen.")
|
||
|
||
def get_practice_id(self):
|
||
"""Gibt die gespeicherte practice_id zurueck (oder leer)."""
|
||
return (self._user_profile.get("practice_id") or "").strip()
|
||
|
||
def _empfang_self_display_name(self) -> str:
|
||
"""Anzeigename fuer Empfang (Header me=, absender-Kern): Konto-Name vom Server bevorzugen."""
|
||
dn = (self._user_profile.get("empfang_display_name") or "").strip()
|
||
if dn:
|
||
return dn
|
||
return (self._user_profile.get("name") or "").strip()
|
||
|
||
def _empfang_self_user_id(self) -> str:
|
||
"""Server-user_id fuer dieselbe Conversation-Aufloesung wie der Browser."""
|
||
return (self._user_profile.get("empfang_user_id") or "").strip()
|
||
|
||
def _empfang_self_user_id_resolve_now(self, users_full: Optional[list] = None) -> str:
|
||
"""Wenn lokal noch keine empfang_user_id existiert: aus users_full per
|
||
normalisiertem Anzeigename ableiten und im Profil cachen.
|
||
Diese Methode ruft das Backend NICHT noch einmal auf, wenn users_full
|
||
uebergeben wird; sonst wird /empfang/users einmal synchron geholt.
|
||
"""
|
||
cur = self._empfang_self_user_id()
|
||
if cur:
|
||
return cur
|
||
my_dn = self._empfang_self_display_name()
|
||
if not my_dn:
|
||
return ""
|
||
full = users_full
|
||
if full is None:
|
||
try:
|
||
bu = self.get_backend_url()
|
||
r = requests.get(
|
||
f"{bu}/empfang/users",
|
||
headers=self._empfang_headers(),
|
||
timeout=5,
|
||
)
|
||
if r.status_code == 200:
|
||
full = (r.json() or {}).get("users_full") or []
|
||
except Exception:
|
||
full = []
|
||
if not isinstance(full, list):
|
||
return ""
|
||
|
||
target = _empfang_identity_key(my_dn)
|
||
for u in full:
|
||
if not isinstance(u, dict):
|
||
continue
|
||
if _empfang_identity_key(u.get("display_name") or "") == target:
|
||
uid = str(u.get("user_id") or "").strip()
|
||
if uid:
|
||
self._user_profile["empfang_user_id"] = uid
|
||
try:
|
||
save_user_profile(self._user_profile)
|
||
except Exception:
|
||
pass
|
||
return uid
|
||
return ""
|
||
|
||
def _invalidate_empfang_prefs_for_new_practice(self) -> None:
|
||
"""Nach Praxiswechsel lokale Empfang-Auswahl zuruecksetzen (keine falschen Empfaenger/Chats)."""
|
||
prefs = self._autotext_data.get("empfang_prefs")
|
||
if not isinstance(prefs, dict):
|
||
prefs = {}
|
||
prefs.pop("rcpt_selected_names", None)
|
||
prefs["rcpt_broadcast"] = True
|
||
self._autotext_data["empfang_prefs"] = prefs
|
||
save_autotext(self._autotext_data)
|
||
try:
|
||
self._empfang_last_seen_ids = set()
|
||
except Exception:
|
||
pass
|
||
|
||
def _empfang_headers(self) -> dict:
|
||
"""Standard-Headers fuer alle Empfang-Requests: API-Token + Practice-Id."""
|
||
h = {"X-API-Token": self.get_backend_token()}
|
||
pid = self.get_practice_id()
|
||
if pid:
|
||
h["X-Practice-Id"] = pid
|
||
return h
|
||
|
||
def _open_empfang_chat_history(self):
|
||
"""Eigenes Fenster: Chatverlauf (Allgemein / Direkt / Gruppe), getrennt vom Live-Sendefenster."""
|
||
prev = getattr(self, "_empfang_hist_win", None)
|
||
if prev is not None:
|
||
try:
|
||
if prev.winfo_exists():
|
||
prev.lift()
|
||
prev.focus_force()
|
||
return
|
||
except Exception:
|
||
pass
|
||
|
||
win = tk.Toplevel(self)
|
||
self._empfang_hist_win = win
|
||
win.title("Chatverlauf")
|
||
win.geometry("920x560")
|
||
win.minsize(640, 400)
|
||
win.configure(bg="#f0f4f8")
|
||
self._register_window(win)
|
||
|
||
def _hist_close():
|
||
try:
|
||
self._empfang_hist_win = None
|
||
except Exception:
|
||
pass
|
||
try:
|
||
win.destroy()
|
||
except Exception:
|
||
pass
|
||
|
||
win.protocol("WM_DELETE_WINDOW", _hist_close)
|
||
|
||
me_disp = self._empfang_self_display_name()
|
||
|
||
top = tk.Frame(win, bg="#5B8DB3")
|
||
top.pack(fill="x")
|
||
tk.Label(
|
||
top, text="Chatverlauf", font=("Segoe UI", 11, "bold"),
|
||
bg="#5B8DB3", fg="white",
|
||
).pack(side="left", padx=12, pady=8)
|
||
|
||
def _hn(s: str) -> str:
|
||
return _empfang_identity_key(s)
|
||
|
||
def _sender_core(absender: str) -> str:
|
||
return (absender or "").split("(", 1)[0].strip()
|
||
|
||
def _group_key_from_extras(extras: dict) -> str:
|
||
if not isinstance(extras, dict):
|
||
return ""
|
||
rlist = extras.get("recipients")
|
||
if isinstance(rlist, list) and len(rlist) >= 2:
|
||
parts = sorted({_hn(str(x)) for x in rlist if str(x).strip()})
|
||
return "|".join(parts) if parts else ""
|
||
rcpt = (extras.get("recipient") or "").strip()
|
||
if "," in rcpt:
|
||
parts = sorted({_hn(p) for p in rcpt.split(",") if p.strip()})
|
||
if len(parts) >= 2:
|
||
return "|".join(parts)
|
||
return ""
|
||
|
||
def _tid(m: dict) -> str:
|
||
return str(m.get("thread_id") or m.get("id") or "")
|
||
|
||
def _thread_root(tid: str, arr: list) -> dict:
|
||
for x in arr:
|
||
if str(x.get("id")) == tid:
|
||
return x
|
||
return min(arr, key=lambda x: (x.get("empfangen") or x.get("zeitstempel") or ""))
|
||
|
||
def _bucket_for_thread(tid: str, arr: list) -> tuple[str, str]:
|
||
root = _thread_root(tid, arr)
|
||
ex = root.get("extras") or {}
|
||
gk = _group_key_from_extras(ex)
|
||
if gk:
|
||
rlist = ex.get("recipients")
|
||
if isinstance(rlist, list) and len(rlist) >= 2:
|
||
disp = ", ".join(sorted(str(x).strip() for x in rlist if str(x).strip()))
|
||
else:
|
||
rc = (ex.get("recipient") or "").strip()
|
||
disp = ", ".join(sorted(p.strip() for p in rc.split(",") if p.strip()))
|
||
label = f"Gruppe ({disp})" if disp else "Gruppe"
|
||
return ("group:" + gk, label)
|
||
|
||
rcpt = (ex.get("recipient") or "").strip()
|
||
if _hn(rcpt) in ("", "alle"):
|
||
return ("allgemein", "Allgemein")
|
||
|
||
snd = _sender_core(root.get("absender", ""))
|
||
if _hn(snd) == _hn(me_disp):
|
||
peer = rcpt
|
||
else:
|
||
peer = snd
|
||
label = f"Direkt: {peer}"
|
||
return ("dm:" + _hn(peer), label)
|
||
|
||
def _partition_sessions(messages: list) -> list:
|
||
by_tid: dict = {}
|
||
for m in messages:
|
||
tid = _tid(m)
|
||
by_tid.setdefault(tid, []).append(m)
|
||
buckets: dict = {}
|
||
for tid, arr in by_tid.items():
|
||
if not arr:
|
||
continue
|
||
bkey, label = _bucket_for_thread(tid, arr)
|
||
if bkey not in buckets:
|
||
buckets[bkey] = {"key": bkey, "label": label, "msgs": []}
|
||
buckets[bkey]["msgs"].extend(arr)
|
||
for b in buckets.values():
|
||
uid = {}
|
||
for m in b["msgs"]:
|
||
mid = m.get("id")
|
||
if mid:
|
||
uid[mid] = m
|
||
b["msgs"] = sorted(
|
||
uid.values(),
|
||
key=lambda x: (x.get("empfangen") or x.get("zeitstempel") or ""),
|
||
)
|
||
sess = []
|
||
if "allgemein" in buckets:
|
||
sess.append(buckets["allgemein"])
|
||
for k in sorted(x for x in buckets if x.startswith("dm:")):
|
||
sess.append(buckets[k])
|
||
for k in sorted(x for x in buckets if x.startswith("group:")):
|
||
sess.append(buckets[k])
|
||
return sess
|
||
|
||
body = tk.Frame(win, bg="#f0f4f8")
|
||
body.pack(fill="both", expand=True, padx=8, pady=8)
|
||
|
||
left_fr = tk.Frame(body, bg="#f0f4f8", width=260)
|
||
left_fr.pack(side="left", fill="y", padx=(0, 8))
|
||
left_fr.pack_propagate(False)
|
||
|
||
tk.Label(
|
||
left_fr, text="Chats", font=("Segoe UI", 9, "bold"),
|
||
bg="#f0f4f8", fg="#1a4d6d",
|
||
).pack(anchor="w", pady=(0, 4))
|
||
|
||
lb = tk.Listbox(
|
||
left_fr, width=32, height=24, font=("Segoe UI", 9),
|
||
bg="white", fg="#1a2a3a", selectbackground="#d4e4f0",
|
||
highlightthickness=1, highlightbackground="#d0dce8",
|
||
activestyle="none",
|
||
)
|
||
lb.pack(fill="both", expand=True)
|
||
|
||
btn_row = tk.Frame(left_fr, bg="#f0f4f8")
|
||
btn_row.pack(fill="x", pady=(6, 0))
|
||
|
||
right_fr = tk.Frame(body, bg="#f0f4f8")
|
||
right_fr.pack(side="left", fill="both", expand=True)
|
||
|
||
tk.Label(
|
||
right_fr, text="Nachrichten", font=("Segoe UI", 9, "bold"),
|
||
bg="#f0f4f8", fg="#1a4d6d",
|
||
).pack(anchor="w", pady=(0, 4))
|
||
|
||
txt_fr = tk.Frame(right_fr, bg="white", highlightthickness=1, highlightbackground="#d0dce8")
|
||
txt_fr.pack(fill="both", expand=True)
|
||
|
||
sb = ttk.Scrollbar(txt_fr)
|
||
sb.pack(side="right", fill="y")
|
||
|
||
txt = tk.Text(
|
||
txt_fr, wrap="word", state="disabled", font=("Segoe UI", 9),
|
||
bg="white", fg="#1a2a3a", padx=10, pady=8,
|
||
yscrollcommand=sb.set,
|
||
)
|
||
txt.pack(side="left", fill="both", expand=True)
|
||
sb.config(command=txt.yview)
|
||
|
||
txt.tag_configure("hdr", font=("Segoe UI", 8, "bold"))
|
||
txt.tag_configure("sub", font=("Segoe UI", 8), foreground="#5a6a7a")
|
||
txt.tag_configure("tx", font=("Segoe UI", 9))
|
||
txt.tag_configure("even", background="#fafcfe")
|
||
txt.tag_configure("odd", background="#e8eef6")
|
||
|
||
sessions_holder: list = []
|
||
|
||
def _render_session(idx: int):
|
||
if idx < 0 or idx >= len(sessions_holder):
|
||
return
|
||
sess = sessions_holder[idx]
|
||
msgs = sess.get("msgs") or []
|
||
txt.configure(state="normal")
|
||
txt.delete("1.0", "end")
|
||
if not msgs:
|
||
txt.insert("end", "Keine Nachrichten in diesem Chat.\n")
|
||
txt.configure(state="disabled")
|
||
return
|
||
for i, m in enumerate(msgs):
|
||
row_tag = "even" if i % 2 == 0 else "odd"
|
||
ts = m.get("zeitstempel") or m.get("empfangen") or ""
|
||
snd = m.get("absender") or ""
|
||
body_t = (m.get("kommentar") or "").strip()
|
||
if body_t == "\u200b":
|
||
body_t = "(Anhang / ohne Text)"
|
||
outgoing = _hn(_sender_core(snd)) == _hn(me_disp)
|
||
dir_txt = "Ausgehend" if outgoing else "Eingehend"
|
||
txt.insert("end", f"{dir_txt} — {ts}\n", (row_tag, "hdr"))
|
||
txt.insert("end", f"{snd}\n", (row_tag, "sub"))
|
||
txt.insert("end", (body_t if body_t else "(leer)") + "\n\n", (row_tag, "tx"))
|
||
txt.configure(state="disabled")
|
||
txt.see("1.0")
|
||
|
||
def _on_lb_select(_evt=None):
|
||
sel = lb.curselection()
|
||
if not sel:
|
||
return
|
||
_render_session(int(sel[0]))
|
||
|
||
lb.bind("<<ListboxSelect>>", _on_lb_select)
|
||
|
||
status_var = tk.StringVar(value="Laden …")
|
||
|
||
def _apply_sessions(sess_list: list):
|
||
sessions_holder.clear()
|
||
sessions_holder.extend(sess_list)
|
||
lb.delete(0, tk.END)
|
||
for s in sess_list:
|
||
lb.insert(tk.END, s.get("label") or s.get("key") or "?")
|
||
status_var.set(f"{len(sess_list)} Chat(s), Aufbewahrung max. ca. 14 Tage (Server)")
|
||
if sess_list:
|
||
lb.selection_set(0)
|
||
_render_session(0)
|
||
else:
|
||
txt.configure(state="normal")
|
||
txt.delete("1.0", "end")
|
||
txt.insert("end", "Keine Chat-Daten.\n")
|
||
txt.configure(state="disabled")
|
||
|
||
def _fetch_worker():
|
||
try:
|
||
bu = self.get_backend_url()
|
||
hdrs = self._empfang_headers()
|
||
r = requests.get(f"{bu}/empfang/messages", headers=hdrs, timeout=12)
|
||
if r.status_code != 200:
|
||
self.after(0, lambda: status_var.set(
|
||
f"Laden fehlgeschlagen ({r.status_code})."))
|
||
return
|
||
data = r.json() or {}
|
||
msgs = data.get("messages") or []
|
||
sess_list = _partition_sessions(msgs)
|
||
self.after(0, lambda sl=list(sess_list): _apply_sessions(sl))
|
||
except Exception as exc:
|
||
err = str(exc)
|
||
self.after(0, lambda m=err: status_var.set(f"Fehler: {m}"))
|
||
|
||
def _reload():
|
||
status_var.set("Laden …")
|
||
threading.Thread(target=_fetch_worker, daemon=True).start()
|
||
|
||
tk.Button(
|
||
btn_row, text="Aktualisieren", font=("Segoe UI", 9),
|
||
bg="#e8eef4", fg="#1a4d6d", relief="flat", cursor="hand2",
|
||
padx=10, pady=4,
|
||
command=_reload,
|
||
).pack(side="left")
|
||
|
||
tk.Label(
|
||
win, textvariable=status_var, font=("Segoe UI", 8),
|
||
bg="#f0f4f8", fg="#6a8a9a",
|
||
).pack(fill="x", padx=12, pady=(0, 6))
|
||
|
||
_reload()
|
||
|
||
def _open_billing_portal_from_ui(self):
|
||
try:
|
||
backend_url = self.get_backend_url()
|
||
api_token = self.get_backend_token()
|
||
except Exception:
|
||
self.set_status("Billing-Portal nicht verfügbar.")
|
||
return
|
||
|
||
if open_billing_portal(backend_url=backend_url, api_token=api_token):
|
||
self.set_status("Billing-Portal geöffnet.")
|
||
else:
|
||
self.set_status("Billing-Portal konnte nicht geöffnet werden.")
|
||
|
||
def _debug_log(self, msg: str):
|
||
try:
|
||
path = os.path.join(get_writable_data_dir(), "client_debug.log")
|
||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||
with open(path, "a", encoding="utf-8") as f:
|
||
f.write(f"[{ts}] {msg}\n")
|
||
except Exception:
|
||
pass
|
||
|
||
def _backend_health_state(self):
|
||
try:
|
||
backend_url = self.get_backend_url()
|
||
except Exception as e:
|
||
return False, "", str(e)
|
||
try:
|
||
with requests.Session() as session:
|
||
session.trust_env = False
|
||
session.proxies = {"http": None, "https": None}
|
||
r = session.get(f"{backend_url}/health", timeout=(1.5, 2.5))
|
||
if r.ok:
|
||
return True, backend_url, "ok"
|
||
return False, backend_url, f"HTTP {r.status_code}"
|
||
except Exception as e:
|
||
return False, backend_url, str(e)
|
||
|
||
def _refresh_backend_status(self):
|
||
ok, backend_url, detail = self._backend_health_state()
|
||
try:
|
||
if ok:
|
||
self._backend_status_var.set("Verbunden")
|
||
self._backend_status_label.configure(fg="#2E7D32")
|
||
else:
|
||
self._backend_status_var.set("Nicht verbunden")
|
||
self._backend_status_label.configure(fg="#BD4500")
|
||
tooltip = f"Server: {backend_url or 'nicht konfiguriert'}\nStatus: {'verbunden' if ok else 'nicht verbunden'}\n{detail}"
|
||
if not ok:
|
||
try:
|
||
from aza_firewall import get_firewall_hint_text
|
||
tooltip += "\n\n" + get_firewall_hint_text()
|
||
except Exception:
|
||
pass
|
||
add_tooltip(self._backend_status_label, tooltip)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self._backend_status_after_id:
|
||
self.after_cancel(self._backend_status_after_id)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self._backend_status_after_id = self.after(10000, self._refresh_backend_status)
|
||
except Exception:
|
||
self._backend_status_after_id = None
|
||
|
||
def _start_backend_from_ui(self):
|
||
ok, backend_url, detail = self._backend_health_state()
|
||
if ok:
|
||
self.set_status("Backend läuft bereits.")
|
||
return
|
||
if not backend_url:
|
||
self.set_status("Backend-URL fehlt.")
|
||
messagebox.showerror("Backend starten", "Backend-URL fehlt (backend_url.txt oder MEDWORK_BACKEND_URL).")
|
||
return
|
||
parsed = urlparse(backend_url)
|
||
host = (parsed.hostname or "").strip().lower()
|
||
if host not in ("127.0.0.1", "localhost", "0.0.0.0"):
|
||
messagebox.showinfo("Backend starten", f"Automatischer Start nur für lokales Backend.\nAktuell: {backend_url}")
|
||
return
|
||
|
||
backend_script = os.path.join(os.path.dirname(__file__), "backend_main.py")
|
||
if not os.path.isfile(backend_script):
|
||
self.set_status("backend_main.py nicht gefunden.")
|
||
return
|
||
|
||
env = os.environ.copy()
|
||
try:
|
||
token = self.get_backend_token()
|
||
if token:
|
||
env["MEDWORK_API_TOKEN"] = token
|
||
except Exception:
|
||
pass
|
||
env.setdefault("AZA_TLS_REQUIRE", "0")
|
||
|
||
flags = 0
|
||
if sys.platform == "win32":
|
||
flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
|
||
try:
|
||
subprocess.Popen(
|
||
[sys.executable, backend_script],
|
||
cwd=os.path.dirname(backend_script),
|
||
env=env,
|
||
creationflags=flags,
|
||
)
|
||
self.set_status("Backend wird gestartet...")
|
||
except Exception as e:
|
||
self.set_status(f"Backend-Start fehlgeschlagen: {e}")
|
||
err_msg = f"Start fehlgeschlagen:\n{e}\n\nLetzter Status: {detail}"
|
||
try:
|
||
from aza_firewall import get_firewall_hint_text
|
||
err_msg += "\n\n---\n" + get_firewall_hint_text()
|
||
except Exception:
|
||
pass
|
||
messagebox.showerror("Backend starten", err_msg)
|
||
return
|
||
|
||
def _wait_until_up():
|
||
for _ in range(12):
|
||
time.sleep(1.0)
|
||
ok_now, _, _ = self._backend_health_state()
|
||
if ok_now:
|
||
try:
|
||
self.after(0, lambda: self.set_status("Backend gestartet und erreichbar."))
|
||
except Exception:
|
||
pass
|
||
break
|
||
try:
|
||
self.after(0, self._refresh_backend_status)
|
||
except Exception:
|
||
pass
|
||
|
||
threading.Thread(target=_wait_until_up, daemon=True).start()
|
||
|
||
def _auto_start_backend_if_needed(self):
|
||
"""Startet das lokale Backend einmalig automatisch, falls es offline ist."""
|
||
if getattr(self, "_backend_autostart_attempted", False):
|
||
return
|
||
self._backend_autostart_attempted = True
|
||
ok, backend_url, _ = self._backend_health_state()
|
||
if ok:
|
||
return
|
||
if not backend_url:
|
||
return
|
||
parsed = urlparse(backend_url)
|
||
host = (parsed.hostname or "").strip().lower()
|
||
if host not in ("127.0.0.1", "localhost", "0.0.0.0"):
|
||
return
|
||
self.set_status("Backend offline – starte automatisch...")
|
||
self._start_backend_from_ui()
|
||
|
||
def _resolve_audio_notiz_script(self) -> str | None:
|
||
"""Pfad zu audio_notiz_app.py (nur fuer Dev-Modus/Subprocess)."""
|
||
if getattr(sys, "frozen", False):
|
||
base = os.path.dirname(sys.executable)
|
||
else:
|
||
base = os.path.dirname(os.path.abspath(__file__))
|
||
path = os.path.join(base, "apps", "diktat", "audio_notiz_app.py")
|
||
return path if os.path.isfile(path) else None
|
||
|
||
def _start_audio_notiz_addon(self, silent: bool = False) -> bool:
|
||
if not self._check_ai_consent():
|
||
return False
|
||
existing = getattr(self, "_audio_notiz_window", None)
|
||
if existing is not None:
|
||
try:
|
||
if hasattr(existing, "_toplevel") and existing._toplevel.winfo_exists():
|
||
existing._toplevel.deiconify()
|
||
existing._toplevel.lift()
|
||
return True
|
||
elif hasattr(existing, "winfo_exists") and existing.winfo_exists():
|
||
existing.deiconify()
|
||
existing.lift()
|
||
return True
|
||
except Exception:
|
||
pass
|
||
self._audio_notiz_window = None
|
||
if getattr(sys, "frozen", False):
|
||
try:
|
||
from apps.diktat.diktat_app import DiktatApp
|
||
app = DiktatApp(_as_toplevel_of=self)
|
||
self._audio_notiz_window = app
|
||
self.set_status("Audio-Notiz gestartet.")
|
||
return True
|
||
except Exception as e:
|
||
if not silent:
|
||
messagebox.showerror(
|
||
"Audio-Notiz",
|
||
f"Audio-Notiz konnte nicht gestartet werden:\n{e}"
|
||
)
|
||
return False
|
||
script_path = self._resolve_audio_notiz_script()
|
||
if not script_path:
|
||
if not silent:
|
||
messagebox.showerror(
|
||
"Audio-Notiz",
|
||
"audio_notiz_app.py nicht gefunden.\nErwartet unter apps/diktat/."
|
||
)
|
||
return False
|
||
try:
|
||
kwargs = {"cwd": os.path.dirname(script_path)}
|
||
if sys.platform == "win32":
|
||
pythonw = sys.executable.replace("python.exe", "pythonw.exe")
|
||
if os.path.exists(pythonw):
|
||
subprocess.Popen([pythonw, script_path], **kwargs)
|
||
else:
|
||
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||
subprocess.Popen([sys.executable, script_path], **kwargs)
|
||
else:
|
||
subprocess.Popen([sys.executable, script_path], **kwargs)
|
||
self.set_status("Audio-Notiz gestartet.")
|
||
return True
|
||
except Exception as e:
|
||
if not silent:
|
||
messagebox.showerror("Audio-Notiz", f"Start fehlgeschlagen:\n{e}")
|
||
return False
|
||
|
||
def _auto_start_audio_notiz_if_enabled(self):
|
||
if getattr(self, "_audio_notiz_autostart_attempted", False):
|
||
return
|
||
self._audio_notiz_autostart_attempted = True
|
||
if not bool(self._autotext_data.get("audio_notiz_autostart", True)):
|
||
return
|
||
self._start_audio_notiz_addon(silent=True)
|
||
|
||
def transcribe_file_via_backend_with_fallback(self, audio_path: str) -> str:
|
||
"""Backend-Transkription mit automatischem Retry (bis zu 3 Versuche)."""
|
||
last_error = None
|
||
for attempt in range(1, self._TRANSCRIBE_MAX_RETRIES + 1):
|
||
try:
|
||
if attempt > 1:
|
||
wait = min(attempt * 5, 15)
|
||
try:
|
||
self.after(0, lambda a=attempt, w=wait: self.set_status(
|
||
f"Transkription: Versuch {a}/{self._TRANSCRIBE_MAX_RETRIES} (warte {w}s)…"
|
||
))
|
||
except Exception:
|
||
pass
|
||
time.sleep(wait)
|
||
return self._transcribe_single_attempt(audio_path, attempt)
|
||
except RuntimeError as e:
|
||
err_str = str(e)
|
||
if any(k in err_str for k in ("Token fehlt", "401", "403", "Einwilligung")):
|
||
raise
|
||
last_error = e
|
||
try:
|
||
self._debug_log(
|
||
f"TRANSCRIBE_RETRY attempt={attempt}/{self._TRANSCRIBE_MAX_RETRIES} "
|
||
f"error={repr(e)}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
except Exception as e:
|
||
last_error = e
|
||
try:
|
||
self._debug_log(
|
||
f"TRANSCRIBE_RETRY attempt={attempt}/{self._TRANSCRIBE_MAX_RETRIES} "
|
||
f"error={repr(e)}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
raise RuntimeError(
|
||
f"Transkription nach {self._TRANSCRIBE_MAX_RETRIES} Versuchen fehlgeschlagen.\n"
|
||
f"Letzter Fehler: {last_error}"
|
||
)
|
||
|
||
def _transcribe_single_attempt(self, audio_path: str, attempt: int = 1) -> str:
|
||
"""Ein einzelner Backend-Transkriptionsversuch."""
|
||
backend_url = self.get_backend_url()
|
||
backend_token = (self.get_backend_token() or "").strip()
|
||
audio_name = os.path.basename(audio_path)
|
||
x_user = (self._get_consent_user_id() or "default").strip() or "default"
|
||
health_response = None
|
||
response = None
|
||
|
||
def _latin1_safe(value) -> str:
|
||
s = value if isinstance(value, str) else str(value or "")
|
||
s = (
|
||
s.replace("\u2013", "-")
|
||
.replace("\u2014", "-")
|
||
.replace("\u2018", "'")
|
||
.replace("\u2019", "'")
|
||
.replace("\u201c", '"')
|
||
.replace("\u201d", '"')
|
||
)
|
||
return s.encode("latin-1", "ignore").decode("latin-1")
|
||
|
||
audio_name = _latin1_safe(audio_name)
|
||
x_user = _latin1_safe(x_user)
|
||
backend_token = _latin1_safe(backend_token)
|
||
device_id = _latin1_safe(_get_or_create_device_id())
|
||
|
||
ext = os.path.splitext(audio_path)[1].lower()
|
||
upload_ct = "audio/mp4" if ext == ".m4a" else "audio/wav"
|
||
timeout = self._calc_transcribe_timeout(audio_path)
|
||
|
||
try:
|
||
self._debug_log(
|
||
f"TRANSCRIBE_REQUEST url={backend_url} attempt={attempt} "
|
||
f"token_present={'yes' if bool(backend_token) else 'no'} file={audio_name} "
|
||
f"x_user={x_user} timeout={timeout}"
|
||
)
|
||
headers = {"X-API-Token": backend_token, "X-User": x_user, "X-Device-Id": device_id}
|
||
with requests.Session() as session:
|
||
session.trust_env = False
|
||
session.proxies = {"http": None, "https": None}
|
||
health_response = session.get(f"{backend_url}/health", timeout=(2, 5))
|
||
health_body_snippet = ((health_response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
|
||
self._debug_log(
|
||
f"TRANSCRIBE_HEALTH url={backend_url} status={health_response.status_code} body={health_body_snippet}"
|
||
)
|
||
if not health_response.ok:
|
||
raise RuntimeError(
|
||
f"Backend-Healthcheck fehlgeschlagen (HTTP {health_response.status_code}). Body: {health_body_snippet}"
|
||
)
|
||
with open(audio_path, "rb") as f:
|
||
response = session.post(
|
||
f"{backend_url}/v1/transcribe",
|
||
files={"file": (audio_name, f, upload_ct)},
|
||
data={
|
||
"language": "de",
|
||
"prompt": self._get_transcribe_prompt(),
|
||
"domain": self._get_transcribe_domain(),
|
||
"specialty": self._autotext_data.get("user_specialty_default", "dermatology"),
|
||
},
|
||
headers=headers,
|
||
timeout=timeout,
|
||
)
|
||
|
||
body_snippet = ((response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
|
||
if not response.ok:
|
||
status = response.status_code
|
||
if status in (401, 403):
|
||
msg = (
|
||
f"Token fehlt/ungueltig (HTTP {status}).\n\n"
|
||
"Loesung: Backend neu starten (Token aus backend_token.txt wird dann geladen).\n"
|
||
"Stelle sicher, dass backend_token.txt und Backend im gleichen Projektordner liegen."
|
||
)
|
||
elif status == 404:
|
||
msg = f"Endpoint nicht gefunden: /v1/transcribe (HTTP 404). Body: {body_snippet}"
|
||
elif status >= 500:
|
||
msg = f"Backend error (HTTP {status}). Body: {body_snippet}"
|
||
else:
|
||
msg = f"HTTP-Fehler vom Backend (HTTP {status}). Body: {body_snippet}"
|
||
self._debug_log(
|
||
f"TRANSCRIBE_HTTP_ERROR url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
|
||
f"file={audio_name} status={status} body={body_snippet}"
|
||
)
|
||
raise RuntimeError(msg)
|
||
|
||
try:
|
||
data = response.json()
|
||
except Exception:
|
||
self._debug_log(
|
||
f"TRANSCRIBE_JSON_ERROR url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
|
||
f"file={audio_name} status={response.status_code} body={((response.text or '')[:500]).replace(chr(10), ' ')}"
|
||
)
|
||
raise RuntimeError("Ungueltige JSON-Antwort vom Backend.")
|
||
|
||
if not data.get("success"):
|
||
backend_err = (data.get("error") or "").strip()
|
||
if backend_err:
|
||
raise RuntimeError(f"Backend meldet success != true. Fehler: {backend_err}")
|
||
raise RuntimeError("Backend meldet success != true.")
|
||
|
||
text = (data.get("transcript") or "").strip()
|
||
if not text:
|
||
raise RuntimeError("Backend lieferte leeres Transkript.")
|
||
|
||
estimated_tokens = len(text) // 4
|
||
add_token_usage(estimated_tokens)
|
||
try:
|
||
self.after(0, self.update_token_display)
|
||
except Exception:
|
||
pass
|
||
dur = data.get("duration_ms", "?")
|
||
try:
|
||
self.after(0, lambda: self.set_status(f"Transkription via Backend ({dur} ms)"))
|
||
except Exception:
|
||
pass
|
||
return text
|
||
|
||
except requests.exceptions.ConnectTimeout as e:
|
||
health_status = "n/a" if health_response is None else health_response.status_code
|
||
health_body = ""
|
||
if health_response is not None:
|
||
health_body = ((health_response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
|
||
self._debug_log(
|
||
f"TRANSCRIBE_CONNECT_TIMEOUT url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
|
||
f"file={audio_name} exception={repr(e)} health_status={health_status} health_body={health_body}\n{traceback.format_exc()}"
|
||
)
|
||
try:
|
||
self.after(0, lambda: self.set_status("Backend nicht erreichbar (Verbindungs-Timeout)."))
|
||
except Exception:
|
||
pass
|
||
raise RuntimeError("Backend nicht erreichbar (Verbindungs-Timeout).")
|
||
except requests.exceptions.Timeout as e:
|
||
body_snippet = ""
|
||
status = "n/a"
|
||
if response is not None:
|
||
status = response.status_code
|
||
body_snippet = ((response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
|
||
health_status = "n/a" if health_response is None else health_response.status_code
|
||
health_body = ""
|
||
if health_response is not None:
|
||
health_body = ((health_response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
|
||
self._debug_log(
|
||
f"TRANSCRIBE_TIMEOUT url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
|
||
f"file={audio_name} exception={repr(e)} status={status} body={body_snippet} "
|
||
f"health_status={health_status} health_body={health_body}\n{traceback.format_exc()}"
|
||
)
|
||
try:
|
||
self.after(0, lambda: self.set_status("Backend-Timeout bei Transkription."))
|
||
except Exception:
|
||
pass
|
||
raise RuntimeError("Timeout bei Backend-Transkription.")
|
||
except requests.exceptions.ConnectionError as e:
|
||
health_status = "n/a" if health_response is None else health_response.status_code
|
||
health_body = ""
|
||
if health_response is not None:
|
||
health_body = ((health_response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
|
||
self._debug_log(
|
||
f"TRANSCRIBE_CONNECTION_ERROR url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
|
||
f"file={audio_name} exception={repr(e)} health_status={health_status} health_body={health_body}\n{traceback.format_exc()}"
|
||
)
|
||
try:
|
||
self.after(0, lambda: self.set_status("Backend nicht erreichbar."))
|
||
except Exception:
|
||
pass
|
||
raise RuntimeError("Backend nicht erreichbar.")
|
||
except Exception as e:
|
||
body_snippet = ""
|
||
status = "n/a"
|
||
if response is not None:
|
||
status = response.status_code
|
||
body_snippet = ((response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
|
||
health_status = "n/a" if health_response is None else health_response.status_code
|
||
health_body = ""
|
||
if health_response is not None:
|
||
health_body = ((health_response.text or "")[:500]).replace("\n", " ").replace("\r", " ")
|
||
self._debug_log(
|
||
f"TRANSCRIBE_ERROR url={backend_url} token_present={'yes' if bool(backend_token) else 'no'} "
|
||
f"file={audio_name} exception={repr(e)} status={status} body={body_snippet} "
|
||
f"health_status={health_status} health_body={health_body}\n{traceback.format_exc()}"
|
||
)
|
||
try:
|
||
self.after(0, lambda: self.set_status("Backend-Transkription fehlgeschlagen."))
|
||
except Exception:
|
||
pass
|
||
raise
|
||
|
||
def transcribe_wav(self, wav_path: str) -> str:
|
||
uid = self._get_consent_user_id()
|
||
if not has_valid_consent(uid):
|
||
log_event("AI_BLOCKED", uid, success=False, detail="transcribe, kein Consent")
|
||
raise RuntimeError("KI-Einwilligung fehlt oder wurde widerrufen.")
|
||
log_event("AI_TRANSCRIBE", uid)
|
||
|
||
chunks = split_audio_into_chunks(wav_path)
|
||
parts: list[str] = []
|
||
failed_chunks: list[int] = []
|
||
last_chunk_error = None
|
||
try:
|
||
for i, chunk_path in enumerate(chunks):
|
||
if len(chunks) > 1:
|
||
try:
|
||
self.after(0, lambda idx=i, total=len(chunks):
|
||
self.set_status(f"Transkribiere Teil {idx+1}/{total}…"))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
part = self.transcribe_file_via_backend_with_fallback(chunk_path)
|
||
if part:
|
||
parts.append(part)
|
||
except Exception as chunk_err:
|
||
failed_chunks.append(i + 1)
|
||
last_chunk_error = chunk_err
|
||
try:
|
||
self._debug_log(
|
||
f"CHUNK_TRANSCRIBE_FAIL chunk={i+1}/{len(chunks)} "
|
||
f"error={repr(chunk_err)}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
if not parts:
|
||
raise
|
||
finally:
|
||
for cp in chunks:
|
||
if cp != wav_path:
|
||
try:
|
||
os.remove(cp)
|
||
except Exception:
|
||
pass
|
||
|
||
if failed_chunks and parts:
|
||
hint = f" [Teile {', '.join(str(c) for c in failed_chunks)} von {len(chunks)} konnten nicht transkribiert werden]"
|
||
parts.append(hint)
|
||
try:
|
||
self.after(0, lambda: self.set_status(
|
||
f"Teiltranskription: {len(chunks) - len(failed_chunks)}/{len(chunks)} Teile erfolgreich"
|
||
))
|
||
except Exception:
|
||
pass
|
||
|
||
text = "\n\n".join(parts)
|
||
return text.replace("ß", "ss") if text else text
|
||
|
||
def _get_consent_user_id(self) -> str:
|
||
return self._user_profile.get("name", "default")
|
||
|
||
def _check_ai_consent(self) -> bool:
|
||
"""Prueft ob eine gueltige KI-Einwilligung vorliegt. Zeigt Dialog falls nicht."""
|
||
uid = self._get_consent_user_id()
|
||
if has_valid_consent(uid):
|
||
return True
|
||
return self._show_consent_dialog()
|
||
|
||
def _show_consent_dialog(self) -> bool:
|
||
"""Zeigt den Einwilligungsdialog. Gibt True zurueck bei Zustimmung."""
|
||
consent_text = ""
|
||
try:
|
||
legal_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "legal", "ai_consent.md")
|
||
with open(legal_path, "r", encoding="utf-8") as fh:
|
||
consent_text = fh.read()
|
||
except (FileNotFoundError, OSError):
|
||
consent_text = "(KI-Einwilligungstext konnte nicht geladen werden.)"
|
||
|
||
result = {"accepted": False}
|
||
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title("KI-Einwilligung erforderlich")
|
||
dlg.transient(self)
|
||
dlg.grab_set()
|
||
dlg.geometry("920x720")
|
||
dlg.minsize(700, 550)
|
||
dlg.attributes("-topmost", True)
|
||
self._register_window(dlg)
|
||
add_resize_grip(dlg, 700, 550)
|
||
center_window(dlg, 920, 720)
|
||
|
||
frame = ttk.Frame(dlg, padding=12)
|
||
frame.pack(fill="both", expand=True)
|
||
|
||
ttk.Label(frame, text="Einwilligung zur KI-gestuetzten Datenverarbeitung",
|
||
font=("Segoe UI", 11, "bold")).pack(anchor="w", pady=(0, 8))
|
||
|
||
ttk.Label(frame, text="Bitte lesen Sie den folgenden Text und stimmen Sie zu,\n"
|
||
"bevor KI-Funktionen genutzt werden koennen.",
|
||
wraplength=600).pack(anchor="w", pady=(0, 8))
|
||
|
||
from tkinter.scrolledtext import ScrolledText
|
||
txt = ScrolledText(frame, wrap="word", font=("Segoe UI", 9), bg="#FAFAFA", height=16)
|
||
txt.pack(fill="both", expand=True, pady=(0, 8))
|
||
txt.insert("1.0", consent_text)
|
||
txt.configure(state="disabled")
|
||
|
||
check_var = tk.BooleanVar(value=False)
|
||
cb = ttk.Checkbutton(frame,
|
||
text="Ich habe den Text gelesen und stimme der KI-gestuetzten Verarbeitung zu.",
|
||
variable=check_var)
|
||
cb.pack(anchor="w", pady=(4, 8))
|
||
|
||
btn_frame = ttk.Frame(frame)
|
||
btn_frame.pack(fill="x")
|
||
|
||
def on_accept():
|
||
if not check_var.get():
|
||
messagebox.showwarning("Einwilligung", "Bitte setzen Sie das Haekchen, um zuzustimmen.", parent=dlg)
|
||
return
|
||
uid = self._get_consent_user_id()
|
||
record_consent(uid, source="ui")
|
||
log_event("CONSENT_GRANT", uid)
|
||
result["accepted"] = True
|
||
dlg.destroy()
|
||
|
||
def on_decline():
|
||
dlg.destroy()
|
||
|
||
ttk.Button(btn_frame, text="Zustimmen", command=on_accept).pack(side="left", padx=(0, 8))
|
||
ttk.Button(btn_frame, text="Ablehnen", command=on_decline).pack(side="left")
|
||
|
||
dlg.wait_window()
|
||
return result["accepted"]
|
||
|
||
def _chat_via_backend(self, **kwargs):
|
||
"""Route chat completion through remote backend's /v1/chat endpoint."""
|
||
from types import SimpleNamespace
|
||
backend_url = self.get_backend_url()
|
||
backend_token = self.get_backend_token()
|
||
payload: dict = {
|
||
"model": kwargs.get("model", "gpt-4o"),
|
||
"messages": [
|
||
{"role": m["role"], "content": m["content"]}
|
||
if isinstance(m, dict) else {"role": m.role, "content": m.content}
|
||
for m in kwargs.get("messages", [])
|
||
],
|
||
}
|
||
if kwargs.get("temperature") is not None:
|
||
payload["temperature"] = kwargs["temperature"]
|
||
if kwargs.get("max_tokens") is not None:
|
||
payload["max_tokens"] = kwargs["max_tokens"]
|
||
if kwargs.get("top_p") is not None:
|
||
payload["top_p"] = kwargs["top_p"]
|
||
r = requests.post(
|
||
f"{backend_url}/v1/chat",
|
||
json=payload,
|
||
headers={"X-API-Token": backend_token},
|
||
timeout=(5, 120),
|
||
)
|
||
r.raise_for_status()
|
||
data = r.json()
|
||
if not data.get("success"):
|
||
raise RuntimeError(data.get("error", "Backend-Chat fehlgeschlagen"))
|
||
content = (data.get("content") or "").replace("ß", "ss")
|
||
msg = SimpleNamespace(content=content, role="assistant")
|
||
choice = SimpleNamespace(message=msg, finish_reason=data.get("finish_reason"))
|
||
usage = None
|
||
usage_data = data.get("usage")
|
||
if usage_data:
|
||
usage = SimpleNamespace(
|
||
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
||
completion_tokens=usage_data.get("completion_tokens", 0),
|
||
total_tokens=usage_data.get("total_tokens", 0),
|
||
)
|
||
return SimpleNamespace(choices=[choice], usage=usage, model=data.get("model", ""))
|
||
|
||
def call_chat_completion(self, **kwargs):
|
||
"""Wrapper für chat.completions.create mit automatischem Token-Tracking, Soft-Lock und CH-Rechtschreibung (ss statt ß)."""
|
||
uid = self._get_consent_user_id()
|
||
if not has_valid_consent(uid):
|
||
log_event("AI_BLOCKED", uid, success=False, detail="chat, kein Consent")
|
||
raise RuntimeError("KI-Einwilligung fehlt oder wurde widerrufen.")
|
||
|
||
if is_capacity_low():
|
||
remaining = get_remaining_tokens()
|
||
if remaining <= 0:
|
||
messagebox.showwarning(
|
||
"KI-Kapazität aufgebraucht",
|
||
"Ihr KI-Guthaben ist aufgebraucht.\n\n"
|
||
"Bitte laden Sie unter aza-medwork.ch\n"
|
||
"neue Einheiten nach.",
|
||
)
|
||
raise RuntimeError("KI-Kapazität aufgebraucht.")
|
||
messagebox.showinfo(
|
||
"KI-Kapazität niedrig",
|
||
f"Ihr KI-Guthaben ist fast aufgebraucht.\n"
|
||
f"Verbleibend: {remaining:,} Einheiten.\n\n"
|
||
"Sie können unter aza-medwork.ch\n"
|
||
"Guthaben nachladen.",
|
||
)
|
||
|
||
model = kwargs.get("model", "unknown")
|
||
log_event("AI_CHAT", uid, detail=f"model={model}")
|
||
|
||
if _has_remote_backend():
|
||
resp = self._chat_via_backend(**kwargs)
|
||
elif self.client:
|
||
resp = self.client.chat.completions.create(**kwargs)
|
||
else:
|
||
raise RuntimeError("KI-Verbindung nicht eingerichtet.")
|
||
|
||
if hasattr(resp, 'usage'):
|
||
total_tokens = getattr(resp.usage, 'total_tokens', 0)
|
||
if total_tokens > 0:
|
||
add_token_usage(total_tokens)
|
||
self.after(0, self.update_token_display)
|
||
if resp and resp.choices:
|
||
for choice in resp.choices:
|
||
if choice.message and choice.message.content:
|
||
choice.message.content = choice.message.content.replace("ß", "ss")
|
||
return resp
|
||
|
||
def _demo_usage_path(self) -> str:
|
||
return os.path.join(get_writable_data_dir(), DEMO_USAGE_FILE)
|
||
|
||
def _load_demo_usage_count(self) -> int:
|
||
try:
|
||
with open(self._demo_usage_path(), "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
return int(data.get("count", 0))
|
||
except Exception:
|
||
return 0
|
||
|
||
def _save_demo_usage_count(self, count: int):
|
||
try:
|
||
with open(self._demo_usage_path(), "w", encoding="utf-8") as f:
|
||
json.dump({"count": int(max(0, count))}, f, ensure_ascii=False, indent=2)
|
||
except Exception:
|
||
pass
|
||
|
||
def _demo_limit_reached(self) -> bool:
|
||
return self.license_mode == "demo" and self._load_demo_usage_count() >= DEMO_MAX_DICTATIONS
|
||
|
||
def _increment_demo_usage_if_needed(self):
|
||
if self.license_mode != "demo":
|
||
return
|
||
current = self._load_demo_usage_count()
|
||
self._save_demo_usage_count(current + 1)
|
||
|
||
def _show_demo_limit_message(self):
|
||
messagebox.showerror(
|
||
"Demo-Limit erreicht",
|
||
f"Sie haben das Demo-Limit von {DEMO_MAX_DICTATIONS} Diktaten erreicht.\n\n"
|
||
"Bitte aktivieren Sie Ihren Lizenzschluessel\n"
|
||
"ueber das Schluessel-Symbol \U0001F511 in der Kopfleiste.",
|
||
)
|
||
|
||
def open_brief_window(self):
|
||
if self._demo_limit_reached():
|
||
self._show_demo_limit_message()
|
||
return
|
||
return TextWindowsMixin.open_brief_window(self)
|
||
|
||
@staticmethod
|
||
def _diktat_apply_punctuation(text: str) -> str:
|
||
"""Ersetzt gesprochene Satzzeichen/Anweisungen durch echte Zeichen (nur im Diktat-Fenster)."""
|
||
if not text or not text.strip():
|
||
return text
|
||
t = text
|
||
# Zeilenumbrüche / Absätze zuerst
|
||
t = re.sub(r"\s+neuer\s+Absatz\s*", "\n\n", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+neue\s+Zeile\s*", "\n", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Zeilenumbruch\s*", "\n", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Absatz\s+", "\n\n", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Absatz\s*$", "\n\n", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Absatzzeichen\s*", "\n\n", t, flags=re.IGNORECASE)
|
||
# Satzzeichen (werden nicht ausgeschrieben, sondern als Zeichen eingefügt)
|
||
t = re.sub(r"\s+Punkt\s+", ". ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Punkt\s*$", ".", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Komma\s+", ", ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Komma\s*$", ",", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Semikolon\s+", "; ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Semikolon\s*$", ";", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Strichpunkt\s+", "; ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Strichpunkt\s*$", ";", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Doppelpunkt\s+", ": ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Doppelpunkt\s*$", ":", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Fragezeichen\s+", "? ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Fragezeichen\s*$", "?", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Ausrufezeichen\s+", "! ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Ausrufezeichen\s*$", "!", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Gedankenstrich\s+", " ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Gedankenstrich\s*$", " ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Bindestrich\s+", "-", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Schrägstrich\s+", "/", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Klammer\s+auf\s+", " (", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Klammer\s+zu\s+", ") ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Auslassungspunkte\s+", " ", t, flags=re.IGNORECASE)
|
||
t = re.sub(r"\s+Auslassungspunkte\s*$", "", t, flags=re.IGNORECASE)
|
||
# Ordinalzahlen: erstens 1., zweitens 2., usw.
|
||
ord_map = [
|
||
(r"\b(erstens)\b", "1."),
|
||
(r"\b(zweitens)\b", "2."),
|
||
(r"\b(drittens)\b", "3."),
|
||
(r"\b(viertens)\b", "4."),
|
||
(r"\b(f\u00fcnftens)\b", "5."),
|
||
(r"\b(sechstens)\b", "6."),
|
||
(r"\b(siebtens)\b", "7."),
|
||
(r"\b(achtens)\b", "8."),
|
||
(r"\b(neuntens)\b", "9."),
|
||
(r"\b(zehntens)\b", "10."),
|
||
]
|
||
for pat, repl in ord_map:
|
||
t = re.sub(pat, repl, t, flags=re.IGNORECASE)
|
||
# Gesprochene Jahreszahlen in Ziffern umwandeln
|
||
_year_words = {
|
||
"zweitausendzwanzig": "2020", "zweitausendeinundzwanzig": "2021",
|
||
"zweitausendzweiundzwanzig": "2022", "zweitausenddreiundzwanzig": "2023",
|
||
"zweitausendvierundzwanzig": "2024", "zweitausendf\u00fcnfundzwanzig": "2025",
|
||
"zweitausendsechsundzwanzig": "2026", "zweitausendsiebenundzwanzig": "2027",
|
||
"zweitausendachtundzwanzig": "2028", "zweitausendneunundzwanzig": "2029",
|
||
"zweitausenddreissig": "2030", "zweitausenddrei\u00dfig": "2030",
|
||
"neunzehnhundertneunzig": "1990",
|
||
"zweitausend": "2000",
|
||
}
|
||
for word, year in sorted(_year_words.items(), key=lambda x: -len(x[0])):
|
||
t = re.sub(r"\b" + word + r"\b", year, t, flags=re.IGNORECASE)
|
||
# Gesprochene Tageszahlen vor Monaten: "den dritten Januar" "den 3. Januar"
|
||
_day_words = {
|
||
"ersten": "1.", "zweiten": "2.", "dritten": "3.", "vierten": "4.",
|
||
"f\u00fcnften": "5.", "sechsten": "6.", "siebten": "7.", "achten": "8.",
|
||
"neunten": "9.", "zehnten": "10.", "elften": "11.", "zw\u00f6lften": "12.",
|
||
"dreizehnten": "13.", "vierzehnten": "14.", "f\u00fcnfzehnten": "15.",
|
||
"sechzehnten": "16.", "siebzehnten": "17.", "achtzehnten": "18.",
|
||
"neunzehnten": "19.", "zwanzigsten": "20.", "einundzwanzigsten": "21.",
|
||
"zweiundzwanzigsten": "22.", "dreiundzwanzigsten": "23.",
|
||
"vierundzwanzigsten": "24.", "f\u00fcnfundzwanzigsten": "25.",
|
||
"sechsundzwanzigsten": "26.", "siebenundzwanzigsten": "27.",
|
||
"achtundzwanzigsten": "28.", "neunundzwanzigsten": "29.",
|
||
"dreissigsten": "30.", "drei\u00dfigsten": "30.", "einunddreissigsten": "31.",
|
||
"einunddrei\u00dfigsten": "31.",
|
||
}
|
||
_months = (r"(?:Januar|Februar|M\u00e4rz|April|Mai|Juni|Juli|August|"
|
||
r"September|Oktober|November|Dezember)")
|
||
for word, day in sorted(_day_words.items(), key=lambda x: -len(x[0])):
|
||
t = re.sub(r"\b" + word + r"\s+" + _months,
|
||
lambda m: day + " " + m.group(0).split()[-1], t, flags=re.IGNORECASE)
|
||
return t
|
||
|
||
def _build_system_prompt(self, base_prompt: str) -> str:
|
||
"""Baut den System-Prompt zusammen: Vorlage (höchste Priorität) + Detail-Level + Basis-Prompt."""
|
||
template = load_templates_text().strip()
|
||
detail_level = load_kg_detail_level()
|
||
detail_instr = get_kg_detail_instruction(detail_level)
|
||
diagnosis_coverage_instr = (
|
||
"VERBINDLICHE ERGÄNZUNG ZUR DIAGNOSE-ABDECKUNG:\n"
|
||
"- Alle im Transkript genannten medizinischen Diagnosen, Verdachtsdiagnosen und klinisch relevanten Nebenbefunde "
|
||
"müssen in der Krankengeschichte erwähnt werden (insbesondere auch Nebendiagnosen).\n"
|
||
"- Wenn im Transkript z. B. Warzen oder ähnliche Nebenbefunde genannt sind, müssen diese explizit dokumentiert werden.\n"
|
||
"- Es darf keine im Transkript klar genannte medizinische Diagnose/Nebendiagnose in der KG fehlen.\n"
|
||
"- Verwende in der Diagnose medizinische Fachsprache statt Laienbegriffen (z. B. Warze/Warzen → Verruca vulgaris), "
|
||
"mit passendem ICD-10-GM-Code."
|
||
)
|
||
|
||
user_specialty = self._autotext_data.get("user_specialty_default", "")
|
||
specialty_label = dict(self._all_medical_specialties()).get(user_specialty, "")
|
||
specialty_instr = ""
|
||
if specialty_label:
|
||
specialty_instr = (
|
||
f"FACHGEBIET DES ARZTES: {specialty_label}\n"
|
||
f"Der dokumentierende Arzt ist Facharzt für {specialty_label}. "
|
||
f"Berücksichtige dies bei der Dokumentation: {specialty_label}-spezifische Terminologie, "
|
||
f"Befundbeschreibungen und Diagnosen bevorzugt verwenden, aber auch alle anderen "
|
||
f"medizinischen Befunde und Diagnosen vollständig dokumentieren."
|
||
)
|
||
|
||
parts = []
|
||
if specialty_instr:
|
||
parts.append(specialty_instr)
|
||
if template:
|
||
parts.append(
|
||
"ZWINGENDE VORLAGE DES ARZTES (hat höchste Priorität, MUSS vollständig eingehalten werden):\n"
|
||
"Die folgende Vorlage definiert verbindlich, wie die Krankengeschichte aufgebaut und formuliert werden soll. "
|
||
"Struktur, Reihenfolge, Stil und alle Vorgaben aus dieser Vorlage haben Vorrang vor allen anderen Anweisungen. "
|
||
"Baue die Krankengeschichte ZUERST nach dieser Vorlage auf, dann ergänze fehlende Standardabschnitte.\n\n"
|
||
f"{template}"
|
||
)
|
||
parts.append(base_prompt)
|
||
if detail_instr:
|
||
parts.append(detail_instr)
|
||
soap_levels = load_soap_section_levels()
|
||
soap_instr = get_soap_section_instruction(soap_levels)
|
||
if soap_instr:
|
||
parts.append(soap_instr)
|
||
soap_visibility = load_soap_visibility()
|
||
soap_order = load_soap_order()
|
||
order_instr = get_soap_order_instruction(soap_order, soap_visibility)
|
||
if order_instr:
|
||
parts.append(order_instr)
|
||
vis_instr = get_soap_visibility_instruction(soap_visibility)
|
||
if vis_instr:
|
||
parts.append(vis_instr)
|
||
parts.append(diagnosis_coverage_instr)
|
||
return "\n\n".join(parts)
|
||
|
||
def summarize_text(self, transcript: str) -> str:
|
||
if self._demo_limit_reached():
|
||
self._stop_timer()
|
||
self.set_status("Demo-Limit erreicht. Bitte Lizenz aktivieren.")
|
||
self._show_demo_limit_message()
|
||
return ""
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
system_content = self._build_system_prompt(SYSTEM_PROMPT)
|
||
|
||
user_text = f"""TRANSKRIPT:
|
||
{transcript}
|
||
|
||
WICHTIG:
|
||
- Nenne alle im Transkript erwähnten Diagnosen und medizinischen Nebenbefunde in der KG.
|
||
- Erwähnte Nebendiagnosen/Nebenbefunde (z. B. Warzen) dürfen nicht fehlen.
|
||
- Formuliere Diagnosen in medizinischer Fachsprache (z. B. Warzen als Verruca vulgaris).
|
||
"""
|
||
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": system_content},
|
||
{"role": "user", "content": user_text},
|
||
],
|
||
)
|
||
return resp.choices[0].message.content
|
||
|
||
def merge_kg(self, existing_kg: str, full_transcript: str) -> str:
|
||
"""Ergänzt die bestehende KG um neue Informationen aus dem Transkript."""
|
||
if self._demo_limit_reached():
|
||
self._stop_timer()
|
||
self.set_status("Demo-Limit erreicht. Bitte Lizenz aktivieren.")
|
||
self._show_demo_limit_message()
|
||
return existing_kg
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
system_content = self._build_system_prompt(MERGE_PROMPT)
|
||
user_text = f"""BESTEHENDE KRANKENGESCHICHTE:
|
||
{existing_kg}
|
||
|
||
VOLLSTÄNDIGES TRANSKRIPT (bisher + Ergänzung):
|
||
{full_transcript}
|
||
|
||
Aktualisiere die KG: neue Informationen aus dem Transkript in die passenden Abschnitte einfügen, gleiche Überschriften beibehalten.
|
||
|
||
WICHTIG:
|
||
- Alle im Transkript genannten Diagnosen und medizinischen Nebenbefunde müssen in der aktualisierten KG enthalten sein.
|
||
- Das gilt auch für Nebendiagnosen/Nebenbefunde (z. B. Warzen), sofern sie im Transkript genannt sind.
|
||
- Formuliere Diagnosen in medizinischer Fachsprache (z. B. Warzen als Verruca vulgaris)."""
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": system_content},
|
||
{"role": "user", "content": user_text},
|
||
],
|
||
)
|
||
return resp.choices[0].message.content
|
||
|
||
def make_kg_from_text(self):
|
||
if not self.ensure_ready():
|
||
return
|
||
|
||
transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
if not transcript:
|
||
self.set_status("Keine Audio vorhanden oder kein Transkript vorhanden.")
|
||
return
|
||
|
||
self._start_timer("kg")
|
||
|
||
def worker():
|
||
try:
|
||
kg = strip_kg_warnings(self.summarize_text(transcript))
|
||
self.after(0, lambda k=kg: self._fill_kg_and_finish(k))
|
||
except Exception as e:
|
||
self.after(0, lambda: self._stop_timer())
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _recompute_kg_inline(self):
|
||
"""Generiert die KG neu und schreibt das Ergebnis direkt ins KG-Feld (txt_output) im Hauptfenster, ohne Popup."""
|
||
if not self.ensure_ready():
|
||
return
|
||
transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
if not transcript:
|
||
self.set_status("Kein Transkript vorhanden.")
|
||
return
|
||
if not self._check_ai_consent():
|
||
return
|
||
self._start_timer("kg")
|
||
self.set_status("KG wird mit neuer Vorlage neu generiert...")
|
||
|
||
def worker():
|
||
try:
|
||
kg = strip_kg_warnings(self.summarize_text(transcript))
|
||
def _apply(k):
|
||
self._stop_timer()
|
||
self.set_status("KG aktualisiert.")
|
||
cleaned = k.strip() if k else ""
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", cleaned)
|
||
self._autocopy_kg(cleaned)
|
||
if cleaned:
|
||
try:
|
||
save_to_ablage("KG", cleaned)
|
||
except Exception:
|
||
pass
|
||
self.after(0, lambda k=kg: _apply(k))
|
||
except Exception as e:
|
||
self.after(0, lambda: self._stop_timer())
|
||
self.after(0, lambda: self.set_status("Fehler bei KG-Erneuerung."))
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
# KG bearbeiten: Kürzer / Ausführlicher / Vorlage
|
||
|
||
def _update_kg_detail_display(self):
|
||
"""Aktualisiert die Anzeige des Detail-Levels auf den Buttons."""
|
||
level = load_kg_detail_level()
|
||
if level < 0:
|
||
self.btn_kg_kuerzer.configure(text=f"Kürzer ({level})")
|
||
self.btn_kg_ausfuehrlicher.configure(text="Ausführlicher")
|
||
elif level > 0:
|
||
self.btn_kg_kuerzer.configure(text="Kürzer")
|
||
self.btn_kg_ausfuehrlicher.configure(text=f"Ausführlicher (+{level})")
|
||
else:
|
||
self.btn_kg_kuerzer.configure(text="Kürzer")
|
||
self.btn_kg_ausfuehrlicher.configure(text="Ausführlicher")
|
||
|
||
def _rebuild_soap_section_controls(self):
|
||
"""Baut die SOAP-Section-Controls neu auf (Reihenfolge + Sichtbarkeit aus Vorlage)."""
|
||
soap_inner = self._soap_inner
|
||
bg = self._soap_bg
|
||
for w in soap_inner.winfo_children():
|
||
w.destroy()
|
||
self._soap_section_labels.clear()
|
||
|
||
order = load_soap_order()
|
||
visibility = load_soap_visibility()
|
||
|
||
_FG = "#1a4d6d"
|
||
_ARROW_FG = "#7AAFC8"
|
||
_ARROW_HOVER = "#1a4d6d"
|
||
|
||
for sec_key in order:
|
||
if not visibility.get(sec_key, True):
|
||
continue
|
||
sec_frame = tk.Frame(soap_inner, bg=bg)
|
||
sec_frame.pack(side="left", padx=(0, 14))
|
||
|
||
lv = self._soap_section_levels.get(sec_key, 0)
|
||
lbl_text = sec_key if lv == 0 else f"{sec_key} {lv:+d}"
|
||
|
||
sec_label = tk.Label(sec_frame, text=lbl_text, font=("Segoe UI", 9, "bold"),
|
||
bg=bg, fg=_FG, anchor="center", width=4, pady=1)
|
||
sec_label.pack(side="left")
|
||
self._soap_section_labels[sec_key] = sec_label
|
||
|
||
btn_up = tk.Label(sec_frame, text="\u25B2", font=("Segoe UI", 8),
|
||
bg=bg, fg=_ARROW_FG, cursor="hand2",
|
||
bd=0, highlightthickness=0, padx=0, pady=0)
|
||
btn_up.pack(side="left", padx=(1, 0))
|
||
|
||
btn_down = tk.Label(sec_frame, text="\u25BC", font=("Segoe UI", 8),
|
||
bg=bg, fg=_ARROW_FG, cursor="hand2",
|
||
bd=0, highlightthickness=0, padx=0, pady=0)
|
||
btn_down.pack(side="left", padx=(0, 0))
|
||
|
||
def make_adjust(key, delta):
|
||
return lambda e: self._adjust_soap_section(key, delta)
|
||
|
||
btn_up.bind("<Button-1>", make_adjust(sec_key, +1))
|
||
btn_down.bind("<Button-1>", make_adjust(sec_key, -1))
|
||
|
||
def make_hover(w, enter_fg, leave_fg):
|
||
w.bind("<Enter>", lambda e, ww=w, c=enter_fg: ww.configure(fg=c))
|
||
w.bind("<Leave>", lambda e, ww=w, c=leave_fg: ww.configure(fg=c))
|
||
|
||
make_hover(btn_up, _ARROW_HOVER, _ARROW_FG)
|
||
make_hover(btn_down, _ARROW_HOVER, _ARROW_FG)
|
||
|
||
def make_reset(key):
|
||
return lambda e: self._adjust_soap_section(key, 0, reset=True)
|
||
|
||
sec_label.bind("<Double-Button-1>", make_reset(sec_key))
|
||
sec_label.configure(cursor="hand2")
|
||
|
||
if not getattr(self, "_suppress_inline_soap_reset_icon", False):
|
||
reset_lbl = tk.Label(soap_inner, text=" \u21BA ", font=("Segoe UI", 10),
|
||
bg=bg, fg="#7AAFC8", cursor="hand2")
|
||
reset_lbl.pack(side="left", padx=(10, 0))
|
||
reset_lbl.bind("<Button-1>", lambda e: self._reset_all_soap_sections())
|
||
reset_lbl.bind("<Enter>", lambda e: reset_lbl.configure(fg="#1a4d6d"))
|
||
reset_lbl.bind("<Leave>", lambda e: reset_lbl.configure(fg="#7AAFC8"))
|
||
|
||
def _update_soap_section_display(self):
|
||
"""Aktualisiert die Anzeige aller SOAP-Section-Labels."""
|
||
for key in _SOAP_SECTIONS:
|
||
lbl = self._soap_section_labels.get(key)
|
||
if lbl:
|
||
lv = self._soap_section_levels.get(key, 0)
|
||
lbl.configure(text=key if lv == 0 else f"{key} {lv:+d}")
|
||
|
||
def _adjust_soap_section(self, key: str, delta: int, reset: bool = False):
|
||
"""Passt eine SOAP-Abschnitts-Detailstufe an und wendet optional sofort auf die aktuelle KG an."""
|
||
if reset:
|
||
self._soap_section_levels[key] = 0
|
||
else:
|
||
old = self._soap_section_levels.get(key, 0)
|
||
self._soap_section_levels[key] = max(-3, min(3, old + delta))
|
||
save_soap_section_levels(self._soap_section_levels)
|
||
self._update_soap_section_display()
|
||
lv = self._soap_section_levels[key]
|
||
name = _SOAP_LABELS[key]
|
||
if reset:
|
||
self.set_status(f"{name}: zurückgesetzt auf Standard.")
|
||
elif lv == 0:
|
||
self.set_status(f"{name}: Standard-Länge.")
|
||
else:
|
||
direction = "kürzer" if lv < 0 else "ausführlicher"
|
||
self.set_status(f"{name}: Stufe {lv:+d} ({direction}) wird bei jeder KG-Erstellung berücksichtigt.")
|
||
|
||
# Sofort auf aktuelle KG anwenden, wenn vorhanden
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
if kg_text and not reset:
|
||
self._apply_soap_section_edit(key, delta)
|
||
|
||
def _reset_all_soap_sections(self):
|
||
"""Setzt SOAP-Feintuning (Abschnitte) und KG Detailstufe Kürzer/Ausführlich auf 0."""
|
||
self._soap_section_levels = {k: 0 for k in _SOAP_SECTIONS}
|
||
save_soap_section_levels(self._soap_section_levels)
|
||
self._update_soap_section_display()
|
||
try:
|
||
from aza_persistence import save_kg_detail_level
|
||
|
||
save_kg_detail_level(0)
|
||
ud = getattr(self, "_update_kg_detail_display", None)
|
||
if callable(ud):
|
||
ud()
|
||
except Exception:
|
||
pass
|
||
self.set_status(
|
||
"SOAP-Abschnitte und Kürzer/Ausführlicher zurückgesetzt.")
|
||
|
||
def _apply_soap_section_edit(self, key: str, delta: int):
|
||
"""Wendet eine Kürzung/Erweiterung auf einen einzelnen SOAP-Abschnitt der aktuellen KG an."""
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
if not kg_text:
|
||
return
|
||
if not self.ensure_ready():
|
||
return
|
||
|
||
name = _SOAP_LABELS[key]
|
||
action = "gekürzt" if delta < 0 else "erweitert"
|
||
self.set_status(f"{name} wird {action}")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
|
||
other_sections = ", ".join(
|
||
n for k, n in _SOAP_LABELS.items() if k != key)
|
||
if delta < 0:
|
||
task = (
|
||
f"Kürze LEICHT NUR den Abschnitt '{name}' in der folgenden Krankengeschichte. "
|
||
f"Formuliere die vorhandenen Punkte knapper — gleiche Fakten, kürzere Wortwahl. "
|
||
f"Stil beibehalten (Stichpunkte bleiben Stichpunkte). Nur eine kleine Reduktion. "
|
||
f"Alle anderen Abschnitte ({other_sections}) bleiben WORT FÜR WORT UNVERÄNDERT. "
|
||
f"Gib die KOMPLETTE Krankengeschichte aus."
|
||
)
|
||
else:
|
||
task = (
|
||
f"Formuliere NUR den Abschnitt '{name}' in der folgenden Krankengeschichte LEICHT ausführlicher. "
|
||
f"Vorhandene Stichpunkte in vollständigere Sätze umformulieren. "
|
||
f"WICHTIG: NUR die bereits vorhandenen Informationen ausführlicher formulieren — "
|
||
f"KEINE neuen Fakten, Befunde, Werte oder Details erfinden! "
|
||
f"Nichts hinzufügen, was nicht bereits im Text steht. "
|
||
f"Alle anderen Abschnitte ({other_sections}) bleiben WORT FÜR WORT UNVERÄNDERT. "
|
||
f"Gib die KOMPLETTE Krankengeschichte aus."
|
||
)
|
||
|
||
template = load_templates_text().strip()
|
||
sys_parts = []
|
||
if template:
|
||
sys_parts.append(
|
||
"ZWINGENDE VORLAGE DES ARZTES (höchste Priorität):\n" + template)
|
||
sys_parts.append(
|
||
"Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n" + task +
|
||
"\nDiagnosen mit ICD-10-GM-Codes in eckigen Klammern beibehalten. "
|
||
"Verwende \u2022 statt - als Aufzählungszeichen. "
|
||
"Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. "
|
||
"Überschriften OHNE Doppelpunkt, OHNE Einrückung. "
|
||
"Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. "
|
||
"Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile die Punkte folgen direkt untereinander. "
|
||
"Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. "
|
||
"Keine Sternchen (*). "
|
||
"Keine Meta-Kommentare. Ausgabe: nur die komplette KG."
|
||
)
|
||
soap_levels = load_soap_section_levels()
|
||
soap_instr = get_soap_section_instruction(soap_levels)
|
||
if soap_instr:
|
||
sys_parts.append(soap_instr)
|
||
_vis = load_soap_visibility()
|
||
_vis_instr = get_soap_visibility_instruction(_vis)
|
||
if _vis_instr:
|
||
sys_parts.append(_vis_instr)
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": "\n\n".join(sys_parts)},
|
||
{"role": "user", "content": kg_text},
|
||
],
|
||
)
|
||
result = strip_kg_warnings(resp.choices[0].message.content)
|
||
self.after(0, lambda: self._apply_kg_edit(result, f"{name} {action}"))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _kg_kuerzer(self):
|
||
"""Kürzt die aktuelle KG UND speichert die Stufe dauerhaft für zukünftige KG-Erstellungen."""
|
||
level = load_kg_detail_level()
|
||
new_level = max(-3, level - 1)
|
||
save_kg_detail_level(new_level)
|
||
self._update_kg_detail_display()
|
||
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
if not kg_text:
|
||
self.set_status(f"KG-Stil gespeichert: Stufe {new_level} (kürzer). Nächste KG wird kürzer erstellt.")
|
||
return
|
||
if not self.ensure_ready():
|
||
return
|
||
self.set_status(f"KG wird gekürzt (Stufe {new_level})")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
template = load_templates_text().strip()
|
||
sys_parts = []
|
||
if template:
|
||
sys_parts.append(
|
||
"ZWINGENDE VORLAGE DES ARZTES (höchste Priorität, MUSS eingehalten werden):\n" + template)
|
||
sys_parts.append(
|
||
"Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n"
|
||
"Kürze die folgende Krankengeschichte deutlich. "
|
||
"Fasse Stichpunkte zusammen, entferne Wiederholungen und "
|
||
"reduziere auf das Wesentliche. Behalte die SOAP-Struktur bei "
|
||
"(Anamnese, Subjektiv, Objektiv, Beurteilung, Diagnose mit ICD-10-GM, Therapie, Procedere). "
|
||
"Diagnosen mit ICD-10-Codes in eckigen Klammern beibehalten. "
|
||
"Verwende \u2022 statt - als Aufzählungszeichen. "
|
||
"Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. "
|
||
"Überschriften OHNE Doppelpunkt, OHNE Einrückung. "
|
||
"Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. "
|
||
"Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile die Punkte folgen direkt untereinander. "
|
||
"Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. "
|
||
"Keine Sternchen (*). "
|
||
"Keine Meta-Kommentare. Ausgabe: nur die gekürzte KG."
|
||
)
|
||
soap_levels = load_soap_section_levels()
|
||
soap_instr = get_soap_section_instruction(soap_levels)
|
||
if soap_instr:
|
||
sys_parts.append(soap_instr)
|
||
_vis = load_soap_visibility()
|
||
_vis_instr = get_soap_visibility_instruction(_vis)
|
||
if _vis_instr:
|
||
sys_parts.append(_vis_instr)
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": "\n\n".join(sys_parts)},
|
||
{"role": "user", "content": kg_text},
|
||
],
|
||
)
|
||
result = strip_kg_warnings(resp.choices[0].message.content)
|
||
self.after(0, lambda: self._apply_kg_edit(result, f"Gekürzt (Stufe {new_level})"))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _kg_ausfuehrlicher(self):
|
||
"""Macht die aktuelle KG ausführlicher UND speichert die Stufe dauerhaft für zukünftige KG-Erstellungen."""
|
||
level = load_kg_detail_level()
|
||
new_level = min(3, level + 1)
|
||
save_kg_detail_level(new_level)
|
||
self._update_kg_detail_display()
|
||
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
if not kg_text:
|
||
self.set_status(f"KG-Stil gespeichert: Stufe +{new_level} (ausführlicher). Nächste KG wird ausführlicher erstellt.")
|
||
return
|
||
if not self.ensure_ready():
|
||
return
|
||
self.set_status(f"KG wird ausführlicher (Stufe +{new_level})")
|
||
|
||
def worker():
|
||
try:
|
||
model = self.model_var.get().strip() or DEFAULT_SUMMARY_MODEL
|
||
if model not in ALLOWED_SUMMARY_MODELS:
|
||
model = DEFAULT_SUMMARY_MODEL
|
||
|
||
template = load_templates_text().strip()
|
||
sys_parts = []
|
||
if template:
|
||
sys_parts.append(
|
||
"ZWINGENDE VORLAGE DES ARZTES (höchste Priorität, MUSS eingehalten werden):\n" + template)
|
||
sys_parts.append(
|
||
"Du bist ein ärztlicher Dokumentationsassistent (Deutsch).\n"
|
||
"Formuliere die folgende Krankengeschichte ausführlicher. "
|
||
"Wandle vorhandene Stichpunkte in vollständige Sätze um. "
|
||
"STRIKT NUR vorhandene Informationen ausführlicher ausformulieren "
|
||
"KEINE neuen Fakten, Befunde, Werte, Mengenangaben oder klinische Details erfinden! "
|
||
"Nichts hinzufügen, was nicht bereits im Text steht. "
|
||
"Behalte die SOAP-Struktur bei "
|
||
"(Anamnese, Subjektiv, Objektiv, Beurteilung, Diagnose mit ICD-10-GM, Therapie, Procedere). "
|
||
"Diagnosen mit ICD-10-Codes in eckigen Klammern beibehalten. "
|
||
"Verwende \u2022 statt - als Aufzählungszeichen. "
|
||
"Jeder Aufzählungspunkt mit 3 Leerzeichen eingerückt. "
|
||
"Überschriften OHNE Doppelpunkt, OHNE Einrückung. "
|
||
"Nach JEDER Abschnittsüberschrift folgt EINE Leerzeile, dann die eingerückten Aufzählungspunkte. "
|
||
"Zwischen den Aufzählungspunkten innerhalb eines Abschnitts KEINE Leerzeile die Punkte folgen direkt untereinander. "
|
||
"Zwischen dem letzten Punkt eines Abschnitts und der nächsten Überschrift EINE Leerzeile. "
|
||
"Keine Sternchen (*). "
|
||
"Keine Meta-Kommentare. Ausgabe: nur die ausführlichere KG."
|
||
)
|
||
soap_levels = load_soap_section_levels()
|
||
soap_instr = get_soap_section_instruction(soap_levels)
|
||
if soap_instr:
|
||
sys_parts.append(soap_instr)
|
||
_vis = load_soap_visibility()
|
||
_vis_instr = get_soap_visibility_instruction(_vis)
|
||
if _vis_instr:
|
||
sys_parts.append(_vis_instr)
|
||
|
||
user_content = f"KRANKENGESCHICHTE:\n{kg_text}"
|
||
if transcript:
|
||
user_content += f"\n\nORIGINAL-TRANSKRIPT (als Quelle für Details):\n{transcript}"
|
||
|
||
resp = self.call_chat_completion(
|
||
model=model,
|
||
messages=[
|
||
{"role": "system", "content": "\n\n".join(sys_parts)},
|
||
{"role": "user", "content": user_content},
|
||
],
|
||
)
|
||
result = strip_kg_warnings(resp.choices[0].message.content)
|
||
self.after(0, lambda: self._apply_kg_edit(result, f"Ausführlicher (Stufe +{new_level})"))
|
||
except Exception as e:
|
||
self.after(0, lambda: self.set_status("Fehler."))
|
||
self.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=self))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _apply_kg_edit(self, new_kg: str, label: str):
|
||
"""Wendet eine KG-Bearbeitung an (Kürzer/Ausführlicher) und zeigt das Ergebnis."""
|
||
if new_kg and new_kg.strip():
|
||
self.txt_output.delete("1.0", "end")
|
||
self.txt_output.insert("1.0", new_kg.strip())
|
||
try:
|
||
save_to_ablage("KG", new_kg.strip())
|
||
except Exception:
|
||
pass
|
||
self.set_status(f"KG {label}.")
|
||
else:
|
||
self.set_status("Keine Änderung erhalten.")
|
||
|
||
def _open_kg_vorlage(self):
|
||
"""Öffnet ein Fenster, in dem der Arzt eine Vorlage für die KG-Erstellung definieren kann."""
|
||
VOR_W, VOR_H = 540, 620
|
||
win = tk.Toplevel(self)
|
||
win.title("Vorlage KG-Erstellung")
|
||
win.transient(self)
|
||
win.minsize(420, 500)
|
||
win.configure(bg="#E8F4FA")
|
||
win.attributes("-topmost", True)
|
||
self._register_window(win)
|
||
add_resize_grip(win, 420, 500)
|
||
|
||
saved_geom = load_toplevel_geometry("kg_vorlage")
|
||
if saved_geom:
|
||
win.geometry(saved_geom)
|
||
else:
|
||
win.geometry(f"{VOR_W}x{VOR_H}")
|
||
center_window(win, VOR_W, VOR_H)
|
||
|
||
def _on_close():
|
||
try:
|
||
save_toplevel_geometry("kg_vorlage", win.geometry())
|
||
except Exception:
|
||
pass
|
||
win.destroy()
|
||
|
||
win.protocol("WM_DELETE_WINDOW", _on_close)
|
||
win.bind("<Configure>", lambda e: save_toplevel_geometry("kg_vorlage", win.geometry()))
|
||
|
||
# Header
|
||
header = tk.Frame(win, bg="#C8E8C8")
|
||
header.pack(fill="x")
|
||
tk.Label(header, text=" Vorlage für KG-Erstellung", font=("Segoe UI", 12, "bold"),
|
||
bg="#C8E8C8", fg="#2A5A2A").pack(padx=12, pady=8)
|
||
|
||
# SOAP-Reihenfolge mit Profilen
|
||
order_frame = tk.LabelFrame(win, text=" Abschnitts-Reihenfolge ", font=("Segoe UI", 9, "bold"),
|
||
bg="#E8F4FA", fg="#1a4d6d", padx=10, pady=6)
|
||
order_frame.pack(fill="x", padx=14, pady=(10, 4))
|
||
|
||
presets_data = load_soap_presets()
|
||
active_preset_idx = [presets_data.get("active", 0)]
|
||
|
||
profile_bar = tk.Frame(order_frame, bg="#E8F4FA")
|
||
profile_bar.pack(fill="x", pady=(0, 4))
|
||
|
||
_profile_btns = []
|
||
_PROF_ACTIVE = {"bg": "#1a4d6d", "fg": "white", "font": ("Segoe UI", 9, "bold")}
|
||
_PROF_INACTIVE = {"bg": "#D4EEF7", "fg": "#1a4d6d", "font": ("Segoe UI", 9)}
|
||
|
||
order_list = []
|
||
current_visibility = {}
|
||
visibility_vars = {}
|
||
row_widgets = []
|
||
drag_info = {"active": False, "src_idx": -1, "num_labels": []}
|
||
|
||
list_frame = tk.Frame(order_frame, bg="#E8F4FA")
|
||
list_frame.pack(fill="x")
|
||
|
||
def _load_preset(idx):
|
||
active_preset_idx[0] = idx
|
||
preset = presets_data["presets"][idx]
|
||
order_list.clear()
|
||
order_list.extend(preset.get("order", list(DEFAULT_SOAP_ORDER)))
|
||
current_visibility.clear()
|
||
vis = preset.get("visibility", {})
|
||
current_visibility.update({k: vis.get(k, True) for k in DEFAULT_SOAP_ORDER})
|
||
for k in DEFAULT_SOAP_ORDER:
|
||
if k in visibility_vars:
|
||
visibility_vars[k].set(current_visibility.get(k, True))
|
||
else:
|
||
visibility_vars[k] = tk.BooleanVar(value=current_visibility.get(k, True))
|
||
for i, btn in enumerate(_profile_btns):
|
||
marker = "\u25cf " if i == idx else "\u25cb "
|
||
btn.configure(text=f" {marker}Profil {i+1} ",
|
||
**(_PROF_ACTIVE if i == idx else _PROF_INACTIVE))
|
||
_rebuild_order_ui()
|
||
|
||
def _save_current_to_preset():
|
||
idx = active_preset_idx[0]
|
||
presets_data["presets"][idx]["order"] = list(order_list)
|
||
presets_data["presets"][idx]["visibility"] = dict(current_visibility)
|
||
presets_data["active"] = idx
|
||
|
||
for pi in range(NUM_SOAP_PRESETS):
|
||
is_active = (pi == active_preset_idx[0])
|
||
style = _PROF_ACTIVE if is_active else _PROF_INACTIVE
|
||
marker = "\u25cf " if is_active else "\u25cb "
|
||
btn = tk.Label(profile_bar, text=f" {marker}Profil {pi+1} ", cursor="hand2",
|
||
padx=10, pady=3, **style)
|
||
btn.pack(side="left", padx=(0, 4))
|
||
btn.bind("<Button-1>", lambda e, i=pi: _load_preset(i))
|
||
btn.bind("<Enter>", lambda e, b=btn, i=pi: b.configure(
|
||
bg="#2a6a8d" if i == active_preset_idx[0] else "#B8DDE8"))
|
||
btn.bind("<Leave>", lambda e, b=btn, i=pi: b.configure(
|
||
**(_PROF_ACTIVE if i == active_preset_idx[0] else _PROF_INACTIVE)))
|
||
_profile_btns.append(btn)
|
||
|
||
tk.Label(order_frame, text="Drag-and-Drop · Häkchen = in KG anzeigen · nur aktives Profil wird verwendet:",
|
||
font=("Segoe UI", 8), bg="#E8F4FA", fg="#4a8aaa").pack(anchor="w", pady=(0, 4))
|
||
|
||
def _on_visibility_toggle(key):
|
||
current_visibility[key] = visibility_vars[key].get()
|
||
_rebuild_order_ui()
|
||
|
||
def _drag_start(event, idx):
|
||
drag_info["active"] = True
|
||
drag_info["src_idx"] = idx
|
||
row_widgets[idx].configure(highlightbackground="#FFA500", highlightthickness=2)
|
||
|
||
def _drag_motion(event):
|
||
if not drag_info["active"]:
|
||
return
|
||
src = drag_info["src_idx"]
|
||
y = event.y_root
|
||
target = src
|
||
for i, row in enumerate(row_widgets):
|
||
try:
|
||
ry = row.winfo_rooty()
|
||
rh = row.winfo_height()
|
||
if ry <= y < ry + rh:
|
||
target = i
|
||
break
|
||
except Exception:
|
||
pass
|
||
if target == src:
|
||
return
|
||
item = order_list.pop(src)
|
||
order_list.insert(target, item)
|
||
row = row_widgets.pop(src)
|
||
row_widgets.insert(target, row)
|
||
nlbl = drag_info["num_labels"].pop(src)
|
||
drag_info["num_labels"].insert(target, nlbl)
|
||
for w in row_widgets:
|
||
w.pack_forget()
|
||
for i, w in enumerate(row_widgets):
|
||
w.pack(fill="x", pady=1)
|
||
for i, lbl in enumerate(drag_info["num_labels"]):
|
||
lbl.configure(text=f"{i + 1}.")
|
||
for w in row_widgets:
|
||
w.configure(highlightbackground="#B0D4E8", highlightthickness=1)
|
||
row_widgets[target].configure(highlightbackground="#FFA500", highlightthickness=2)
|
||
drag_info["src_idx"] = target
|
||
|
||
def _drag_end(event):
|
||
if drag_info["active"]:
|
||
drag_info["active"] = False
|
||
_rebuild_order_ui()
|
||
|
||
def _rebuild_order_ui():
|
||
for w in list_frame.winfo_children():
|
||
w.destroy()
|
||
row_widgets.clear()
|
||
drag_info["num_labels"] = []
|
||
for idx, key in enumerate(order_list):
|
||
vis = visibility_vars.get(key, tk.BooleanVar(value=True)).get()
|
||
row_bg = "white" if vis else "#F0F0F0"
|
||
fg_color = "#1a4d6d" if vis else "#AAAAAA"
|
||
row = tk.Frame(list_frame, bg=row_bg, highlightbackground="#B0D4E8",
|
||
highlightthickness=1, padx=4, pady=2)
|
||
row.pack(fill="x", pady=1)
|
||
handle = tk.Label(row, text="", font=("Segoe UI", 10),
|
||
bg=row_bg, fg="#B0D4E8", cursor="fleur", padx=2)
|
||
handle.pack(side="left", padx=(2, 4))
|
||
cb = tk.Checkbutton(row, variable=visibility_vars.get(key),
|
||
bg=row_bg, activebackground=row_bg,
|
||
command=lambda k=key: _on_visibility_toggle(k))
|
||
cb.pack(side="left", padx=(0, 0))
|
||
num_lbl = tk.Label(row, text=f"{idx + 1}.", font=("Segoe UI", 10, "bold"),
|
||
bg=row_bg, fg=fg_color, width=2)
|
||
num_lbl.pack(side="left", padx=(2, 2))
|
||
drag_info["num_labels"].append(num_lbl)
|
||
name_lbl = tk.Label(row, text=f"{key} {_SOAP_LABELS.get(key, key)}",
|
||
font=("Segoe UI", 10), bg=row_bg, fg=fg_color, anchor="w")
|
||
name_lbl.pack(side="left", fill="x", expand=True, padx=4)
|
||
for widget in (handle, name_lbl, num_lbl, row):
|
||
widget.bind("<ButtonPress-1>", lambda e, i=idx: _drag_start(e, i))
|
||
widget.bind("<B1-Motion>", _drag_motion)
|
||
widget.bind("<ButtonRelease-1>", _drag_end)
|
||
handle.bind("<Enter>", lambda e, h=handle: h.configure(fg="#1a4d6d"))
|
||
handle.bind("<Leave>", lambda e, h=handle: h.configure(fg="#B0D4E8"))
|
||
row_widgets.append(row)
|
||
|
||
_load_preset(active_preset_idx[0])
|
||
|
||
def _reset_order():
|
||
order_list.clear()
|
||
order_list.extend(DEFAULT_SOAP_ORDER)
|
||
for k in visibility_vars:
|
||
visibility_vars[k].set(True)
|
||
current_visibility[k] = True
|
||
_rebuild_order_ui()
|
||
|
||
reset_order_btn = tk.Label(order_frame, text=" Standard-Reihenfolge", font=("Segoe UI", 8),
|
||
bg="#E8F4FA", fg="#7EC8E3", cursor="hand2")
|
||
reset_order_btn.pack(anchor="e", pady=(4, 0))
|
||
reset_order_btn.bind("<Button-1>", lambda e: _reset_order())
|
||
reset_order_btn.bind("<Enter>", lambda e: reset_order_btn.configure(fg="#1a4d6d"))
|
||
reset_order_btn.bind("<Leave>", lambda e: reset_order_btn.configure(fg="#7EC8E3"))
|
||
|
||
# Freitext-Vorlage
|
||
tk.Label(win, text="Zusätzliche Anweisungen für die KI (optional):",
|
||
font=("Segoe UI", 9, "bold"), bg="#E8F4FA", fg="#1a4d6d"
|
||
).pack(anchor="w", padx=14, pady=(8, 2))
|
||
|
||
example_frame = tk.Frame(win, bg="#F5FBF5", padx=10, pady=4)
|
||
example_frame.pack(fill="x", padx=14, pady=(0, 4))
|
||
tk.Label(example_frame,
|
||
text="z.B. «Fachrichtung: Dermatologie» · «Procedere immer mit Kontrolltermin» · «Kurz und stichpunktartig»",
|
||
font=("Segoe UI", 7), bg="#F5FBF5", fg="#4A7A4A",
|
||
wraplength=460, justify="left").pack(anchor="w")
|
||
|
||
txt_frame = tk.Frame(win, bg="#E8F4FA", padx=14)
|
||
txt_frame.pack(fill="both", expand=True, pady=(0, 4))
|
||
vorlage_text = tk.Text(txt_frame, font=("Segoe UI", 11), bg="white",
|
||
fg="#1a4d6d", relief="flat", bd=0, wrap="word",
|
||
insertbackground="#1a4d6d", padx=8, pady=6, height=5)
|
||
vorlage_text.pack(fill="both", expand=True)
|
||
|
||
current = load_templates_text()
|
||
if current:
|
||
vorlage_text.insert("1.0", current)
|
||
|
||
status_var = tk.StringVar(value="")
|
||
|
||
# Buttons
|
||
btn_frame = tk.Frame(win, bg="#D4EEF7", padx=14, pady=8)
|
||
btn_frame.pack(fill="x")
|
||
|
||
def _save():
|
||
text = vorlage_text.get("1.0", "end-1c").strip()
|
||
save_templates_text(text)
|
||
_save_current_to_preset()
|
||
save_soap_presets(presets_data)
|
||
self._rebuild_soap_section_controls()
|
||
status_var.set(f" Profil {active_preset_idx[0]+1} + Vorlage gespeichert.")
|
||
|
||
def _clear():
|
||
vorlage_text.delete("1.0", "end")
|
||
save_templates_text("")
|
||
_reset_order()
|
||
_save_current_to_preset()
|
||
save_soap_presets(presets_data)
|
||
self._rebuild_soap_section_controls()
|
||
status_var.set("Alles zurückgesetzt Standard-Format wird verwendet.")
|
||
|
||
def _save_and_close():
|
||
_save()
|
||
_on_close()
|
||
transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
if transcript:
|
||
self._recompute_kg_inline()
|
||
|
||
save_btn = tk.Button(btn_frame, text=" Speichern", font=("Segoe UI", 11, "bold"),
|
||
bg="#5BDB7B", fg="#1a4d6d", activebackground="#4BCB6B",
|
||
relief="flat", bd=0, padx=20, pady=6, cursor="hand2",
|
||
command=_save)
|
||
save_btn.pack(side="left", padx=(0, 6))
|
||
tk.Button(btn_frame, text="Speichern & Schliessen", font=("Segoe UI", 9),
|
||
bg="#7EC8E3", fg="#1a4d6d", activebackground="#6CB8D3",
|
||
relief="flat", bd=0, padx=12, pady=4, cursor="hand2",
|
||
command=_save_and_close).pack(side="left", padx=(0, 6))
|
||
tk.Button(btn_frame, text="Zurücksetzen", font=("Segoe UI", 9),
|
||
bg="#E0E0E0", fg="#666", activebackground="#D0D0D0",
|
||
relief="flat", bd=0, padx=10, pady=4, cursor="hand2",
|
||
command=_clear).pack(side="left", padx=(0, 6))
|
||
tk.Button(btn_frame, text="Schliessen", font=("Segoe UI", 9),
|
||
bg="#C8DDE6", fg="#1a4d6d", activebackground="#B8CDD6",
|
||
relief="flat", bd=0, padx=10, pady=4, cursor="hand2",
|
||
command=_on_close).pack(side="right")
|
||
|
||
tk.Label(win, textvariable=status_var, font=("Segoe UI", 8, "bold"),
|
||
bg="#E8F4FA", fg="#2A7A2A").pack(fill="x", padx=14, pady=(0, 4))
|
||
|
||
def _rebuild_textblock_buttons(self):
|
||
"""Erstellt die Textblock-Buttons neu basierend auf _textbloecke_data."""
|
||
for w in self._textbloecke_rows_frame.winfo_children():
|
||
w.destroy()
|
||
self._textbloecke_buttons = []
|
||
slots = sorted(self._textbloecke_data.keys(), key=int)
|
||
for i in range(0, len(slots), 2):
|
||
row_slots = slots[i : i + 2]
|
||
block_row = ttk.Frame(self._textbloecke_rows_frame, padding=(0, 4, 0, 0))
|
||
block_row.pack(fill="x")
|
||
block_inner = ttk.Frame(block_row)
|
||
block_inner.pack(anchor="center")
|
||
for slot_str in row_slots:
|
||
btn = RoundedButton(
|
||
block_inner, self._textblock_label(slot_str),
|
||
command=None,
|
||
bg="#e1f6fc", fg="#1a4d6d", active_bg="#B8E8F4", width=70, height=26, canvas_bg="#B9ECFA",
|
||
radius=0,
|
||
)
|
||
btn._textblock_slot = slot_str
|
||
def make_click(s):
|
||
return lambda: self._copy_textblock(s)
|
||
btn.configure(command=make_click(slot_str))
|
||
def on_btn_press(e, s):
|
||
self._textblock_copy_to_clipboard(s)
|
||
btn.bind("<ButtonPress-1>", lambda e, s=slot_str: on_btn_press(e, s), add="+")
|
||
btn.pack(side="left", padx=(0, 4))
|
||
def make_ctx(s):
|
||
return lambda e: self._textblock_context(e, s)
|
||
def make_save_clip(s):
|
||
return lambda e: self._textblock_save_from_clipboard_or_selection(s)
|
||
btn.bind("<Button-3>", make_ctx(slot_str))
|
||
btn.bind("<Shift-Button-1>", lambda e, s=slot_str: make_save_clip(s)(e))
|
||
self._textbloecke_buttons.append((slot_str, btn))
|
||
|
||
def _add_textblock(self):
|
||
"""Fügt einen Textblock hinzu wenn vorher mit - entfernt, wird der gespeicherte Inhalt wiederhergestellt."""
|
||
slots = sorted(self._textbloecke_data.keys(), key=int)
|
||
next_num = int(slots[-1]) + 1 if slots else 1
|
||
slot_str = str(next_num)
|
||
if self._removed_textbloecke:
|
||
restored = self._removed_textbloecke.pop()
|
||
self._textbloecke_data[slot_str] = {"name": restored.get("name", f"Textblock {slot_str}"), "content": restored.get("content", "")}
|
||
else:
|
||
self._textbloecke_data[slot_str] = {"name": f"Textblock {slot_str}", "content": ""}
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._rebuild_textblock_buttons()
|
||
self.set_status(f"Textblock {slot_str} hinzugefügt.")
|
||
|
||
def _remove_textblock(self):
|
||
"""Entfernt den letzten Textblock. Inhalt wird gespeichert für spätere Wiederverwendung mit +."""
|
||
slots = sorted(self._textbloecke_data.keys(), key=int)
|
||
if len(slots) <= 2:
|
||
self.set_status("Mindestens 2 Textblöcke müssen bestehen bleiben.")
|
||
return
|
||
last = slots[-1]
|
||
self._removed_textbloecke.append(dict(self._textbloecke_data[last]))
|
||
del self._textbloecke_data[last]
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._rebuild_textblock_buttons()
|
||
self.set_status(f"Textblock {last} entfernt (Inhalt für + gespeichert).")
|
||
|
||
def _textblock_label(self, slot: str) -> str:
|
||
"""Anzeigetext für einen Textblock-Button (Name oder gekürzter Inhalt)."""
|
||
d = self._textbloecke_data.get(slot) or {"name": "", "content": ""}
|
||
name = (d.get("name") or "").strip()
|
||
content = (d.get("content") or "").strip()
|
||
max_len = 12
|
||
if name and name != f"Textblock {slot}":
|
||
return (name[:max_len] + "") if len(name) > max_len else name
|
||
if content:
|
||
return (content[:max_len] + "") if len(content) > max_len else content
|
||
return f"Textblock {slot}"
|
||
|
||
def _refresh_textblock_button(self, slot: str):
|
||
"""Aktualisiert den angezeigten Text eines Textblock-Buttons."""
|
||
for s, btn in self._textbloecke_buttons:
|
||
if s == slot:
|
||
btn.configure(text=self._textblock_label(slot))
|
||
break
|
||
|
||
# Typische SOAP-/KG-Überschriften für Abschnitts-Kopieren (Doppelklick)
|
||
_KG_SECTION_HEADERS = (
|
||
"subjektiv", "objektiv", "diagnose", "diagnosen", "beurteilung",
|
||
"therapie", "procedere", "anlass", "befunde", "empfehlung",
|
||
"assessment", "plan", "befund", "verlauf", "medikation",
|
||
)
|
||
|
||
def _get_kg_section_at_cursor(self, text_widget, index_override: str | None = None):
|
||
"""Wenn Cursor auf einer Abschnittsüberschrift steht: (start, end, text) des Abschnitts, sonst None."""
|
||
try:
|
||
insert = text_widget.index(index_override or tk.INSERT)
|
||
line_no = int(insert.split(".")[0])
|
||
line_start = f"{line_no}.0"
|
||
line_end = f"{line_no}.end"
|
||
line_text = (text_widget.get(line_start, line_end) or "").strip()
|
||
if not line_text:
|
||
return None
|
||
line_lower = line_text.lower().strip()
|
||
# Erlaubt:
|
||
# - "Therapie"
|
||
# - "Therapie:"
|
||
# - "1. Therapie"
|
||
# - "1. Therapie:"
|
||
if line_lower.endswith(":"):
|
||
head = line_lower.rstrip(":").strip()
|
||
elif "." in line_lower and line_lower.split(".", 1)[0].strip().isdigit():
|
||
head = line_lower.split(".", 1)[1].strip().rstrip(":").strip()
|
||
else:
|
||
head = line_lower
|
||
if not any(h == head or head.startswith(h + " ") or head.startswith(h + ":") for h in self._KG_SECTION_HEADERS):
|
||
return None
|
||
content = text_widget.get("1.0", "end")
|
||
lines = content.split("\n")
|
||
start_line = line_no
|
||
end_line = len(lines)
|
||
|
||
def _is_section_header(ln_text):
|
||
"""Prüft ob eine Zeile eine bekannte SOAP-/KG-Abschnittsüberschrift ist."""
|
||
lt = (ln_text or "").strip().lower()
|
||
if not lt:
|
||
return False
|
||
if lt.endswith(":"):
|
||
h2 = lt.rstrip(":").strip()
|
||
elif "." in lt and lt.split(".", 1)[0].strip().isdigit():
|
||
h2 = lt.split(".", 1)[1].strip().rstrip(":").strip()
|
||
else:
|
||
h2 = lt
|
||
return any(k == h2 or h2.startswith(k + " ") or h2.startswith(k + ":") for k in self._KG_SECTION_HEADERS)
|
||
|
||
for i in range(start_line + 1, len(lines) + 1):
|
||
if i > len(lines):
|
||
break
|
||
if _is_section_header(lines[i - 1]):
|
||
end_line = i - 1
|
||
break
|
||
|
||
while end_line > start_line and not (lines[end_line - 1] or "").strip():
|
||
end_line -= 1
|
||
|
||
section_text = "\n".join(lines[start_line - 1 : end_line]).rstrip()
|
||
if not section_text:
|
||
return None
|
||
start_idx = f"{start_line}.0"
|
||
end_idx = f"{end_line}.end"
|
||
return (start_idx, end_idx, section_text)
|
||
except (tk.TclError, AttributeError, ValueError, IndexError):
|
||
return None
|
||
|
||
def _bind_kg_section_copy(self, text_widget):
|
||
"""Doppelklick auf eine SOAP-Überschrift: ganzen Abschnitt in Zwischenablage kopieren."""
|
||
def on_double_click(event):
|
||
click_index = None
|
||
try:
|
||
click_index = text_widget.index(f"@{event.x},{event.y}")
|
||
except Exception:
|
||
pass
|
||
result = self._get_kg_section_at_cursor(text_widget, click_index)
|
||
if result:
|
||
_, _, section_text = result
|
||
try:
|
||
if not _win_clipboard_set(section_text):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(section_text))
|
||
# Falls globales Rechtsklick-Paste deaktiviert wurde: einmaliges Paste-Fenster aktivieren.
|
||
try:
|
||
self._arm_one_click_external_paste()
|
||
except Exception:
|
||
pass
|
||
self.set_status("Abschnitt kopiert.")
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
text_widget.bind("<Double-Button-1>", on_double_click, add="+")
|
||
|
||
def _bind_text_context_menu(self, text_widget):
|
||
"""Rechtsklick-Menü: Kopieren, Einfügen, Markierung in Textblock speichern."""
|
||
def on_right_click(event):
|
||
menu = tk.Menu(text_widget, tearoff=0)
|
||
menu.add_command(
|
||
label="Kopieren",
|
||
command=lambda: _do_copy(),
|
||
)
|
||
menu.add_command(
|
||
label="Einfügen",
|
||
command=lambda: _do_paste(),
|
||
)
|
||
slots = sorted(getattr(self, "_textbloecke_data", {}).keys())
|
||
if slots:
|
||
save_menu = tk.Menu(menu, tearoff=0)
|
||
for slot in slots:
|
||
lbl = self._textblock_label(slot)
|
||
save_menu.add_command(
|
||
label=lbl,
|
||
command=lambda s=slot, w=text_widget: _do_save_to_textblock(s, w),
|
||
)
|
||
menu.add_cascade(label="Markierung in Textblock speichern", menu=save_menu)
|
||
try:
|
||
menu.tk_popup(event.x_root, event.y_root)
|
||
finally:
|
||
menu.grab_release()
|
||
|
||
def _do_copy():
|
||
try:
|
||
if hasattr(text_widget, "selection_present") and text_widget.selection_present():
|
||
sel = text_widget.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||
if not _win_clipboard_set(sel):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(sel))
|
||
self.set_status("Kopiert.")
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
def _do_paste():
|
||
try:
|
||
text = self.clipboard_get()
|
||
if isinstance(text, str):
|
||
text_widget.insert(tk.INSERT, text)
|
||
self.set_status("Eingefügt.")
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
def _do_save_to_textblock(slot, w):
|
||
try:
|
||
if hasattr(w, "selection_present") and w.selection_present():
|
||
text = w.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||
else:
|
||
text = self._get_selection_from_focus()
|
||
except (tk.TclError, AttributeError):
|
||
text = ""
|
||
if not (text or "").strip():
|
||
self.set_status("Keine Markierung zuerst Text markieren.")
|
||
return
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Auswahl in Textblock gespeichert.")
|
||
|
||
text_widget.bind("<Button-3>", on_right_click)
|
||
|
||
def _bind_textblock_pending(self, text_widget):
|
||
"""Wie textbloecke.py: ButtonPress-1 = Auswahl für Drag; Cursorposition ständig aktualisieren für Einfügen."""
|
||
if not hasattr(self, "_textblock_drag_data"):
|
||
self._textblock_drag_data = {"text": None, "active": False}
|
||
self._bind_text_context_menu(text_widget)
|
||
|
||
def _save_as_last():
|
||
"""Speichert Widget + Cursorposition wird vor Klick auf Textblock-Button gesichert."""
|
||
try:
|
||
if text_widget.winfo_exists():
|
||
self._last_focused_text_widget = text_widget
|
||
self._last_insert_index = text_widget.index(tk.INSERT)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
text_widget.bind("<FocusIn>", lambda e: _save_as_last(), add="+")
|
||
text_widget.bind("<FocusOut>", lambda e: _save_as_last(), add="+")
|
||
text_widget.bind("<KeyRelease>", lambda e: _save_as_last(), add="+")
|
||
text_widget.bind("<ButtonRelease-1>", lambda e: _save_as_last(), add="+")
|
||
|
||
def get_selection_from_widget(w):
|
||
"""Wie textbloecke.py get_selection(): Auswahl per tag_nextrange('sel')."""
|
||
try:
|
||
sel = w.tag_nextrange("sel", "1.0")
|
||
if sel:
|
||
return w.get(*sel)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
try:
|
||
if hasattr(w, "selection_present") and w.selection_present():
|
||
return w.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
return None
|
||
|
||
def start_drag(event):
|
||
"""Bei gedrückter Maustaste im Textfeld: Auswahl sichern (wie textbloecke.py start_drag)."""
|
||
s = get_selection_from_widget(text_widget)
|
||
if s and (s or "").strip():
|
||
self._textblock_drag_data["text"] = s
|
||
self._textblock_drag_data["active"] = False
|
||
else:
|
||
self._textblock_drag_data["text"] = None
|
||
self._textblock_drag_data["active"] = False
|
||
text_widget.bind("<ButtonPress-1>", start_drag, add="+")
|
||
text_widget.bind("<B1-Motion>", self._textblock_on_drag_motion, add="+")
|
||
text_widget.bind("<ButtonRelease-1>", self._on_global_drag_release, add="+")
|
||
self._bind_autotext(text_widget)
|
||
|
||
def _autotext_debug(self, msg: str):
|
||
if (os.environ.get("AZA_AUTOTEXT_DEBUG") or "").strip().lower() not in (
|
||
"1", "yes", "true", "on",
|
||
):
|
||
return
|
||
try:
|
||
print(f"[AZA][autotext] {msg}", file=sys.stderr)
|
||
except Exception:
|
||
pass
|
||
|
||
def _lifecycle_shutdown_debug(self, msg: str):
|
||
if (os.environ.get("AZA_LIFECYCLE_DEBUG") or "").strip().lower() not in (
|
||
"1",
|
||
"yes",
|
||
"true",
|
||
"on",
|
||
):
|
||
return
|
||
try:
|
||
print(f"[AZA][lifecycle] {msg}", file=sys.stderr)
|
||
except Exception:
|
||
pass
|
||
def _widget_hosted_in_autotext_binding_set(self, w):
|
||
"""True, wenn Widget (oder ein Eltern-Frame wie ScrolledText) In-App-Binding hat."""
|
||
bucket = getattr(self, "_autotext_in_app_widgets", None)
|
||
if not bucket or w is None:
|
||
return False
|
||
cur = w
|
||
seen = set()
|
||
while cur is not None and id(cur) not in seen:
|
||
seen.add(id(cur))
|
||
try:
|
||
if cur in bucket:
|
||
return True
|
||
except (tk.TclError, TypeError):
|
||
return False
|
||
cur = getattr(cur, "master", None)
|
||
return False
|
||
|
||
def _sync_autotext_focus_for_global(self):
|
||
"""True in _autotext_focus_in_app -> globaler Listener expandiert nicht (In-App uebernimmt)."""
|
||
try:
|
||
import ctypes
|
||
fg = ctypes.windll.user32.GetForegroundWindow()
|
||
pid_c = ctypes.c_ulong()
|
||
ctypes.windll.user32.GetWindowThreadProcessId(fg, ctypes.byref(pid_c))
|
||
if pid_c.value != os.getpid():
|
||
self._autotext_focus_in_app[0] = False
|
||
self._autotext_debug("sync: fremde PID -> global darf")
|
||
return
|
||
w = self.focus_get()
|
||
in_app = self._widget_hosted_in_autotext_binding_set(w)
|
||
self._autotext_focus_in_app[0] = in_app
|
||
self._autotext_debug(
|
||
f"sync: eigenes Fenster, in_app_binding={in_app} w={type(w).__name__}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _check_autotext_focus_out(self):
|
||
"""Nach Fokuswechsel Flag mit Vordergrund+Fokus abgleichen."""
|
||
self._sync_autotext_focus_for_global()
|
||
|
||
def _bind_autotext(self, text_widget):
|
||
"""In-App-Autotext: koordiniert mit globalem pynput-Listener ueber
|
||
_sync_autotext_focus_for_global() / _autotext_in_app_widgets."""
|
||
if not hasattr(self, "_autotext_in_app_widgets"):
|
||
self._autotext_in_app_widgets = set()
|
||
self._autotext_in_app_widgets.add(text_widget)
|
||
|
||
def _unreg_autotext_widget(_e=None):
|
||
try:
|
||
self._autotext_in_app_widgets.discard(text_widget)
|
||
except Exception:
|
||
pass
|
||
|
||
text_widget.bind("<Destroy>", _unreg_autotext_widget, add="+")
|
||
|
||
AUTOTEXT_TERMINATORS = " \n\t,.;:!?)\"]"
|
||
|
||
def on_focus_in(event):
|
||
self._autotext_focus_in_app[0] = True
|
||
self.after(20, self._sync_autotext_focus_for_global)
|
||
|
||
def on_focus_out(event):
|
||
self.after(50, self._check_autotext_focus_out)
|
||
|
||
text_widget.bind("<FocusIn>", on_focus_in, add="+")
|
||
text_widget.bind("<FocusOut>", on_focus_out, add="+")
|
||
|
||
_last_expansion = [0.0, "", ""]
|
||
_AUTOTEXT_DEDupe_s = 0.34
|
||
|
||
def on_keyrelease(event):
|
||
if getattr(self, "_autotext_injecting", [False])[0]:
|
||
self._autotext_debug("in_app: skip injecting")
|
||
return
|
||
if not getattr(self, "_autotext_data", {}).get("enabled", True):
|
||
return
|
||
entries = (self._autotext_data.get("entries") or {})
|
||
if not entries:
|
||
return
|
||
try:
|
||
text_widget.update_idletasks()
|
||
insert = text_widget.index(tk.INSERT)
|
||
text_before = text_widget.get("1.0", insert)
|
||
if not text_before:
|
||
return
|
||
last_char = text_before[-1]
|
||
if last_char not in AUTOTEXT_TERMINATORS:
|
||
return
|
||
word_end = len(text_before) - 1
|
||
i = word_end - 1
|
||
while i >= 0 and text_before[i] not in AUTOTEXT_TERMINATORS:
|
||
i -= 1
|
||
word_start = i + 1
|
||
word = text_before[word_start:word_end]
|
||
if not word:
|
||
return
|
||
expansion = entries.get(word)
|
||
if expansion is None:
|
||
for ek, ev in entries.items():
|
||
if ek.lower() == word.lower():
|
||
expansion = ev
|
||
word = ek
|
||
break
|
||
if not expansion:
|
||
return
|
||
now = time.time()
|
||
insert_s = str(insert)
|
||
if (
|
||
_last_expansion[1] == word
|
||
and str(_last_expansion[2]) == insert_s
|
||
and now - _last_expansion[0] < _AUTOTEXT_DEDupe_s
|
||
):
|
||
self._autotext_debug(f"in_app: dedupe blocked {word!r}")
|
||
return
|
||
_last_expansion[0] = now
|
||
_last_expansion[1] = word
|
||
_last_expansion[2] = insert_s
|
||
start_idx = text_widget.index(f"{insert} - {len(word) + 1} chars")
|
||
text_widget.delete(start_idx, insert)
|
||
text_widget.insert(start_idx, expansion + last_char)
|
||
self._autotext_debug(f"in_app: expanded {word!r}")
|
||
except (tk.TclError, AttributeError, IndexError, ValueError):
|
||
pass
|
||
|
||
_kr_scheduled = [False]
|
||
|
||
def schedule_in_app_check(_e=None):
|
||
if _kr_scheduled[0]:
|
||
return
|
||
_kr_scheduled[0] = True
|
||
|
||
def run():
|
||
_kr_scheduled[0] = False
|
||
try:
|
||
on_keyrelease(None)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
self.after_idle(lambda: self.after(14, run))
|
||
except Exception:
|
||
self.after(14, run)
|
||
|
||
text_widget.bind("<KeyRelease>", schedule_in_app_check, add="+")
|
||
|
||
def _run_global_autotext_listener(self):
|
||
"""Globaler Tastatur-Hook (Windows) fuer externe Autotext-Expansion.
|
||
|
||
===== FREEZE-SCHUTZ / NICHT VEREINFACHEN =====
|
||
Dieser Code funktioniert in Word, PowerShell, Notepad und
|
||
allen externen Apps. Folgende Regeln duerfen NICHT gebrochen
|
||
werden, sonst entfernt Windows den LowLevelHook still:
|
||
|
||
1. KEINE Disk-I/O in on_press/on_release (load_autotext etc.)
|
||
→ Stattdessen _cached_autotext() mit 5s RAM-Cache.
|
||
2. on_press/on_release muessen < 200ms sein (Windows Timeout).
|
||
3. Console-Fenster (PowerShell/Terminal) brauchen LAENGERE
|
||
Delays im Worker (0.04s/Key, 0.15s vor Paste).
|
||
4. GUI-Fenster (Word/Notepad) nutzen schnellere Timings.
|
||
5. Die _is_console_window()-Erkennung darf nicht entfernt werden.
|
||
6. REPLACE_DELAY darf nicht unter 0.12s sinken.
|
||
7. Der Restart-Loop (while True / except / sleep 1) ist noetig,
|
||
weil Windows den Hook bei Overload entfernt.
|
||
===============================================
|
||
"""
|
||
if not _HAS_PYNPUT:
|
||
return
|
||
stop_ev = getattr(self, "_autotext_hook_stop_event", None)
|
||
|
||
AUTOTEXT_TERMINATORS = " \n\t,.;:!?)\"]"
|
||
controller = KbdController()
|
||
buffer = self._autotext_global_buffer
|
||
replace_queue = []
|
||
queue_lock = threading.Lock()
|
||
REPLACE_DELAY = 0.15
|
||
|
||
_cache = {"data": None, "ts": 0}
|
||
|
||
def _cached_autotext():
|
||
now = time.time()
|
||
if _cache["data"] is None or now - _cache["ts"] > 5:
|
||
try:
|
||
_cache["data"] = load_autotext()
|
||
except Exception:
|
||
_cache["data"] = {}
|
||
_cache["ts"] = now
|
||
return _cache["data"]
|
||
|
||
def key_to_char(key):
|
||
try:
|
||
if key == Key.space:
|
||
return " "
|
||
if key == Key.enter:
|
||
return "\n"
|
||
if key == Key.tab:
|
||
return "\t"
|
||
if hasattr(key, "char") and key.char:
|
||
return key.char
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def key_to_terminator_char(key):
|
||
try:
|
||
if key == Key.space:
|
||
return " "
|
||
if key == Key.enter:
|
||
return "\n"
|
||
if key == Key.tab:
|
||
return "\t"
|
||
if hasattr(key, "vk") and key.vk == 13:
|
||
return "\n"
|
||
if hasattr(key, "char") and key.char and key.char in AUTOTEXT_TERMINATORS:
|
||
return key.char
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
def _is_console_window():
|
||
try:
|
||
import ctypes
|
||
fg = ctypes.windll.user32.GetForegroundWindow()
|
||
buf = ctypes.create_unicode_buffer(256)
|
||
ctypes.windll.user32.GetClassNameW(fg, buf, 256)
|
||
cls = buf.value
|
||
return ("Console" in cls or "CASCADIA" in cls
|
||
or "Terminal" in cls or "mintty" in cls)
|
||
except Exception:
|
||
return False
|
||
|
||
def worker():
|
||
while True:
|
||
if stop_ev is not None and stop_ev.is_set():
|
||
break
|
||
time.sleep(0.02)
|
||
with queue_lock:
|
||
if not replace_queue:
|
||
continue
|
||
n_back, text = replace_queue.pop(0)
|
||
injecting_ref = getattr(self, "_autotext_injecting", [False])
|
||
injecting_ref[0] = True
|
||
is_con = _is_console_window()
|
||
time.sleep(REPLACE_DELAY)
|
||
try:
|
||
saved = _win_clipboard_get()
|
||
if not _win_clipboard_set(text):
|
||
injecting_ref[0] = False
|
||
continue
|
||
_key_delay = 0.04 if is_con else 0.01
|
||
for _ in range(n_back):
|
||
controller.press(Key.backspace)
|
||
controller.release(Key.backspace)
|
||
if is_con:
|
||
time.sleep(_key_delay)
|
||
time.sleep(0.15 if is_con else 0.08)
|
||
with controller.pressed(Key.ctrl):
|
||
controller.tap(KeyCode.from_char("v"))
|
||
time.sleep(0.1 if is_con else 0.05)
|
||
if saved:
|
||
time.sleep(0.05)
|
||
_win_clipboard_set(saved)
|
||
except Exception as _w_exc:
|
||
try:
|
||
print(f"[AZA] Autotext-Worker: {_w_exc}", file=sys.stderr)
|
||
except Exception:
|
||
pass
|
||
injecting_ref[0] = False
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def on_press(key):
|
||
if stop_ev is not None and stop_ev.is_set():
|
||
return
|
||
if getattr(self, "_autotext_global_paused", False):
|
||
return
|
||
if getattr(self, "_autotext_injecting", [False])[0]:
|
||
return
|
||
try:
|
||
if key == Key.backspace:
|
||
if buffer:
|
||
buffer.pop()
|
||
else:
|
||
ch = key_to_char(key)
|
||
if ch:
|
||
buffer.append(ch)
|
||
if len(buffer) > 200:
|
||
buffer.pop(0)
|
||
except Exception:
|
||
pass
|
||
|
||
def on_release(key):
|
||
if stop_ev is not None and stop_ev.is_set():
|
||
return
|
||
if getattr(self, "_autotext_global_paused", False):
|
||
return
|
||
if getattr(self, "_autotext_injecting", [False])[0]:
|
||
return
|
||
if getattr(self, "_autotext_focus_in_app", [False])[0]:
|
||
return
|
||
tc = key_to_terminator_char(key)
|
||
if tc is None:
|
||
return
|
||
try:
|
||
data = _cached_autotext()
|
||
if not data.get("enabled", True):
|
||
return
|
||
entries = data.get("entries") or {}
|
||
if not entries or len(buffer) < 2:
|
||
return
|
||
i = len(buffer) - 2
|
||
while i >= 0 and buffer[i] not in AUTOTEXT_TERMINATORS:
|
||
i -= 1
|
||
word = "".join(buffer[i + 1 : -1])
|
||
if not word:
|
||
return
|
||
expansion = entries.get(word)
|
||
if expansion is None:
|
||
for ek, ev in entries.items():
|
||
if ek.lower() == word.lower():
|
||
expansion = ev
|
||
break
|
||
if not expansion:
|
||
return
|
||
n_back = len(word) + 1
|
||
text = expansion + tc
|
||
with queue_lock:
|
||
replace_queue.append((n_back, text))
|
||
del buffer[-(len(word) + 1) :]
|
||
buffer.extend(list(expansion + tc))
|
||
while len(buffer) > 200:
|
||
buffer.pop(0)
|
||
except Exception:
|
||
pass
|
||
|
||
while stop_ev is None or not stop_ev.is_set():
|
||
listener = None
|
||
try:
|
||
self._lifecycle_shutdown_debug("autotext listener started")
|
||
listener = KbdListener(on_press=on_press, on_release=on_release)
|
||
listener.start()
|
||
with self._autotext_kbd_listener_lock:
|
||
self._autotext_active_kbd_listener = listener
|
||
listener.join()
|
||
except Exception as _at_exc:
|
||
try:
|
||
print(
|
||
f"[AZA] Autotext-Listener Fehler, Neustart in 1s: {_at_exc}",
|
||
file=sys.stderr,
|
||
)
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
with self._autotext_kbd_listener_lock:
|
||
if self._autotext_active_kbd_listener is listener:
|
||
self._autotext_active_kbd_listener = None
|
||
if stop_ev is not None and stop_ev.is_set():
|
||
break
|
||
time.sleep(1)
|
||
_cache["data"] = None
|
||
self._lifecycle_shutdown_debug("autotext listener stopped")
|
||
|
||
def _toggle_kommentare_auto(self):
|
||
val = self._kommentare_auto_var.get()
|
||
self._autotext_data["kommentare_auto_open"] = val
|
||
save_autotext(self._autotext_data)
|
||
|
||
def _toggle_empfang_auto(self):
|
||
val = self._empfang_auto_var.get()
|
||
self._autotext_data["empfang_auto_open"] = val
|
||
save_autotext(self._autotext_data)
|
||
|
||
def _auto_open_empfang_if_enabled(self):
|
||
"""Öffnet Empfang-Fenster automatisch nach KG, wenn aktiviert."""
|
||
if self._autotext_data.get("empfang_auto_open", False):
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
if kg_text:
|
||
self._send_to_empfang()
|
||
|
||
def _auto_refresh_kommentare(self):
|
||
"""Aktualisiert das Kommentare-Fenster automatisch, wenn es offen ist.
|
||
Oeffnet es automatisch, wenn kommentare_auto_open aktiv ist und KG-Inhalt vorhanden."""
|
||
win_open = False
|
||
if hasattr(self, "_kommentare_win") and self._kommentare_win is not None:
|
||
try:
|
||
self._kommentare_win.winfo_exists()
|
||
win_open = True
|
||
except tk.TclError:
|
||
self._kommentare_win = None
|
||
|
||
if win_open and hasattr(self, "_kommentare_txt") and self._kommentare_txt is not None:
|
||
self._refresh_kommentare(self._kommentare_txt)
|
||
return
|
||
|
||
if self._autotext_data.get("kommentare_auto_open", False):
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
if kg_text:
|
||
self._open_kommentare_fenster()
|
||
|
||
def _open_kommentare_fenster(self):
|
||
"""Oeffnet das Kommentare-Fenster neben dem Hauptfenster.
|
||
Zeigt kurze medizinische Hinweise basierend auf dem KG-Inhalt."""
|
||
if hasattr(self, "_kommentare_win") and self._kommentare_win is not None:
|
||
try:
|
||
if not self._kommentare_win.winfo_exists():
|
||
raise tk.TclError("destroyed")
|
||
self._kommentare_win.deiconify()
|
||
self._kommentare_win.lift()
|
||
self._kommentare_win.focus_force()
|
||
return
|
||
except tk.TclError:
|
||
self._kommentare_win = None
|
||
|
||
kw = tk.Toplevel(self)
|
||
kw.title("Kommentare")
|
||
kw.configure(bg="#FFFFFF")
|
||
kw.minsize(320, 300)
|
||
|
||
main_x = self.winfo_x()
|
||
main_w = self.winfo_width()
|
||
main_y = self.winfo_y()
|
||
kw.geometry(f"380x520+{main_x + main_w + 10}+{main_y}")
|
||
add_resize_grip(kw, 320, 300)
|
||
|
||
self._kommentare_win = kw
|
||
|
||
header = tk.Frame(kw, bg="#E8F4F8")
|
||
header.pack(fill="x")
|
||
tk.Label(header, text="Medizinische Kommentare", font=("Segoe UI Semibold", 11),
|
||
bg="#E8F4F8", fg="#1a4d6d").pack(side="left", padx=10, pady=6)
|
||
|
||
btn_refresh = tk.Button(header, text="\u21bb Aktualisieren", font=("Segoe UI", 9),
|
||
bg="#5B8DB3", fg="white", relief="flat", cursor="hand2",
|
||
command=lambda: self._refresh_kommentare(txt_area))
|
||
btn_refresh.pack(side="right", padx=10, pady=6)
|
||
|
||
opts_frame = tk.Frame(kw, bg="#F5F9FC")
|
||
opts_frame.pack(fill="x", padx=6, pady=(2, 0))
|
||
|
||
def _toggle_auto_open():
|
||
val = self._kommentare_auto_var.get()
|
||
self._autotext_data["kommentare_auto_open"] = val
|
||
save_autotext(self._autotext_data)
|
||
|
||
tk.Checkbutton(opts_frame, text="Kommentare-Fenster nach KG-Erstellung automatisch oeffnen",
|
||
variable=self._kommentare_auto_var, font=("Segoe UI", 8),
|
||
bg="#F5F9FC", fg="#555", activebackground="#F5F9FC", selectcolor="#F5F9FC",
|
||
command=_toggle_auto_open).pack(anchor="w", padx=4)
|
||
|
||
txt_area = ScrolledText(kw, wrap="word", font=("Segoe UI", 10),
|
||
bg="#FFFFFF", fg="#333333", relief="flat",
|
||
padx=10, pady=8)
|
||
txt_area.pack(fill="both", expand=True)
|
||
self._kommentare_txt = txt_area
|
||
|
||
txt_area.tag_configure("heading", font=("Segoe UI Semibold", 10, "bold"),
|
||
foreground="#1a4d6d")
|
||
txt_area.tag_configure("clickable", font=("Segoe UI Semibold", 10, "bold"),
|
||
foreground="#1a4d6d", underline=True)
|
||
txt_area.tag_configure("body", font=("Segoe UI", 9), foreground="#444444")
|
||
txt_area.tag_configure("warn", font=("Segoe UI", 9, "bold"), foreground="#C0392B")
|
||
|
||
txt_area.insert("1.0", "Kommentare werden automatisch bei KG-Erstellung geladen.\n"
|
||
"Oder klicken Sie 'Aktualisieren'.\n\n"
|
||
"Pro Diagnose / Medikament:\n"
|
||
"\u2022 Kurzkommentar\n"
|
||
"\u2022 Red Flags / Gefahren\n"
|
||
"\u2022 Kurze Therapieempfehlung")
|
||
txt_area.configure(state="disabled")
|
||
|
||
status = tk.Label(kw, text="Bereit", font=("Segoe UI", 8),
|
||
bg="#F5F5F5", fg="#888888", anchor="w")
|
||
status.pack(fill="x", padx=4, pady=(0, 2))
|
||
self._kommentare_status = status
|
||
|
||
def on_close():
|
||
self._kommentare_win = None
|
||
self._kommentare_txt = None
|
||
kw.destroy()
|
||
kw.protocol("WM_DELETE_WINDOW", on_close)
|
||
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
if kg_text:
|
||
self.after(300, lambda: self._refresh_kommentare(txt_area))
|
||
|
||
def _refresh_kommentare(self, txt_widget):
|
||
"""Generiert medizinische Kurzkommentare zum aktuellen KG-Inhalt."""
|
||
kg_text = self.txt_output.get("1.0", "end").strip()
|
||
transcript = self.txt_transcript.get("1.0", "end").strip()
|
||
|
||
if not kg_text and not transcript:
|
||
txt_widget.configure(state="normal")
|
||
txt_widget.delete("1.0", "end")
|
||
txt_widget.insert("1.0", "Kein KG-Inhalt oder Transkript vorhanden.\n"
|
||
"Bitte zuerst eine Aufnahme machen oder KG erstellen.")
|
||
txt_widget.configure(state="disabled")
|
||
return
|
||
|
||
if not self._check_ai_consent():
|
||
return
|
||
|
||
if hasattr(self, "_kommentare_status") and self._kommentare_status:
|
||
try:
|
||
self._kommentare_status.configure(text="Kommentare werden generiert...")
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
txt_widget.configure(state="normal")
|
||
txt_widget.delete("1.0", "end")
|
||
txt_widget.insert("1.0", "Wird geladen...")
|
||
txt_widget.configure(state="disabled")
|
||
|
||
source = kg_text if kg_text else transcript
|
||
|
||
def worker():
|
||
try:
|
||
resp = self.call_chat_completion(
|
||
model="gpt-4o-mini",
|
||
messages=[
|
||
{"role": "system", "content": (
|
||
"Du bist ein medizinischer Kurzkommentar-Assistent für Ärzte. "
|
||
"Gib zu jeder erkannten Diagnose, jedem erwähnten Medikament und jeder erwähnten "
|
||
"Therapie/Prozedur einen kurzen, nützlichen Kommentar. "
|
||
"Regeln: "
|
||
"- Pro Diagnose maximal 2 kurze Zeilen. "
|
||
"- Inhalt: Wichtigstes, Red Flags, kurze Therapieempfehlung. "
|
||
"- Pro Medikament EXAKT 1 Satz: NUR die Indikation (wofür zugelassen). "
|
||
" VERBOTEN im Inline-Kommentar: Einnahmehinweise, Dosierung, Nebenwirkungen, "
|
||
" Nahrungsbezug, Tageszeit, Maximaldosis, Applikationsart. "
|
||
" Diese Details gehören in das Detailfenster, NICHT in den Inline-Kommentar. "
|
||
" NIEMALS Sätze wie 'mit Wasser einnehmen', 'bevorzugt morgens', 'nach Bedarf', "
|
||
" 'nüchtern einnehmen', 'mit Nahrung', 'häufigste Nebenwirkungen' im Inline-Kommentar. "
|
||
"- Pro Therapie/Prozedur maximal 2 kurze Sätze: "
|
||
" 1) Was ist das / wofür. "
|
||
" 2) Wichtigster Hinweis (Kontraindikation, Nachsorge). "
|
||
"- SICHERHEITSREGEL MEDIKAMENTE: "
|
||
" 1) Tagge NUR Begriffe als [MED], die du mit hoher Sicherheit als real existierendes, "
|
||
" zugelassenes Medikament oder pharmazeutischen Wirkstoff kennst. "
|
||
" 2) Allgemeine Begriffe (Creme, Salbe, Tropfen, Tabletten, Pflegeprodukte) "
|
||
" sind KEINE Medikamente. "
|
||
" 3) Unbekannte Namen: NICHT als [MED] taggen, KEINE Fachinfos schreiben. "
|
||
" 4) Ähnliche aber nicht exakte Namen: NICHT als [MED] taggen. "
|
||
" 5) Im Zweifel: Lieber NICHT taggen als falsch taggen. "
|
||
"- THERAPIEN/PROZEDUREN: "
|
||
" Tagge Therapien und Prozeduren mit [THER]. "
|
||
" Beispiele: Kryotherapie, Exzision, Phototherapie, Biopsie, Desensibilisierung. "
|
||
" NICHT als [MED] oder [DX] taggen. "
|
||
"- Medizinisch konservativ, keine Überinterpretation. "
|
||
"- WICHTIG Format: Jede Überschrift MUSS mit [DX], [MED] oder [THER] beginnen. "
|
||
"[DX] für Diagnosen, [MED] für Medikamente/Wirkstoffe, [THER] für Therapien/Prozeduren. "
|
||
"Beispiel: '[DX] Aktinische Keratose', '[MED] Bilastin', '[THER] Kryotherapie'. "
|
||
"Dann Kommentar darunter. "
|
||
"- Sprache: Deutsch. "
|
||
"- Lieber kurz und konservativ als ausführlich."
|
||
)},
|
||
{"role": "user", "content": f"Bitte kommentiere folgende Krankengeschichte:\n\n{source}"}
|
||
],
|
||
temperature=0.3,
|
||
max_tokens=1000,
|
||
)
|
||
result = resp.choices[0].message.content.strip()
|
||
self._last_kommentar_result = result
|
||
self.after(0, lambda r=result: self._apply_kommentare(txt_widget, r))
|
||
except Exception as exc:
|
||
self.after(0, lambda e=str(exc): self._apply_kommentare(
|
||
txt_widget, f"Fehler: {e}"))
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _apply_kommentare(self, txt_widget, text):
|
||
"""Setzt den Kommentar-Text ins Widget und macht Ueberschriften klickbar.
|
||
|
||
Fuer [MED]-Eintraege wird VOR der Anzeige validate_medication_name()
|
||
aufgerufen. Nur bei sicher erkanntem Medikament wird die AI-Kurzinfo
|
||
angezeigt. Sonst Warnung + optionaler Kandidatenvorschlag mit
|
||
klickbarem Uebernehmen-Link.
|
||
"""
|
||
try:
|
||
txt_widget.configure(state="normal")
|
||
txt_widget.delete("1.0", "end")
|
||
|
||
lines = text.split("\n")
|
||
skip_until_next_heading = False
|
||
for i, line in enumerate(lines):
|
||
stripped = line.strip()
|
||
is_heading = (stripped.startswith("**") and stripped.endswith("**")) or \
|
||
(stripped.startswith("##")) or \
|
||
(stripped.startswith("[DX]") or stripped.startswith("[MED]") or
|
||
stripped.startswith("[THER]")) or \
|
||
(stripped and stripped[0].isdigit() and "." in stripped[:4] and len(stripped) > 3)
|
||
if is_heading:
|
||
skip_until_next_heading = False
|
||
|
||
clean = stripped.replace("**", "").replace("##", "").strip()
|
||
is_med = clean.startswith("[MED]")
|
||
is_dx = clean.startswith("[DX]")
|
||
is_ther = clean.startswith("[THER]")
|
||
display_name = clean.replace("[MED]", "").replace("[DX]", "").replace("[THER]", "").strip()
|
||
if display_name and display_name[0].isdigit() and "." in display_name[:4]:
|
||
display_name = display_name.split(".", 1)[-1].strip()
|
||
detail_text = self._collect_comment_detail(lines, i)
|
||
|
||
if is_med:
|
||
is_valid, candidate = validate_medication_name(display_name)
|
||
else:
|
||
is_valid, candidate = True, None
|
||
|
||
if is_med and not is_valid:
|
||
skip_until_next_heading = True
|
||
tag_name = f"diag_{i}"
|
||
txt_widget.tag_configure(tag_name, font=("Segoe UI Semibold", 10, "bold"),
|
||
foreground="#999", underline=True)
|
||
txt_widget.insert("end", "\u26A0 " + display_name + "\n", tag_name)
|
||
txt_widget.tag_bind(tag_name, "<Button-1>",
|
||
lambda e, t=display_name, d=detail_text: self._show_med_detail(t, d))
|
||
txt_widget.tag_bind(tag_name, "<Enter>",
|
||
lambda e: txt_widget.config(cursor="hand2"))
|
||
txt_widget.tag_bind(tag_name, "<Leave>",
|
||
lambda e: txt_widget.config(cursor=""))
|
||
|
||
warn_tag = f"warn_{i}"
|
||
txt_widget.tag_configure(warn_tag, foreground="#B0B0B0",
|
||
font=("Segoe UI", 9, "italic"))
|
||
txt_widget.insert("end", " Kein sicher erkanntes Medikament.\n", warn_tag)
|
||
|
||
if candidate:
|
||
cand_tag = f"cand_{i}"
|
||
txt_widget.tag_configure(cand_tag, foreground="#2471A3",
|
||
font=("Segoe UI", 9, "bold underline"))
|
||
txt_widget.insert("end", f" Meinten Sie evtl. {candidate}? ",)
|
||
txt_widget.insert("end", "[Übernehmen]", cand_tag)
|
||
txt_widget.insert("end", "\n")
|
||
txt_widget.tag_bind(cand_tag, "<Button-1>",
|
||
lambda e, old=display_name, new=candidate, tw=txt_widget:
|
||
self._accept_med_candidate(old, new, tw))
|
||
txt_widget.tag_bind(cand_tag, "<Enter>",
|
||
lambda e: txt_widget.config(cursor="hand2"))
|
||
txt_widget.tag_bind(cand_tag, "<Leave>",
|
||
lambda e: txt_widget.config(cursor=""))
|
||
continue
|
||
|
||
tag_name = f"diag_{i}"
|
||
if is_ther:
|
||
fg_color = "#6A1B9A"
|
||
elif is_med:
|
||
fg_color = "#2E7D32"
|
||
else:
|
||
fg_color = "#1a4d6d"
|
||
txt_widget.tag_configure(tag_name, font=("Segoe UI Semibold", 10, "bold"),
|
||
foreground=fg_color, underline=True)
|
||
if is_ther:
|
||
prefix = "\U0001F529 "
|
||
elif is_med:
|
||
prefix = "\U0001F48A "
|
||
elif is_dx:
|
||
prefix = "\U0001F3E5 "
|
||
else:
|
||
prefix = ""
|
||
txt_widget.insert("end", prefix + display_name + "\n", tag_name)
|
||
if is_med:
|
||
txt_widget.tag_bind(tag_name, "<Button-1>",
|
||
lambda e, t=display_name, d=detail_text: self._show_med_detail(t, d))
|
||
elif is_ther:
|
||
txt_widget.tag_bind(tag_name, "<Button-1>",
|
||
lambda e, t=display_name, d=detail_text: self._show_ther_detail(t, d))
|
||
else:
|
||
txt_widget.tag_bind(tag_name, "<Button-1>",
|
||
lambda e, t=display_name, d=detail_text: self._show_dx_detail(t, d))
|
||
txt_widget.tag_bind(tag_name, "<Enter>",
|
||
lambda e, tn=tag_name: txt_widget.config(cursor="hand2"))
|
||
txt_widget.tag_bind(tag_name, "<Leave>",
|
||
lambda e, tn=tag_name: txt_widget.config(cursor=""))
|
||
elif skip_until_next_heading:
|
||
continue
|
||
else:
|
||
txt_widget.insert("end", line + "\n")
|
||
|
||
txt_widget.configure(state="disabled")
|
||
if hasattr(self, "_kommentare_status") and self._kommentare_status:
|
||
try:
|
||
self._kommentare_status.configure(text="Kommentare aktualisiert.")
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
def _accept_med_candidate(self, old_name, new_name, txt_widget):
|
||
"""Uebernimmt den Kandidatenvorschlag und aktualisiert die Kommentare.
|
||
|
||
Ersetzt im letzten AI-Ergebnis den alten Namen durch den Kandidaten
|
||
und rendert das Widget neu – sowohl im Heading-Tag als auch im Fliesstext.
|
||
"""
|
||
try:
|
||
if hasattr(self, "_last_kommentar_result") and self._last_kommentar_result:
|
||
updated = self._last_kommentar_result.replace(
|
||
f"[MED] {old_name}", f"[MED] {new_name}")
|
||
updated = updated.replace(old_name, new_name)
|
||
self._last_kommentar_result = updated
|
||
self._apply_kommentare(txt_widget, updated)
|
||
except Exception:
|
||
pass
|
||
|
||
def _collect_comment_detail(self, lines, heading_idx):
|
||
"""Sammelt den Text nach einer Ueberschrift bis zur naechsten Ueberschrift."""
|
||
detail_lines = []
|
||
for j in range(heading_idx + 1, len(lines)):
|
||
stripped = lines[j].strip()
|
||
if not stripped:
|
||
detail_lines.append("")
|
||
continue
|
||
is_next_heading = (stripped.startswith("**") and stripped.endswith("**")) or \
|
||
(stripped.startswith("##")) or \
|
||
stripped.startswith("[DX]") or \
|
||
stripped.startswith("[MED]") or \
|
||
stripped.startswith("[THER]") or \
|
||
(stripped and stripped[0].isdigit() and "." in stripped[:4] and len(stripped) > 3)
|
||
if is_next_heading:
|
||
break
|
||
detail_lines.append(lines[j])
|
||
return "\n".join(detail_lines).strip()
|
||
|
||
_MED_QUELLEN = {
|
||
"compendium.ch": {
|
||
"label": "Compendium (CH)",
|
||
"url": "https://compendium.ch/search?q={q}",
|
||
},
|
||
"BASG": {
|
||
"label": "BASG Register (AT)",
|
||
"url": "https://aspregister.basg.gv.at/aspregister/faces/aspregister.jspx?_afrLoop=1&_adf.ctrl-state=search&query={q}",
|
||
},
|
||
"BfArM": {
|
||
"label": "BfArM AMIce (DE)",
|
||
"url": "https://www.bfarm.de/SiteGlobals/Forms/Suche/AMICESearch/AMICESearchForm.html?queryString={q}",
|
||
},
|
||
}
|
||
|
||
def _get_med_source_url(self, med_name):
|
||
quelle = self._autotext_data.get("medikament_quelle", "compendium.ch")
|
||
info = self._MED_QUELLEN.get(quelle, self._MED_QUELLEN["compendium.ch"])
|
||
import urllib.parse
|
||
return info["url"].replace("{q}", urllib.parse.quote_plus(med_name))
|
||
|
||
# ─── Diagnose-Quellen ───
|
||
|
||
_DX_QUELLEN = {
|
||
"DocCheck": {
|
||
"label": "DocCheck Flexikon",
|
||
"url": "https://flexikon.doccheck.com/de/Spezial:Suche?search={q}&go=Seite",
|
||
},
|
||
"MSD": {
|
||
"label": "MSD Manual Patienten",
|
||
"url": "https://www.msdmanuals.com/de/heim/SearchResults?query={q}",
|
||
},
|
||
}
|
||
|
||
def _get_default_dx_quelle(self):
|
||
lang = self._autotext_data.get("selectedLanguage", "system")
|
||
if lang in ("system", "de", ""):
|
||
return "DocCheck"
|
||
return "MSD"
|
||
|
||
def _get_dx_source_url(self, dx_name):
|
||
saved = self._autotext_data.get("diagnose_quelle", "")
|
||
quelle = saved if saved and saved in self._DX_QUELLEN else self._get_default_dx_quelle()
|
||
info = self._DX_QUELLEN[quelle]
|
||
import urllib.parse
|
||
return info["url"].replace("{q}", urllib.parse.quote_plus(dx_name))
|
||
|
||
_DETAIL_SECTION_HEADINGS_MED = [
|
||
"Indikation / Wofuer", "Haeufigste Nebenwirkungen",
|
||
"Anwendung / Applikation", "Dosierung / Dauer",
|
||
"Schwangerschaft / Stillzeit", "Wichtige Hinweise",
|
||
]
|
||
_DETAIL_SECTION_HEADINGS_DX = [
|
||
"Was ist das?", "Typische Symptome / Klinik",
|
||
"Typische Therapie / Behandlung",
|
||
"Wichtige Warnhinweise / Red Flags",
|
||
]
|
||
_DETAIL_SECTION_HEADINGS_THER = [
|
||
"Was ist das?", "Indikationen",
|
||
"Durchfuehrung", "Wichtige Hinweise",
|
||
]
|
||
|
||
_THER_QUELLEN = {
|
||
"DocCheck": {
|
||
"label": "DocCheck Flexikon",
|
||
"url": "https://flexikon.doccheck.com/de/Spezial:Suche?search={q}&go=Seite",
|
||
},
|
||
"PharmaWiki": {
|
||
"label": "PharmaWiki (pharmawiki.ch)",
|
||
"url": "https://www.pharmawiki.ch/wiki/index.php?wiki={q}",
|
||
},
|
||
}
|
||
|
||
def _get_ther_source_url(self, ther_name):
|
||
quelle = self._autotext_data.get("therapie_quelle", "DocCheck")
|
||
info = self._THER_QUELLEN.get(quelle, self._THER_QUELLEN["DocCheck"])
|
||
import urllib.parse
|
||
return info["url"].replace("{q}", urllib.parse.quote_plus(ther_name))
|
||
|
||
def _build_detail_window(self, title, header_bg, prompt, detail, quelle_dict, quelle_key, open_url_fn,
|
||
quelle_label="Quelle:",
|
||
original_quelle_dict=None, original_quelle_key=None,
|
||
original_quelle_label="Originalquelle:"):
|
||
"""Gemeinsamer Fenster-Builder fuer Med- und Dx-Detail mit klickbaren Abschnitten."""
|
||
dlg = tk.Toplevel(self)
|
||
dlg.title(f"Detail: {title}")
|
||
dlg.configure(bg="#FFFFFF")
|
||
w, h = 520, 540
|
||
sw = dlg.winfo_screenwidth()
|
||
sh = dlg.winfo_screenheight()
|
||
dlg.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
|
||
dlg.attributes("-topmost", True)
|
||
self._register_window(dlg)
|
||
add_resize_grip(dlg, 400, 370)
|
||
|
||
tk.Label(dlg, text=title, font=("Segoe UI Semibold", 12, "bold"),
|
||
bg=header_bg, fg="#1a4d6d", anchor="w").pack(fill="x", ipadx=10, ipady=6)
|
||
|
||
quelle_frame = tk.Frame(dlg, bg="#F5F9FC")
|
||
quelle_frame.pack(fill="x", padx=8, pady=(4, 0))
|
||
tk.Label(quelle_frame, text=quelle_label, font=("Segoe UI", 8),
|
||
bg="#F5F9FC", fg="#555").pack(side="left")
|
||
saved_q = self._autotext_data.get(quelle_key, "")
|
||
q_keys = list(quelle_dict.keys())
|
||
q_labels = [quelle_dict[k]["label"] for k in q_keys]
|
||
label_to_key = dict(zip(q_labels, q_keys))
|
||
current_key = saved_q if saved_q in quelle_dict else q_keys[0]
|
||
q_display = tk.StringVar(value=quelle_dict[current_key]["label"])
|
||
combo = ttk.Combobox(quelle_frame, textvariable=q_display,
|
||
values=q_labels, state="readonly", width=34)
|
||
combo.pack(side="left", padx=(4, 0))
|
||
|
||
if original_quelle_dict and original_quelle_key:
|
||
oq_frame = tk.Frame(dlg, bg="#F5F9FC")
|
||
oq_frame.pack(fill="x", padx=8, pady=(2, 0))
|
||
tk.Label(oq_frame, text=original_quelle_label, font=("Segoe UI", 8),
|
||
bg="#F5F9FC", fg="#555").pack(side="left")
|
||
oq_saved = self._autotext_data.get(original_quelle_key, "")
|
||
oq_keys = list(original_quelle_dict.keys())
|
||
oq_labels = [original_quelle_dict[k]["label"] for k in oq_keys]
|
||
oq_label_to_key = dict(zip(oq_labels, oq_keys))
|
||
oq_current = oq_saved if oq_saved in original_quelle_dict else oq_keys[0]
|
||
oq_display = tk.StringVar(value=original_quelle_dict[oq_current]["label"])
|
||
oq_combo = ttk.Combobox(oq_frame, textvariable=oq_display,
|
||
values=oq_labels, state="readonly", width=34)
|
||
oq_combo.pack(side="left", padx=(4, 0))
|
||
|
||
def _on_oq_change(*_):
|
||
oq_key = oq_label_to_key.get(oq_display.get(), oq_keys[0])
|
||
self._autotext_data[original_quelle_key] = oq_key
|
||
save_autotext(self._autotext_data)
|
||
oq_combo.bind("<<ComboboxSelected>>", _on_oq_change)
|
||
|
||
hint = tk.Label(dlg, text="\u2139 Überschrift oder Button unten klicken = Originalquelle im Browser öffnen",
|
||
font=("Segoe UI", 7, "italic"), bg="#FFFFFF", fg="#999", anchor="w")
|
||
hint.pack(fill="x", padx=12, pady=(2, 0))
|
||
|
||
txt = ScrolledText(dlg, wrap="word", font=("Segoe UI", 10),
|
||
bg="#FFFFFF", fg="#333", relief="flat", padx=10, pady=8)
|
||
txt.pack(fill="both", expand=True, padx=4, pady=(2, 0))
|
||
|
||
if header_bg == "#E8F4E8":
|
||
known_headings = self._DETAIL_SECTION_HEADINGS_MED
|
||
elif header_bg == "#F3E5F5":
|
||
known_headings = self._DETAIL_SECTION_HEADINGS_THER
|
||
else:
|
||
known_headings = self._DETAIL_SECTION_HEADINGS_DX
|
||
|
||
def _render_with_links(text):
|
||
try:
|
||
txt.configure(state="normal")
|
||
txt.delete("1.0", "end")
|
||
lines = text.split("\n")
|
||
tag_counter = [0]
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
is_section = any(stripped.lower().startswith(h.lower()) or
|
||
stripped.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
|
||
.startswith(h.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue"))
|
||
for h in known_headings)
|
||
if is_section and len(stripped) < 60:
|
||
tag_name = f"sec_{tag_counter[0]}"
|
||
tag_counter[0] += 1
|
||
txt.tag_configure(tag_name, font=("Segoe UI Semibold", 10, "bold underline"),
|
||
foreground="#1565C0")
|
||
txt.insert("end", stripped + "\n", tag_name)
|
||
txt.tag_bind(tag_name, "<Button-1>",
|
||
lambda e, n=title: open_url_fn(n))
|
||
txt.tag_bind(tag_name, "<Enter>",
|
||
lambda e: txt.config(cursor="hand2"))
|
||
txt.tag_bind(tag_name, "<Leave>",
|
||
lambda e: txt.config(cursor=""))
|
||
else:
|
||
txt.insert("end", line + "\n")
|
||
txt.configure(state="disabled")
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
def _do_reload():
|
||
try:
|
||
txt.configure(state="normal")
|
||
txt.delete("1.0", "end")
|
||
txt.insert("1.0", "Lade Quelldaten und Kurzinfo..." if callable(prompt) else "Lade strukturierte Kurzinfo...")
|
||
txt.configure(state="disabled")
|
||
except tk.TclError:
|
||
return
|
||
def _worker():
|
||
try:
|
||
actual_prompt = prompt() if callable(prompt) else prompt
|
||
resp = self.call_chat_completion(
|
||
model="gpt-4o-mini",
|
||
messages=[
|
||
{"role": "system", "content": actual_prompt},
|
||
{"role": "user", "content": f"Bitte Kurzinfo zu: {title}\n\nKontext:\n{detail[:500]}"}
|
||
],
|
||
temperature=0.2, max_tokens=500,
|
||
)
|
||
result = resp.choices[0].message.content.strip()
|
||
self.after(0, lambda r=result: _render_with_links(r))
|
||
except Exception as exc:
|
||
self.after(0, lambda e=str(exc): _render_with_links(f"Fehler: {e}"))
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
def _on_q_change(*_):
|
||
key = label_to_key.get(q_display.get(), q_keys[0])
|
||
self._autotext_data[quelle_key] = key
|
||
save_autotext(self._autotext_data)
|
||
if callable(prompt) and detail:
|
||
_do_reload()
|
||
combo.bind("<<ComboboxSelected>>", _on_q_change)
|
||
|
||
if detail and hasattr(self, "_check_ai_consent") and self._check_ai_consent():
|
||
_do_reload()
|
||
else:
|
||
txt.insert("1.0", detail if detail else "Keine weiteren Details verfuegbar.")
|
||
txt.configure(state="disabled")
|
||
|
||
btn_frame = tk.Frame(dlg, bg="#FFFFFF")
|
||
btn_frame.pack(fill="x", padx=8, pady=(4, 8))
|
||
tk.Button(btn_frame, text="Originalquelle oeffnen", font=("Segoe UI", 9, "bold"),
|
||
bg="#5B8DB3", fg="white", relief="flat", cursor="hand2",
|
||
command=lambda: open_url_fn(title)).pack(side="left", padx=(0, 8))
|
||
tk.Button(btn_frame, text="Schliessen", font=("Segoe UI", 9),
|
||
bg="#EBEDF0", fg="#333", relief="flat",
|
||
command=dlg.destroy).pack(side="left")
|
||
|
||
# ─── Inhaltsquellen (Content Sources) – getrennt von Originallink ───
|
||
|
||
_MED_CONTENT_QUELLEN = {
|
||
"DocCheck": {
|
||
"label": "DocCheck Flexikon",
|
||
},
|
||
"PharmaWiki": {
|
||
"label": "PharmaWiki (pharmawiki.ch)",
|
||
},
|
||
}
|
||
|
||
def _fetch_doccheck_info(self, med_name):
|
||
"""Extrahiert strukturierte Medikamenten-Info von DocCheck Flexikon.
|
||
|
||
DocCheck nutzt standard <h2>/<h3> Headings (server-rendered).
|
||
Returns (source_text, source_url) oder (None, None).
|
||
"""
|
||
import urllib.request
|
||
import urllib.parse
|
||
import re
|
||
try:
|
||
url = f"https://flexikon.doccheck.com/de/{urllib.parse.quote(med_name)}"
|
||
req = urllib.request.Request(url, headers={
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"Accept-Language": "de-DE,de;q=0.9",
|
||
})
|
||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||
dc_html = resp.read().decode("utf-8", errors="replace")
|
||
if "Suchergebnisse" in dc_html or "Seite nicht gefunden" in dc_html:
|
||
return (None, None)
|
||
clean = re.sub(r"<script[^>]*>.*?</script>", "", dc_html, flags=re.DOTALL)
|
||
clean = re.sub(r"<style[^>]*>.*?</style>", "", clean, flags=re.DOTALL)
|
||
sections: dict[str, str] = {}
|
||
for m in re.finditer(
|
||
r"<h[23][^>]*>(.*?)</h[23]>(.*?)(?=<h[23]|</article|</main|</body|$)",
|
||
clean, re.DOTALL,
|
||
):
|
||
heading = re.sub(r"<[^>]+>", "", m.group(1)).strip()
|
||
body = re.sub(r"<[^>]+>", " ", m.group(2))
|
||
body = re.sub(r"\s+", " ", body).strip()
|
||
if heading and body and len(body) > 15 and heading != "Inhaltsverzeichnis":
|
||
sections[heading] = body[:500]
|
||
if not sections:
|
||
return (None, None)
|
||
relevant = [
|
||
"Definition", "Indikation", "Indikationen", "Dosierung",
|
||
"Darreichungsform", "Darreichungsformen",
|
||
"Wirkmechanismus", "Pharmakokinetik",
|
||
"Nebenwirkungen", "Kontraindikation", "Kontraindikationen",
|
||
"Wechselwirkungen",
|
||
]
|
||
parts: list[str] = []
|
||
for key in relevant:
|
||
for sname, scontent in sections.items():
|
||
if key.lower() in sname.lower():
|
||
parts.append(f"{sname}: {scontent}")
|
||
break
|
||
if not parts:
|
||
parts = [f"{n}: {c}" for n, c in list(sections.items())[:6]]
|
||
return ("\n\n".join(parts), url)
|
||
except Exception:
|
||
return (None, None)
|
||
|
||
def _fetch_pharmawiki_info(self, med_name):
|
||
"""Extrahiert strukturierte Medikamenten-Info von PharmaWiki.ch.
|
||
|
||
PharmaWiki nutzt <span id="subtitle"> fuer Sektionsueberschriften.
|
||
Returns (source_text, product_name, darreichungsform, source_url)
|
||
oder (None, None, None, None).
|
||
"""
|
||
import urllib.request
|
||
import urllib.parse
|
||
import re
|
||
try:
|
||
url = f"https://www.pharmawiki.ch/wiki/index.php?wiki={urllib.parse.quote(med_name)}"
|
||
req = urllib.request.Request(url, headers={
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||
"Accept-Language": "de-CH,de;q=0.9",
|
||
})
|
||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||
pw_html = resp.read().decode("utf-8", errors="replace")
|
||
if "Suchresultate" in pw_html or "wurde nicht gefunden" in pw_html:
|
||
return (None, None, None, None)
|
||
sections: dict[str, str] = {}
|
||
for m in re.finditer(
|
||
r'<span\s+id="subtitle"[^>]*>(.*?)</span>(.*?)'
|
||
r'(?=<span\s+id="subtitle"|<span\s+id="bottom"|<div\s+id="footer"|$)',
|
||
pw_html, re.DOTALL,
|
||
):
|
||
heading = re.sub(r"<[^>]+>", "", m.group(1)).strip()
|
||
body = re.sub(r"<[^>]+>", " ", m.group(2))
|
||
body = re.sub(r"\s+", " ", body).strip()
|
||
if heading and body and len(body) > 15:
|
||
sections[heading] = body[:500]
|
||
if not sections:
|
||
return (None, None, None, None)
|
||
relevant = [
|
||
"Produkte", "Indikationen", "Dosierung", "Wirkungen",
|
||
"Kontraindikationen", "Interaktionen",
|
||
"Unerw\u00fcnschte Wirkungen", "Anwendung", "Verabreichung",
|
||
]
|
||
parts: list[str] = []
|
||
for key in relevant:
|
||
for sname, scontent in sections.items():
|
||
if key.lower() in sname.lower():
|
||
parts.append(f"{sname}: {scontent}")
|
||
break
|
||
if not parts:
|
||
parts = [f"{n}: {c}" for n, c in list(sections.items())[:5]]
|
||
product_name = None
|
||
darreichungsform = None
|
||
for sname, scontent in sections.items():
|
||
if "produkte" in sname.lower():
|
||
prod_match = re.search(
|
||
r"\(([A-Z][a-z\u00e4\u00f6\u00fc\-]+(?:\u00ae|®)?)\)", scontent
|
||
)
|
||
if prod_match:
|
||
product_name = prod_match.group(1).replace("®", "\u00ae")
|
||
df_match = re.search(
|
||
r"(Filmtabletten?|Tabletten?|Kapseln?|Weichkapseln?|Hartkapseln?|"
|
||
r"Tropfen|Sirup|Creme|Salbe|Gel|L[o\u00f6]sung|"
|
||
r"orale[rn]?\s+Suspension|Injektionsl[o\u00f6]sung|"
|
||
r"Suppositorien|Spray|Pflaster|Fertigspritze[n]?|"
|
||
r"Augentropfen|Nasenspray|Brausetabletten?|"
|
||
r"Schmelztabletten?|Retardtabletten?|Granulat|Pulver)",
|
||
scontent, re.IGNORECASE,
|
||
)
|
||
if df_match:
|
||
darreichungsform = df_match.group(1)
|
||
break
|
||
return ("\n\n".join(parts), product_name, darreichungsform, url)
|
||
except Exception:
|
||
return (None, None, None, None)
|
||
|
||
def _show_med_detail(self, title, detail):
|
||
"""Medikamenten-Detailfenster.
|
||
|
||
Zwei getrennte Ebenen:
|
||
- INHALTSQUELLE (content source): DocCheck/PharmaWiki -> Dropdown "Inhaltsquelle:"
|
||
gespeichert als med_content_quelle in _autotext_data
|
||
- ORIGINALQUELLE (external link): CH=Compendium/AT=BASG/DE=BfArM -> Button
|
||
gespeichert als medikament_quelle in _autotext_data (UNVERAENDERT)
|
||
"""
|
||
|
||
def _build_prompt():
|
||
"""Laeuft im Hintergrund-Thread: Quelldaten aus gewaehlter Inhaltsquelle holen."""
|
||
content_quelle = self._autotext_data.get("med_content_quelle", "DocCheck")
|
||
source_text = None
|
||
source_name = None
|
||
|
||
if content_quelle == "DocCheck":
|
||
dc_text, dc_url = self._fetch_doccheck_info(title)
|
||
if dc_text:
|
||
source_text = dc_text
|
||
source_name = "DocCheck Flexikon"
|
||
else:
|
||
pw_text, pw_prod, pw_df, pw_url = self._fetch_pharmawiki_info(title)
|
||
if pw_text:
|
||
source_text = pw_text
|
||
source_name = "PharmaWiki (Fallback)"
|
||
else:
|
||
pw_text, pw_prod, pw_df, pw_url = self._fetch_pharmawiki_info(title)
|
||
if pw_text:
|
||
source_text = pw_text
|
||
source_name = "PharmaWiki (pharmawiki.ch)"
|
||
|
||
if not source_text:
|
||
facts = get_medication_facts(title)
|
||
if facts:
|
||
source_text = (
|
||
f"Indikation: {facts['indikation']}\n"
|
||
f"Einnahme: {facts['einnahme']}\n"
|
||
f"Dosierung: {facts['dosierung']}\n"
|
||
f"Wichtig: {facts['wichtig']}"
|
||
)
|
||
source_name = f"Kuratierte Fakten ({facts['quelle']})"
|
||
|
||
if source_text:
|
||
source_block = (
|
||
f"=== QUELLDATEN ({source_name}) ===\n"
|
||
f"Wirkstoff/Medikament: {title}\n"
|
||
f"{source_text}\n"
|
||
f"=== ENDE QUELLDATEN ===\n"
|
||
)
|
||
return (
|
||
"Du bist ein Kurzinfo-Assistent für Ärzte (Zielgruppe: Fachpersonal). "
|
||
"Dir werden QUELLDATEN aus einer pharmazeutischen Quelle geliefert. "
|
||
"STRIKTE REGEL: Verwende AUSSCHLIESSLICH die gelieferten Quelldaten. "
|
||
"Erfinde NICHTS hinzu. Wenn ein Punkt nicht in den Quelldaten steht, "
|
||
"schreibe 'Siehe Fachinformation'.\n\n"
|
||
"Formatiere die Quelldaten in folgende Abschnitte:\n\n"
|
||
"Indikation / Wofür\n(NUR aus Quelldaten)\n\n"
|
||
"Anwendung / Applikation\n(NUR aus Quelldaten)\n\n"
|
||
"Dosierung\n(NUR aus Quelldaten)\n\n"
|
||
"Wichtige Hinweise\n(NUR aus Quelldaten)\n\n"
|
||
"Quelle\n" + source_name + "\n\n"
|
||
"Regeln: Max 150 Wörter. Fachsprache erlaubt. "
|
||
"NICHTS erfinden, NICHTS verallgemeinern, KEINE generischen Sätze. "
|
||
"Keine Sternchen, kein Markdown, keine Aufzählungszeichen. "
|
||
"Jeder Abschnitt beginnt mit der Überschrift als eigene Zeile. "
|
||
"Sprache: Deutsch.\n\n" + source_block
|
||
)
|
||
return (
|
||
"Du bist ein Kurzinfo-Assistent für Ärzte (Zielgruppe: Fachpersonal). "
|
||
"SICHERHEITSREGEL: Für dieses Medikament konnten KEINE Quelldaten geladen werden. "
|
||
"Weder DocCheck, PharmaWiki noch kuratierte Datenbank haben belastbare Daten geliefert.\n\n"
|
||
"Erstelle eine MINIMALE Zusammenfassung:\n\n"
|
||
"Indikation / Wofür\n(zugelassene Indikationen, kurz – falls bekannt)\n\n"
|
||
"Anwendung / Applikation\n"
|
||
"Keine sichere Quellenzuordnung möglich. Bitte Fachinformation konsultieren.\n\n"
|
||
"Dosierung\nKeine gesicherte Angabe. Siehe Fachinformation.\n\n"
|
||
"Quelle\n"
|
||
"Keine verifizierte Quelle verfügbar. Bitte Fachinformation konsultieren.\n\n"
|
||
"STRIKTE VERBOTE:\n"
|
||
"- KEINE Einnahmehinweise\n- KEINE Dosierungsangaben\n"
|
||
"- KEINE Maximaldosen\n- KEINE Nebenwirkungsauflistung\n"
|
||
"- KEINE generischen Sätze\n"
|
||
"Keine Sternchen, kein Markdown, keine Aufzählungszeichen. "
|
||
"Jeder Abschnitt beginnt mit der Überschrift als eigene Zeile. "
|
||
"Sprache: Deutsch."
|
||
)
|
||
|
||
def _open(name):
|
||
import webbrowser
|
||
webbrowser.open(self._get_med_source_url(name))
|
||
|
||
if not self._autotext_data.get("med_content_quelle"):
|
||
self._autotext_data["med_content_quelle"] = "DocCheck"
|
||
if not self._autotext_data.get("medikament_quelle"):
|
||
self._autotext_data["medikament_quelle"] = "compendium.ch"
|
||
|
||
self._build_detail_window(
|
||
title, "#E8F4E8", _build_prompt, detail,
|
||
self._MED_CONTENT_QUELLEN, "med_content_quelle", _open,
|
||
quelle_label="Inhaltsquelle:",
|
||
original_quelle_dict=self._MED_QUELLEN,
|
||
original_quelle_key="medikament_quelle",
|
||
original_quelle_label="Originalquelle (per Klick):",
|
||
)
|
||
|
||
def _show_dx_detail(self, title, detail):
|
||
"""Diagnose-Detailfenster mit Quellenauswahl."""
|
||
prompt = (
|
||
"Du bist ein Kurzinfo-Assistent für Ärzte (Zielgruppe: Fachpersonal). "
|
||
"Erstelle eine kompakte Zusammenfassung zur Diagnose. "
|
||
"Verwende EXAKT diese Abschnittsüberschriften als eigene Zeilen:\n\n"
|
||
"Was ist das?\n"
|
||
"(Definition, kurz und prägnant)\n\n"
|
||
"Typische Symptome / Klinik\n"
|
||
"(klinisches Bild, Leitsymptome)\n\n"
|
||
"Typische Therapie / Behandlung\n"
|
||
"(Erstlinientherapie, ggf. Alternativen, kurz)\n\n"
|
||
"Wichtige Warnhinweise / Red Flags\n"
|
||
"(wann dringend handeln, Differentialdiagnosen beachten)\n\n"
|
||
"Regeln: Max 150 Wörter. Fachsprache erlaubt. Medizinisch konservativ. "
|
||
"Keine Eskalation in schwerere Diagnosen. Keine Überinterpretation. "
|
||
"Keine Sternchen, kein Markdown, keine Aufzählungszeichen. "
|
||
"Jeder Abschnitt beginnt mit der Überschrift als eigene Zeile, gefolgt vom Inhalt. "
|
||
"Sprache: Deutsch."
|
||
)
|
||
saved = self._autotext_data.get("diagnose_quelle", "")
|
||
if not saved:
|
||
self._autotext_data["diagnose_quelle"] = self._get_default_dx_quelle()
|
||
def _open(name):
|
||
import webbrowser
|
||
webbrowser.open(self._get_dx_source_url(name))
|
||
self._build_detail_window(title, "#E8F4F8", prompt, detail,
|
||
self._DX_QUELLEN, "diagnose_quelle", _open)
|
||
|
||
def _show_ther_detail(self, title, detail):
|
||
"""Therapie-/Prozedur-Detailfenster mit eigenen Therapie-Quellen (DocCheck/PharmaWiki)."""
|
||
prompt = (
|
||
"Du bist ein Kurzinfo-Assistent für Ärzte (Zielgruppe: Fachpersonal). "
|
||
"Es handelt sich um eine THERAPIE oder PROZEDUR, NICHT um ein Medikament. "
|
||
"Verwende KEINE Medikamenten-Logik (keine Einnahmehinweise, keine Dosierung). "
|
||
"Erstelle eine kompakte Zusammenfassung zur medizinischen Therapie/Prozedur. "
|
||
"Verwende EXAKT diese Abschnittsüberschriften als eigene Zeilen:\n\n"
|
||
"Was ist das?\n"
|
||
"(Definition der Therapie/Prozedur, kurz und prägnant)\n\n"
|
||
"Indikationen\n"
|
||
"(wofür wird diese Therapie/Prozedur typischerweise eingesetzt?)\n\n"
|
||
"Durchführung\n"
|
||
"(kurze Beschreibung des Ablaufs, soweit klinisch relevant)\n\n"
|
||
"Wichtige Hinweise\n"
|
||
"(Kontraindikationen, Risiken, Nachsorge, max 2 Sätze)\n\n"
|
||
"Quelle\n"
|
||
"Allgemeines Fachwissen. Für Details siehe Fachliteratur.\n\n"
|
||
"Regeln: Max 120 Wörter. Fachsprache erlaubt. Medizinisch konservativ. "
|
||
"Keine Überinterpretation. KEINE Medikamenten-Informationen hier einfügen. "
|
||
"Keine Sternchen, kein Markdown, keine Aufzählungszeichen. "
|
||
"Jeder Abschnitt beginnt mit der Überschrift als eigene Zeile, gefolgt vom Inhalt. "
|
||
"Sprache: Deutsch."
|
||
)
|
||
def _open(name):
|
||
import webbrowser
|
||
webbrowser.open(self._get_ther_source_url(name))
|
||
self._build_detail_window(title, "#F3E5F5", prompt, detail,
|
||
self._THER_QUELLEN, "therapie_quelle", _open)
|
||
|
||
def _run_global_right_click_paste_listener(self):
|
||
"""
|
||
Globaler Rechtsklick-Hook (Windows): je nach Einstellung wird direkt
|
||
eingefügt (global) oder nur im One-Click-Modus nach Copy.
|
||
"""
|
||
if sys.platform != "win32" or _user32 is None:
|
||
return
|
||
stop_ev = getattr(self, "_autotext_hook_stop_event", None)
|
||
|
||
# Primär: pynput-Maushook (wenn vorhanden)
|
||
if _HAS_PYNPUT_MOUSE:
|
||
def on_click(x, y, button, pressed):
|
||
if pressed:
|
||
return
|
||
if button != MouseButton.right:
|
||
return
|
||
try:
|
||
hwnd = _user32.GetForegroundWindow()
|
||
self._handle_global_right_click(hwnd)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
lis = MouseListener(on_click=on_click)
|
||
lis.start()
|
||
with self._autotext_mouse_listener_lock:
|
||
self._autotext_active_mouse_listener = lis
|
||
lis.join()
|
||
except Exception:
|
||
# Fallback unten
|
||
pass
|
||
finally:
|
||
with self._autotext_mouse_listener_lock:
|
||
self._autotext_active_mouse_listener = None
|
||
return
|
||
|
||
# Fallback: polling über WinAPI, falls pynput.mouse fehlt/fehlschlägt
|
||
VK_RBUTTON = 0x02
|
||
was_down = False
|
||
while stop_ev is None or not stop_ev.is_set():
|
||
try:
|
||
is_down = bool(_user32.GetAsyncKeyState(VK_RBUTTON) & 0x8000)
|
||
if was_down and not is_down:
|
||
hwnd = _user32.GetForegroundWindow()
|
||
self._handle_global_right_click(hwnd)
|
||
was_down = is_down
|
||
except Exception:
|
||
pass
|
||
time.sleep(0.01)
|
||
|
||
def _handle_global_right_click(self, hwnd):
|
||
if not hwnd:
|
||
return
|
||
try:
|
||
always_on = self._is_global_right_click_paste_enabled()
|
||
if (not always_on) and float(getattr(self, "_one_click_paste_until", 0.0)) < time.time():
|
||
return
|
||
if self._is_own_process_window(hwnd):
|
||
return
|
||
if not always_on:
|
||
self._one_click_paste_until = 0.0
|
||
self._last_external_hwnd = hwnd
|
||
self._paste_to_hwnd_direct(hwnd)
|
||
except Exception:
|
||
pass
|
||
|
||
def _is_own_process_window(self, hwnd: int) -> bool:
|
||
"""Thread-sicher ohne Tk: Fenster gehört diesem Prozess?"""
|
||
if _user32 is None:
|
||
return False
|
||
try:
|
||
pid = wintypes.DWORD(0)
|
||
_user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||
return int(pid.value) == int(os.getpid())
|
||
except Exception:
|
||
return False
|
||
|
||
def _paste_to_hwnd_direct(self, hwnd) -> bool:
|
||
"""Direkter WinAPI-Paste ohne Tk-Calls (für Background-Threads)."""
|
||
if _user32 is None or not hwnd:
|
||
return False
|
||
try:
|
||
_user32.SetForegroundWindow(hwnd)
|
||
time.sleep(0.05)
|
||
self._send_escape_key()
|
||
time.sleep(0.15)
|
||
self._release_all_modifier_keys()
|
||
time.sleep(0.02)
|
||
self._send_ctrl_v()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _send_escape_key(self):
|
||
if _user32 is None:
|
||
return
|
||
try:
|
||
VK_ESCAPE = 0x1B
|
||
_user32.keybd_event(VK_ESCAPE, 0, 0, 0)
|
||
_user32.keybd_event(VK_ESCAPE, 0, 2, 0)
|
||
except Exception:
|
||
pass
|
||
|
||
def _is_global_right_click_paste_enabled(self) -> bool:
|
||
try:
|
||
return bool((self._autotext_data or {}).get("global_right_click_paste", True))
|
||
except Exception:
|
||
return True
|
||
|
||
def _toggle_rclick_paste(self):
|
||
new_val = self._rclick_paste_var.get()
|
||
if self._autotext_data is None:
|
||
self._autotext_data = {}
|
||
self._autotext_data["global_right_click_paste"] = new_val
|
||
try:
|
||
from aza_persistence import save_autotext
|
||
save_autotext(self._autotext_data)
|
||
except Exception:
|
||
pass
|
||
|
||
def _is_autocopy_enabled(self) -> bool:
|
||
try:
|
||
return bool((self._autotext_data or {}).get("autocopy_after_diktat", True))
|
||
except Exception:
|
||
return True
|
||
|
||
def _is_aza_window_hwnd(self, hwnd: int) -> bool:
|
||
"""True, wenn hwnd zu einem unserer AZA-Fenster gehört."""
|
||
try:
|
||
if hwnd == self.winfo_id():
|
||
return True
|
||
except Exception:
|
||
pass
|
||
try:
|
||
for w in self._get_registered_windows():
|
||
try:
|
||
if w.winfo_id() == hwnd:
|
||
return True
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
def _textblock_slot_at(self, x_root: int, y_root: int):
|
||
"""Wie textbloecke.py button_index_at: Slot ('1''4') des Buttons unter (x_root, y_root) per Rechteck-Test."""
|
||
if not hasattr(self, "_textbloecke_buttons"):
|
||
return None
|
||
try:
|
||
for slot, btn in self._textbloecke_buttons:
|
||
bx = btn.winfo_rootx()
|
||
by = btn.winfo_rooty()
|
||
bw = max(1, btn.winfo_width())
|
||
bh = max(1, btn.winfo_height())
|
||
if bx <= x_root <= bx + bw and by <= y_root <= by + bh:
|
||
return slot
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
return None
|
||
|
||
def _textblock_on_drag_motion(self, event):
|
||
"""Wie textbloecke.py on_drag_motion: Sobald Maus sich bewegt und wir Text haben Drag aktiv."""
|
||
d = getattr(self, "_textblock_drag_data", None)
|
||
if d is not None and d.get("text"):
|
||
d["active"] = True
|
||
|
||
def _on_global_drag_release(self, event):
|
||
"""Wie textbloecke.py on_release: Maus losgelassen wenn Drag aktiv und über Button dort speichern; sonst in Zwischenablage für externes Einfügen."""
|
||
d = getattr(self, "_textblock_drag_data", None)
|
||
if d is None:
|
||
return
|
||
if not d.get("active") or not d.get("text"):
|
||
d["text"] = None
|
||
d["active"] = False
|
||
return
|
||
slot = self._textblock_slot_at(event.x_root, event.y_root)
|
||
if slot:
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = d["text"] or ""
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Text in Textblock gezogen und gespeichert.")
|
||
self._just_dropped_on_textblock = True
|
||
else:
|
||
try:
|
||
t = (d["text"] or "").strip()
|
||
if not _win_clipboard_set(t):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(t))
|
||
self.set_status("Text in Zwischenablage in Editor/andere App mit Strg+V einfügen.")
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
d["text"] = None
|
||
d["active"] = False
|
||
|
||
def _on_focus_out_for_external_paste(self, event):
|
||
"""Wenn unser Fenster den Fokus verliert: externes Fenster merken, Cursor-Ziel bei uns verwerfen."""
|
||
if event.widget is not self:
|
||
return
|
||
self._last_focused_text_widget = None
|
||
self.after(120, self._store_last_external_window)
|
||
|
||
def _store_last_external_window(self):
|
||
"""Unter Windows: merkt sich das aktuell aktive Fenster (externes Programm)."""
|
||
if _user32 is None:
|
||
return
|
||
try:
|
||
hwnd = _user32.GetForegroundWindow()
|
||
if hwnd and hwnd != self.winfo_id():
|
||
self._last_external_hwnd = hwnd
|
||
except Exception:
|
||
pass
|
||
|
||
def _paste_to_external_window(self):
|
||
"""Unter Windows: aktiviert das zuletzt gespeicherte externe Fenster und sendet Strg+V."""
|
||
if _user32 is None:
|
||
return False
|
||
hwnd = getattr(self, "_last_external_hwnd", None)
|
||
if not hwnd:
|
||
return False
|
||
return self._paste_to_hwnd_direct(hwnd)
|
||
|
||
def _send_escape_then_ctrl_v(self):
|
||
self._send_escape_key()
|
||
self.after(30, self._send_ctrl_v)
|
||
|
||
def _release_all_modifier_keys(self):
|
||
"""Setzt Ctrl/Alt/Shift/Win explizit auf KEYUP, verhindert Ghost-Keys."""
|
||
if _user32 is None:
|
||
return
|
||
try:
|
||
KEYEVENTF_KEYUP = 0x0002
|
||
for vk in (0x10, 0x11, 0x12, 0x5B, 0x5C): # Shift, Ctrl, Alt, LWin, RWin
|
||
_user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0)
|
||
except Exception:
|
||
pass
|
||
|
||
def _send_ctrl_v(self):
|
||
"""Sendet Strg+V (Einfügen) an das aktive Fenster."""
|
||
if _user32 is None:
|
||
return
|
||
if self._send_key_combo_sendinput(0x11, 0x56):
|
||
return
|
||
try:
|
||
VK_CONTROL = 0x11
|
||
VK_V = 0x56
|
||
_user32.keybd_event(VK_CONTROL, 0, 0, 0)
|
||
time.sleep(0.02)
|
||
_user32.keybd_event(VK_V, 0, 0, 0)
|
||
time.sleep(0.02)
|
||
_user32.keybd_event(VK_V, 0, 2, 0)
|
||
time.sleep(0.02)
|
||
_user32.keybd_event(VK_CONTROL, 0, 2, 0)
|
||
except Exception:
|
||
pass
|
||
|
||
def _send_key_combo_sendinput(self, vk_mod: int, vk_key: int) -> bool:
|
||
if _user32 is None:
|
||
return False
|
||
try:
|
||
KEYEVENTF_KEYUP = 0x0002
|
||
INPUT_KEYBOARD = 1
|
||
|
||
class KEYBDINPUT(ctypes.Structure):
|
||
_fields_ = [
|
||
("wVk", wintypes.WORD),
|
||
("wScan", wintypes.WORD),
|
||
("dwFlags", wintypes.DWORD),
|
||
("time", wintypes.DWORD),
|
||
("dwExtraInfo", ctypes.c_size_t),
|
||
]
|
||
|
||
class INPUT(ctypes.Structure):
|
||
_fields_ = [("type", wintypes.DWORD), ("ki", KEYBDINPUT)]
|
||
|
||
seq = [
|
||
INPUT(INPUT_KEYBOARD, KEYBDINPUT(vk_mod, 0, 0, 0, 0)),
|
||
INPUT(INPUT_KEYBOARD, KEYBDINPUT(vk_key, 0, 0, 0, 0)),
|
||
INPUT(INPUT_KEYBOARD, KEYBDINPUT(vk_key, 0, KEYEVENTF_KEYUP, 0, 0)),
|
||
INPUT(INPUT_KEYBOARD, KEYBDINPUT(vk_mod, 0, KEYEVENTF_KEYUP, 0, 0)),
|
||
]
|
||
arr = (INPUT * len(seq))(*seq)
|
||
sent = int(_user32.SendInput(len(arr), ctypes.byref(arr), ctypes.sizeof(INPUT)))
|
||
return sent == len(arr)
|
||
except Exception:
|
||
return False
|
||
|
||
def _arm_one_click_external_paste(self, seconds: int = 120):
|
||
"""Aktiviert einmaliges Rechtsklick->Einfügen in externen Apps (z. B. Word)."""
|
||
try:
|
||
self._one_click_paste_until = time.time() + max(5, int(seconds))
|
||
except Exception:
|
||
self._one_click_paste_until = time.time() + 120
|
||
|
||
def _sync_textbloecke_data_from_disk(self) -> None:
|
||
"""Lädt Textblöcke von der Platte — z. B. Office-Sidebar oder Editor-Sync."""
|
||
try:
|
||
from aza_persistence import load_textbloecke
|
||
|
||
self._textbloecke_data = load_textbloecke()
|
||
except Exception:
|
||
pass
|
||
|
||
def _office_sidebar_insert_textblock(self, slot: str) -> None:
|
||
"""Sidebar-Klick: Inhalt wie _copy_textblock einfügen, immer mit aktueller Dateidatenbasis."""
|
||
self._sync_textbloecke_data_from_disk()
|
||
self._copy_textblock(str(slot))
|
||
|
||
def _textblock_copy_to_clipboard(self, slot: str):
|
||
"""Kopiert den Textblock-Inhalt in die Zwischenablage (z. B. beim Drücken/Ziehen in anderen Editor)."""
|
||
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
|
||
if not content.strip():
|
||
return
|
||
try:
|
||
if not _win_clipboard_set(content):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(content))
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
|
||
def _copy_textblock(self, slot: str):
|
||
"""Klick auf Button: Text dort einfügen, wo der Cursor ist bei uns im Hauptfenster oder im externen Programm."""
|
||
if getattr(self, "_just_dropped_on_textblock", False):
|
||
self._just_dropped_on_textblock = False
|
||
return
|
||
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
|
||
if not content.strip():
|
||
self.set_status("Textblock ist leer. Rechtsklick → 'Aus Zwischenablage speichern' oder Shift+Klick zum Befüllen.")
|
||
return
|
||
try:
|
||
if not _win_clipboard_set(content):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(content))
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
w = getattr(self, "_last_focused_text_widget", None)
|
||
try:
|
||
if w is not None and w.winfo_exists() and hasattr(w, "insert"):
|
||
w.focus_set()
|
||
w.insert(tk.INSERT, content)
|
||
w.see(tk.INSERT)
|
||
self.set_status("Textblock an Cursorposition eingefügt.")
|
||
return
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
ext_hwnd = getattr(self, "_last_external_hwnd", None)
|
||
if sys.platform == "win32" and _user32 and ext_hwnd:
|
||
def _do_external_paste():
|
||
self._paste_to_hwnd_direct(ext_hwnd)
|
||
self.set_status("Textblock in externes Programm eingefügt.")
|
||
self.after(80, _do_external_paste)
|
||
return
|
||
if hasattr(self, "txt_output"):
|
||
try:
|
||
w2 = self.txt_output
|
||
if w2.winfo_exists() and hasattr(w2, "insert"):
|
||
w2.focus_set()
|
||
w2.insert(tk.INSERT, content)
|
||
w2.see(tk.INSERT)
|
||
self.set_status("Textblock an Cursorposition eingefügt.")
|
||
return
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
self.set_status("Textblock in Zwischenablage → Cursor setzen, dann Strg+V.")
|
||
|
||
def _get_selection_from_focus(self) -> str:
|
||
"""Liefert die aktuelle Auswahl aus einem Text-Widget (Transkript, KG, Diktat, Brief etc.)."""
|
||
def find_text_with_selection(widget):
|
||
try:
|
||
if widget.winfo_class() == "Text" and hasattr(widget, "selection_present") and widget.selection_present():
|
||
return widget.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||
for c in widget.winfo_children():
|
||
r = find_text_with_selection(c)
|
||
if r:
|
||
return r
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
return ""
|
||
w = self.focus_get()
|
||
try:
|
||
if w is not None and hasattr(w, "selection_present") and w.selection_present():
|
||
return w.get(tk.SEL_FIRST, tk.SEL_LAST)
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
return find_text_with_selection(self) or ""
|
||
|
||
def _save_selection_to_textblock(self, event, slot: str):
|
||
"""Speichert die aktuelle Textauswahl (aus beliebigem Feld) in den Textblock. Shift+Klick oder Menü."""
|
||
text = self._get_selection_from_focus()
|
||
if not text.strip():
|
||
self.set_status("Keine Auswahl Text markieren oder Rechtsklick Aus Zwischenablage speichern.")
|
||
return
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Auswahl in Textblock gespeichert.")
|
||
|
||
def _textblock_context(self, event, slot: str):
|
||
"""Rechtsklick-Menü: Einfügen, Speichern (Auswahl/Zwischenablage), Umbenennen, Löschen."""
|
||
menu = tk.Menu(self, tearoff=0)
|
||
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
|
||
if content.strip():
|
||
menu.add_command(
|
||
label="An Cursorposition einfügen",
|
||
command=lambda: self._textblock_insert_at_cursor(slot),
|
||
)
|
||
ext_hwnd = getattr(self, "_last_external_hwnd", None)
|
||
if sys.platform == "win32" and _user32 and ext_hwnd:
|
||
menu.add_command(
|
||
label="In externes Feld einfügen",
|
||
command=lambda: self._textblock_paste_to_external(slot),
|
||
)
|
||
menu.add_separator()
|
||
menu.add_command(
|
||
label="Aus Zwischenablage speichern",
|
||
command=lambda: self._textblock_save_from_clipboard(slot),
|
||
)
|
||
menu.add_command(
|
||
label="Markierung speichern",
|
||
command=lambda: self._textblock_save_from_selection(slot),
|
||
)
|
||
menu.add_separator()
|
||
menu.add_command(
|
||
label="Umbenennen",
|
||
command=lambda: self._textblock_rename(slot),
|
||
)
|
||
menu.add_command(
|
||
label="Textblock leeren",
|
||
command=lambda: self._textblock_clear(slot),
|
||
)
|
||
try:
|
||
menu.tk_popup(event.x_root, event.y_root)
|
||
finally:
|
||
menu.grab_release()
|
||
|
||
def _textblock_paste_to_external(self, slot: str):
|
||
"""Fügt den Textblock direkt in das zuletzt aktive externe Fenster ein."""
|
||
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
|
||
if not content.strip():
|
||
self.set_status("Textblock ist leer.")
|
||
return
|
||
try:
|
||
if not _win_clipboard_set(content):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(content))
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
ext_hwnd = getattr(self, "_last_external_hwnd", None)
|
||
if ext_hwnd and _user32:
|
||
def _do():
|
||
self._paste_to_hwnd_direct(ext_hwnd)
|
||
self.set_status("Textblock in externes Programm eingefügt.")
|
||
self.after(80, _do)
|
||
else:
|
||
self.set_status("Kein externes Fenster erkannt → Strg+V zum Einfügen.")
|
||
|
||
def _textblock_insert_at_cursor(self, slot: str):
|
||
"""Fügt den Textblock-Inhalt an der Cursorposition ein (intern oder extern)."""
|
||
content = (self._textbloecke_data.get(slot) or {}).get("content") or ""
|
||
if not content.strip():
|
||
self.set_status("Textblock ist leer.")
|
||
return
|
||
w = getattr(self, "_last_focused_text_widget", None)
|
||
if w is None and hasattr(self, "txt_output"):
|
||
w = self.txt_output
|
||
try:
|
||
if w is not None and w.winfo_exists() and hasattr(w, "insert"):
|
||
w.focus_set()
|
||
w.insert(tk.INSERT, content)
|
||
w.see(tk.INSERT)
|
||
self.set_status("Textblock an Cursorposition eingefügt.")
|
||
return
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
try:
|
||
if not _win_clipboard_set(content):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(content))
|
||
except (tk.TclError, AttributeError):
|
||
pass
|
||
ext_hwnd = getattr(self, "_last_external_hwnd", None)
|
||
if sys.platform == "win32" and _user32 and ext_hwnd:
|
||
def _do_ext():
|
||
self._paste_to_hwnd_direct(ext_hwnd)
|
||
self.set_status("Textblock in externes Programm eingefügt.")
|
||
self.after(80, _do_ext)
|
||
return
|
||
self.set_status("Textblock in Zwischenablage → mit Strg+V einfügen.")
|
||
|
||
def _textblock_clear(self, slot: str):
|
||
"""Löscht den Inhalt (und optional den Namen) des Textblocks."""
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = ""
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Textblock gelöscht.")
|
||
|
||
def _textblock_save_from_selection(self, slot: str):
|
||
"""Speichert die Auswahl des fokussierten Textfelds in den Textblock."""
|
||
text = self._get_selection_from_focus()
|
||
if not text.strip():
|
||
messagebox.showinfo("Hinweis", "Bitte zuerst Text markieren (z. B. im Transkript, in der KG oder im Diktat-Fenster).")
|
||
return
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Auswahl in Textblock gespeichert.")
|
||
|
||
def _textblock_save_from_clipboard_or_selection(self, slot: str):
|
||
"""Shift+Klick: Speichert Textauswahl (falls vorhanden) oder Zwischenablage in den Textblock."""
|
||
text = self._get_selection_from_focus()
|
||
if not text.strip():
|
||
try:
|
||
text = self.clipboard_get()
|
||
except Exception:
|
||
text = ""
|
||
if isinstance(text, str) and text.strip():
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Text in Textblock gespeichert (Shift+Klick).")
|
||
else:
|
||
self.set_status("Kein Text markiert oder in Zwischenablage.")
|
||
|
||
def _textblock_save_from_clipboard(self, slot: str):
|
||
"""Speichert den Inhalt der Zwischenablage in den Textblock (überschreibt)."""
|
||
try:
|
||
text = self.clipboard_get()
|
||
except Exception:
|
||
text = ""
|
||
if isinstance(text, str):
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["content"] = text
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Textblock gespeichert.")
|
||
else:
|
||
self.set_status("Kein Text in Zwischenablage.")
|
||
|
||
def _textblock_rename(self, slot: str):
|
||
"""Umbenennen direkt am Button: Entry überlagert den Button, kein neues Fenster."""
|
||
def _do_rename():
|
||
btn = None
|
||
for s, b in self._textbloecke_buttons:
|
||
if s == slot:
|
||
btn = b
|
||
break
|
||
if not btn or not btn.winfo_exists():
|
||
return
|
||
d = self._textbloecke_data.get(slot) or {"name": "", "content": ""}
|
||
current = (d.get("name") or "").strip() or f"Textblock {slot}"
|
||
parent = btn.nametowidget(btn.winfo_parent())
|
||
entry = ttk.Entry(parent, width=12, font=("Segoe UI", 11))
|
||
entry.insert(0, current)
|
||
entry.select_range(0, tk.END)
|
||
parent.update_idletasks()
|
||
entry.place(x=btn.winfo_x(), y=btn.winfo_y(), width=btn.winfo_width(), height=btn.winfo_height())
|
||
entry.focus_set()
|
||
|
||
def finish(save: bool):
|
||
if save:
|
||
new_name = entry.get().strip()
|
||
self._textbloecke_data.setdefault(slot, {"name": "", "content": ""})["name"] = new_name or f"Textblock {slot}"
|
||
save_textbloecke(self._textbloecke_data)
|
||
self._refresh_textblock_button(slot)
|
||
self.set_status("Button umbenannt.")
|
||
entry.place_forget()
|
||
entry.destroy()
|
||
|
||
entry.bind("<Return>", lambda e: finish(True))
|
||
entry.bind("<Escape>", lambda e: finish(False))
|
||
self.after(150, _do_rename)
|
||
|
||
def _new_session(self):
|
||
# Vor dem Leeren: KG und (falls vorhanden) Brief/Rezept/KOGU zuverlässig in Ablage speichern (JSON + .txt)
|
||
def get_str(widget_or_str):
|
||
if widget_or_str is None:
|
||
return ""
|
||
if hasattr(widget_or_str, "get"):
|
||
try:
|
||
s = widget_or_str.get("1.0", "end")
|
||
return (s if isinstance(s, str) else str(s)).strip()
|
||
except Exception:
|
||
return ""
|
||
return (str(widget_or_str)).strip()
|
||
kg_text = get_str(self.txt_output)
|
||
brief_text = get_str(self._last_brief_text)
|
||
rezept_text = get_str(self._last_rezept_text)
|
||
kogu_text = get_str(self._last_kogu_text)
|
||
try:
|
||
if kg_text:
|
||
save_to_ablage("KG", kg_text)
|
||
if brief_text:
|
||
save_to_ablage("Briefe", brief_text)
|
||
if rezept_text:
|
||
save_to_ablage("Rezepte", rezept_text)
|
||
if kogu_text:
|
||
save_to_ablage("Kostengutsprachen", kogu_text)
|
||
except Exception as e:
|
||
try:
|
||
messagebox.showerror("Speichern bei Neu", str(e))
|
||
except Exception:
|
||
pass
|
||
self._last_brief_text = ""
|
||
self._last_rezept_text = ""
|
||
self._last_kogu_text = ""
|
||
self.txt_transcript.delete("1.0", "end")
|
||
self.txt_output.delete("1.0", "end")
|
||
self.set_status("Bereit.")
|
||
|
||
def copy_output(self):
|
||
text = self.txt_output.get("1.0", "end").strip()
|
||
if not text:
|
||
self.set_status("Kein Diktat/KG vorhanden zum Kopieren.")
|
||
return
|
||
if not _win_clipboard_set(text):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(text))
|
||
self._arm_one_click_external_paste()
|
||
self.set_status("KG kopiert.")
|
||
|
||
def copy_transcript(self):
|
||
text = self.txt_transcript.get("1.0", "end").strip()
|
||
if not text:
|
||
self.set_status("Keine Audio aufgenommen oder kein Transkript vorhanden.")
|
||
return
|
||
if not _win_clipboard_set(text):
|
||
self.clipboard_clear()
|
||
self.clipboard_append(sanitize_markdown_for_plain_text(text))
|
||
self._arm_one_click_external_paste()
|
||
self.set_status("Transkript kopiert.")
|
||
|
||
|
||
def _get_setup_helper_path() -> Optional[str]:
|
||
"""Gibt den Pfad zum setup_openai_runtime.ps1 zurück, falls vorhanden."""
|
||
candidates = []
|
||
if getattr(sys, "frozen", False):
|
||
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
|
||
candidates.append(os.path.join(exe_dir, "setup_openai_runtime.ps1"))
|
||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||
candidates.append(os.path.join(script_dir, "setup_openai_runtime.ps1"))
|
||
for p in candidates:
|
||
if os.path.isfile(p):
|
||
return p
|
||
return None
|
||
|
||
|
||
def _run_setup_helper() -> bool:
|
||
"""Startet den Setup-Helfer und wartet auf Beendigung. Gibt True zurück bei Erfolg."""
|
||
helper = _get_setup_helper_path()
|
||
if not helper:
|
||
return False
|
||
try:
|
||
result = subprocess.run(
|
||
["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", helper],
|
||
cwd=os.path.dirname(helper),
|
||
)
|
||
return result.returncode == 0
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _show_openai_key_setup_dialog():
|
||
"""Premium-Dialog: API-Key direkt eingeben und verschlüsselt im Tresor speichern."""
|
||
has_helper = _get_setup_helper_path() is not None
|
||
|
||
_BG = "#FFFFFF"
|
||
_ACCENT = "#0078D7"
|
||
_ACCENT_HOVER = "#005FA3"
|
||
_TEXT = "#2D3436"
|
||
_SUBTLE = "#636E72"
|
||
_BORDER = "#E2E8F0"
|
||
_SUCCESS = "#00B894"
|
||
|
||
dlg = tk.Toplevel()
|
||
dlg.title("AZA \u2013 KI-Verbindung einrichten")
|
||
dlg.configure(bg=_BG)
|
||
dlg.resizable(False, False)
|
||
w, h = 540, 460
|
||
dlg.geometry(f"{w}x{h}")
|
||
dlg.attributes("-topmost", True)
|
||
|
||
try:
|
||
dlg.update_idletasks()
|
||
sw = dlg.winfo_screenwidth()
|
||
sh = dlg.winfo_screenheight()
|
||
dlg.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
|
||
except Exception:
|
||
pass
|
||
|
||
content = tk.Frame(dlg, bg=_BG)
|
||
content.pack(fill="both", expand=True, padx=40, pady=28)
|
||
|
||
tk.Label(content, text="\U0001F512",
|
||
font=("Segoe UI", 28), fg=_ACCENT, bg=_BG).pack(anchor="w")
|
||
|
||
tk.Label(content, text="KI-Verbindung einrichten",
|
||
font=("Segoe UI", 18, "bold"), fg=_TEXT, bg=_BG
|
||
).pack(anchor="w", pady=(6, 4))
|
||
|
||
tk.Label(content,
|
||
text="Geben Sie Ihren OpenAI-Schlüssel ein, um die\n"
|
||
"KI-Unterstützung zu aktivieren.\n\n"
|
||
"Hinweis: Der Schlüssel wird pro Windows-Benutzer\n"
|
||
"verschlüsselt gespeichert. Jeder Benutzer auf\n"
|
||
"diesem Computer muss seinen eigenen Schlüssel einrichten.",
|
||
font=("Segoe UI", 11), fg=_SUBTLE, bg=_BG,
|
||
justify="left").pack(anchor="w", pady=(0, 18))
|
||
|
||
tk.Label(content, text="API-Schlüssel:",
|
||
font=("Segoe UI", 10, "bold"), fg=_TEXT, bg=_BG
|
||
).pack(anchor="w", pady=(0, 4))
|
||
|
||
key_frame = tk.Frame(content, bg=_BORDER, highlightthickness=0)
|
||
key_frame.pack(fill="x", pady=(0, 6))
|
||
|
||
key_entry = tk.Entry(
|
||
key_frame, font=("Consolas", 12), bg="white", fg=_TEXT,
|
||
relief="flat", bd=0, show="\u2022",
|
||
)
|
||
key_entry.pack(fill="x", ipady=8, padx=2, pady=2)
|
||
|
||
status_label = tk.Label(
|
||
content, text="\U0001F512 Ihr Schlüssel wird verschlüsselt gespeichert",
|
||
font=("Segoe UI", 8), fg=_SUBTLE, bg=_BG, justify="left",
|
||
)
|
||
status_label.pack(anchor="w", pady=(0, 14))
|
||
|
||
result = {"action": None}
|
||
|
||
def do_activate():
|
||
key_val = key_entry.get().strip()
|
||
if not key_val:
|
||
status_label.configure(text="\u26A0 Bitte geben Sie einen Schlüssel ein.", fg="#E05050")
|
||
return
|
||
if len(key_val) < 10:
|
||
status_label.configure(text="\u26A0 Dieser Schlüssel ist zu kurz.", fg="#E05050")
|
||
return
|
||
ok = store_api_key(key_val)
|
||
if ok:
|
||
os.environ["OPENAI_API_KEY"] = key_val
|
||
result["action"] = "stored"
|
||
dlg.destroy()
|
||
else:
|
||
status_label.configure(text="\u26A0 Speichern fehlgeschlagen.", fg="#E05050")
|
||
|
||
def do_helper():
|
||
result["action"] = "setup"
|
||
dlg.destroy()
|
||
|
||
def do_config():
|
||
result["action"] = "config"
|
||
dlg.destroy()
|
||
|
||
def do_skip():
|
||
result["action"] = "skip"
|
||
dlg.destroy()
|
||
|
||
key_entry.bind("<Return>", lambda e: do_activate())
|
||
|
||
btn_area = tk.Frame(content, bg=_BG)
|
||
btn_area.pack(fill="x", pady=(0, 10))
|
||
|
||
btn_primary = tk.Button(
|
||
btn_area, text="\u2713 Schlüssel aktivieren",
|
||
font=("Segoe UI", 11, "bold"),
|
||
bg=_ACCENT, fg="white", activebackground=_ACCENT_HOVER,
|
||
activeforeground="white",
|
||
relief="flat", bd=0, padx=24, pady=10, cursor="hand2",
|
||
command=do_activate,
|
||
)
|
||
btn_primary.pack(side="left")
|
||
|
||
def _on_enter_p(e):
|
||
btn_primary.configure(bg=_ACCENT_HOVER)
|
||
def _on_leave_p(e):
|
||
btn_primary.configure(bg=_ACCENT)
|
||
btn_primary.bind("<Enter>", _on_enter_p)
|
||
btn_primary.bind("<Leave>", _on_leave_p)
|
||
|
||
btn_skip = tk.Button(
|
||
btn_area, text="Später",
|
||
font=("Segoe UI", 10),
|
||
bg=_BG, fg=_SUBTLE, activebackground="#F0F0F0",
|
||
relief="solid", bd=1, padx=18, pady=8, cursor="hand2",
|
||
highlightbackground=_BORDER,
|
||
command=do_skip,
|
||
)
|
||
btn_skip.pack(side="left", padx=(12, 0))
|
||
|
||
links = tk.Frame(content, bg=_BG)
|
||
links.pack(fill="x")
|
||
|
||
if has_helper:
|
||
lnk_setup = tk.Label(links, text="\u2192 Einrichtungsassistent starten",
|
||
font=("Segoe UI", 9, "underline"), fg=_SUBTLE, bg=_BG,
|
||
cursor="hand2")
|
||
lnk_setup.pack(anchor="w", pady=(0, 2))
|
||
lnk_setup.bind("<Button-1>", lambda e: do_helper())
|
||
|
||
lnk_config = tk.Label(links, text="\u2192 Konfiguration manuell öffnen",
|
||
font=("Segoe UI", 9, "underline"), fg=_SUBTLE, bg=_BG,
|
||
cursor="hand2")
|
||
lnk_config.pack(anchor="w")
|
||
lnk_config.bind("<Button-1>", lambda e: do_config())
|
||
|
||
dlg.protocol("WM_DELETE_WINDOW", do_skip)
|
||
dlg.grab_set()
|
||
dlg.wait_window(dlg)
|
||
|
||
if result["action"] == "stored":
|
||
return
|
||
elif result["action"] == "setup":
|
||
success = _run_setup_helper()
|
||
if success and has_openai_api_key():
|
||
return
|
||
if success:
|
||
messagebox.showinfo(
|
||
"Einrichtung",
|
||
"Die Einrichtung wurde abgeschlossen.\n\n"
|
||
"Bitte starten Sie AZA neu, damit die\n"
|
||
"KI-Funktionen aktiv werden.",
|
||
)
|
||
else:
|
||
messagebox.showinfo(
|
||
"Einrichtung",
|
||
"Die automatische Einrichtung ist auf diesem\n"
|
||
"System nicht verfügbar.\n\n"
|
||
"Bitte nutzen Sie den Startmenü-Eintrag\n"
|
||
"\"AZA \u2013 OpenAI Schlüssel einrichten\".",
|
||
)
|
||
elif result["action"] == "config":
|
||
open_runtime_config_in_editor()
|
||
|
||
|
||
def _has_remote_backend() -> bool:
|
||
"""True when a non-localhost backend URL is configured."""
|
||
url = os.getenv("MEDWORK_BACKEND_URL", "").strip()
|
||
if not url:
|
||
search_dirs: list[str] = []
|
||
if getattr(sys, "frozen", False):
|
||
_exe = os.path.dirname(os.path.abspath(sys.executable))
|
||
search_dirs.append(_exe)
|
||
search_dirs.append(os.path.join(_exe, "_internal"))
|
||
_src = os.path.dirname(os.path.abspath(__file__))
|
||
search_dirs.append(_src)
|
||
search_dirs.append(os.path.join(_src, "_internal"))
|
||
search_dirs.append(os.getcwd())
|
||
seen: set[str] = set()
|
||
for base in search_dirs:
|
||
if not base or base in seen:
|
||
continue
|
||
seen.add(base)
|
||
p = os.path.join(base, "backend_url.txt")
|
||
if os.path.isfile(p):
|
||
try:
|
||
with open(p, "r", encoding="utf-8-sig") as f:
|
||
url = f.read().replace("\ufeff", "").strip()
|
||
except Exception:
|
||
continue
|
||
if url:
|
||
break
|
||
if not url:
|
||
return False
|
||
return not any(h in url for h in ("127.0.0.1", "localhost", "0.0.0.0"))
|
||
|
||
|
||
def _show_activation_gate() -> bool:
|
||
"""Prüft den Zugang vor dem Hauptstart.
|
||
|
||
Reihenfolge (Root-Cause-first):
|
||
|
||
1. Bei konfiguriertem Remote-Backend ist der Backend-Lizenzstatus
|
||
führend – das lokale Gate wird übersprungen.
|
||
2. Bestehende, gültige Aktivierung auf demselben Gerät: sofort durchlassen,
|
||
ohne Dialog.
|
||
3. Aktive Testphase: optionaler Schlüssel-Dialog mit klar sichtbarer
|
||
Option ``Im Demomodus weiterfahren``.
|
||
4. Trial abgelaufen: Dialog mit Schlüsseleingabe UND zusätzlich
|
||
``Im Demomodus weiterfahren`` (kein Dead-End).
|
||
|
||
Returns:
|
||
``True`` wenn die App starten darf, ``False`` wenn der Benutzer
|
||
explizit ``Beenden`` gewählt hat.
|
||
"""
|
||
from aza_activation import (
|
||
check_app_access, load_activation_key, validate_key,
|
||
save_activation_key, mark_demo_opt_in,
|
||
)
|
||
|
||
if _has_remote_backend():
|
||
print(
|
||
"[ACTIVATION] Remote-Backend konfiguriert – lokales "
|
||
"Aktivierungs-Gate uebersprungen. Backend-Lizenzstatus ist fuehrend."
|
||
)
|
||
return True
|
||
|
||
# 2) Bestehende Aktivierung auf demselben Computer zuerst prüfen.
|
||
stored = load_activation_key()
|
||
if stored:
|
||
ok, expiry, reason = validate_key(stored)
|
||
if ok:
|
||
print(f"[ACTIVATION] Vorhandener Schlüssel akzeptiert: {reason}")
|
||
return True
|
||
else:
|
||
print(
|
||
f"[ACTIVATION] Gespeicherter Schlüssel nicht (mehr) gültig: {reason}"
|
||
)
|
||
|
||
allowed, msg = check_app_access()
|
||
|
||
# 3) Trial noch aktiv – Dialog optional, Demo immer möglich.
|
||
if allowed:
|
||
print(f"[ACTIVATION] {msg}")
|
||
is_trial = not stored or not validate_key(stored)[0]
|
||
if not is_trial:
|
||
return True
|
||
trial_msg = (
|
||
msg
|
||
+ "\n\nSie können jetzt einen Aktivierungsschlüssel eingeben "
|
||
"oder im Demomodus weiterfahren."
|
||
)
|
||
result = _run_activation_dialog(trial_msg, trial_active=True)
|
||
if result.get("action") == "activate":
|
||
entered = result.get("key") or ""
|
||
valid, _, reason = validate_key(entered)
|
||
if valid:
|
||
save_activation_key(entered)
|
||
print(f"[ACTIVATION] Schlüssel akzeptiert: {reason}")
|
||
else:
|
||
print(f"[ACTIVATION] Schlüssel abgelehnt: {reason}")
|
||
elif result.get("action") == "demo":
|
||
mark_demo_opt_in()
|
||
print("[ACTIVATION] Benutzer fährt im Demomodus weiter (Trial aktiv).")
|
||
return True
|
||
|
||
# 4) Trial abgelaufen – Eingabeschleife mit Demo-Ausweg.
|
||
current_msg = msg
|
||
while True:
|
||
result = _run_activation_dialog(current_msg, trial_active=False)
|
||
action = result.get("action")
|
||
if action == "quit":
|
||
return False
|
||
if action == "demo":
|
||
mark_demo_opt_in()
|
||
print(
|
||
"[ACTIVATION] Benutzer fährt im Demomodus weiter "
|
||
"(Trial abgelaufen, kein gültiger Schlüssel)."
|
||
)
|
||
return True
|
||
# action == "activate"
|
||
entered = result.get("key") or ""
|
||
valid, _, reason = validate_key(entered)
|
||
if valid:
|
||
save_activation_key(entered)
|
||
print(f"[ACTIVATION] Schlüssel akzeptiert: {reason}")
|
||
return True
|
||
current_msg = (
|
||
f"Schlüssel ungültig: {reason}\n"
|
||
"Bitte erneut versuchen oder im Demomodus weiterfahren."
|
||
)
|
||
|
||
|
||
def _run_activation_dialog(message: str, *, trial_active: bool) -> dict:
|
||
"""Wrapper, der einen frischen Tk-Root erstellt, das Dialogfenster
|
||
anzeigt und das Resultat-Dict zurückliefert."""
|
||
root = tk.Tk()
|
||
root.withdraw()
|
||
try:
|
||
return _activation_key_dialog(root, message, trial_active=trial_active)
|
||
finally:
|
||
try:
|
||
root.destroy()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _activation_key_dialog(parent, message: str, *, trial_active: bool) -> dict:
|
||
"""Aktivierungsdialog im neuen AzA-Stil (Empfang-Palette).
|
||
|
||
* Logo links, "AzA Aktivierung" als Überschrift
|
||
* Empfang-Blau (``#5B8DB3``) für Akzent / Aktivieren
|
||
* gleich grosse Pill-Buttons
|
||
* immer eine sichtbare ``Im Demomodus weiterfahren``-Aktion
|
||
|
||
Returns:
|
||
Dict mit ``action`` ∈ ``{"activate", "demo", "quit"}`` und ggf. ``key``.
|
||
"""
|
||
# Empfang-/AzA-Hüllen-Farbpalette (siehe aza_desktop_shell.LIGHT).
|
||
_BG = "#EAF2F7"
|
||
_SURFACE = "#FFFFFF"
|
||
_BORDER = "#D6E2EB"
|
||
_TEXT = "#1A4D6D"
|
||
_TEXT_STRONG = "#0F3850"
|
||
_SUBTLE = "#5C7A8E"
|
||
_ACCENT = "#5B8DB3"
|
||
_ACCENT_HOVER = "#4A7A9E"
|
||
_ACCENT_PRESSED = "#3A6884"
|
||
_SOFT = "#E2EEF6"
|
||
_DANGER_FG = "#C0392B"
|
||
|
||
result: dict = {"action": "quit", "key": None}
|
||
|
||
dlg = tk.Toplevel(parent)
|
||
dlg.title("AzA \u2013 Aktivierung")
|
||
dlg.configure(bg=_BG)
|
||
dlg.resizable(True, True)
|
||
w, h = 560, 540
|
||
dlg.minsize(480, 460)
|
||
dlg.geometry(f"{w}x{h}")
|
||
dlg.attributes("-topmost", True)
|
||
|
||
# Fenster-Icon (logo.ico) konsistent zum Hauptfenster.
|
||
try:
|
||
for _base in (
|
||
os.path.dirname(os.path.abspath(__file__)),
|
||
getattr(sys, "_MEIPASS", "") or "",
|
||
os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else "",
|
||
):
|
||
if not _base:
|
||
continue
|
||
_icp = os.path.join(_base, "logo.ico")
|
||
if os.path.isfile(_icp):
|
||
dlg.iconbitmap(_icp)
|
||
break
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
dlg.update_idletasks()
|
||
sw = dlg.winfo_screenwidth()
|
||
sh = dlg.winfo_screenheight()
|
||
dlg.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Karte ──
|
||
card = tk.Frame(dlg, bg=_SURFACE, highlightthickness=1,
|
||
highlightbackground=_BORDER, bd=0)
|
||
card.pack(fill="both", expand=True, padx=22, pady=22)
|
||
|
||
content = tk.Frame(card, bg=_SURFACE)
|
||
content.pack(fill="both", expand=True, padx=28, pady=24)
|
||
|
||
# ── Header: Logo + Branding ──
|
||
header = tk.Frame(content, bg=_SURFACE)
|
||
header.pack(fill="x", pady=(0, 14))
|
||
|
||
logo_photo = None
|
||
try:
|
||
from PIL import Image, ImageTk
|
||
for _base in (
|
||
os.path.dirname(os.path.abspath(__file__)),
|
||
getattr(sys, "_MEIPASS", "") or "",
|
||
os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else "",
|
||
):
|
||
if not _base:
|
||
continue
|
||
_lp = os.path.join(_base, "logo.png")
|
||
if not os.path.isfile(_lp):
|
||
continue
|
||
_img = Image.open(_lp)
|
||
if _img.mode not in ("RGB", "RGBA"):
|
||
_img = _img.convert("RGBA")
|
||
_resample = (
|
||
Image.Resampling.LANCZOS
|
||
if hasattr(Image, "Resampling") else Image.LANCZOS
|
||
)
|
||
_img = _img.resize((52, 52), _resample)
|
||
logo_photo = ImageTk.PhotoImage(_img, master=dlg)
|
||
break
|
||
except Exception:
|
||
logo_photo = None
|
||
|
||
if logo_photo is not None:
|
||
_logo_lbl = tk.Label(header, image=logo_photo, bg=_SURFACE,
|
||
bd=0, highlightthickness=0)
|
||
_logo_lbl.image = logo_photo # halten
|
||
_logo_lbl.pack(side="left", padx=(0, 14))
|
||
|
||
text_block = tk.Frame(header, bg=_SURFACE)
|
||
text_block.pack(side="left", anchor="w")
|
||
|
||
tk.Label(
|
||
text_block, text="AzA Aktivierung",
|
||
font=("Segoe UI", 18, "bold"), fg=_TEXT_STRONG, bg=_SURFACE,
|
||
anchor="w",
|
||
).pack(anchor="w")
|
||
tk.Label(
|
||
text_block, text="Informatik zu fairen Preisen",
|
||
font=("Segoe UI", 10), fg=_SUBTLE, bg=_SURFACE, anchor="w",
|
||
).pack(anchor="w")
|
||
|
||
# ── Status-/Hinweistext ──
|
||
tk.Label(
|
||
content, text=message, font=("Segoe UI", 10),
|
||
fg=_SUBTLE, bg=_SURFACE, wraplength=460, justify="left",
|
||
).pack(anchor="w", pady=(0, 14))
|
||
|
||
tk.Label(
|
||
content, text="Aktivierungsschlüssel",
|
||
font=("Segoe UI", 10, "bold"), fg=_TEXT, bg=_SURFACE,
|
||
).pack(anchor="w", pady=(0, 4))
|
||
|
||
key_box = tk.Frame(content, bg=_BORDER)
|
||
key_box.pack(fill="x", pady=(0, 4))
|
||
key_entry = tk.Entry(
|
||
key_box, font=("Consolas", 12), bg="white", fg=_TEXT,
|
||
relief="flat", bd=0, insertbackground=_TEXT,
|
||
)
|
||
key_entry.pack(fill="x", ipady=7, padx=2, pady=2)
|
||
|
||
hint_label = tk.Label(
|
||
content,
|
||
text="Format: AzA-YYYYMMDD-XXXXXXXXXXXX (Leerzeichen / Bindestriche werden automatisch korrigiert)",
|
||
font=("Segoe UI", 8), fg=_SUBTLE, bg=_SURFACE,
|
||
justify="left", wraplength=460,
|
||
)
|
||
hint_label.pack(anchor="w", pady=(0, 18))
|
||
|
||
# ── Aktionen (gleich grosse Pill-Buttons) ──
|
||
BTN_W = 180
|
||
BTN_H = 38
|
||
BTN_R = 8
|
||
|
||
def _set_hint(text: str, *, danger: bool = False):
|
||
try:
|
||
hint_label.configure(text=text, fg=_DANGER_FG if danger else _SUBTLE)
|
||
except Exception:
|
||
pass
|
||
|
||
def do_activate(_e=None):
|
||
raw = key_entry.get().strip()
|
||
if not raw:
|
||
_set_hint("⚠ Bitte Aktivierungsschlüssel eingeben.", danger=True)
|
||
return
|
||
result["action"] = "activate"
|
||
result["key"] = raw
|
||
dlg.destroy()
|
||
|
||
def do_demo():
|
||
result["action"] = "demo"
|
||
result["key"] = None
|
||
dlg.destroy()
|
||
|
||
def do_quit():
|
||
result["action"] = "quit"
|
||
result["key"] = None
|
||
dlg.destroy()
|
||
|
||
key_entry.bind("<Return>", do_activate)
|
||
|
||
btn_row = tk.Frame(content, bg=_SURFACE)
|
||
btn_row.pack(fill="x")
|
||
|
||
def _make_pill(parent, text, command, *, kind="primary", tooltip=None):
|
||
bg = parent.cget("bg")
|
||
cv = tk.Canvas(parent, width=BTN_W, height=BTN_H,
|
||
bg=bg, highlightthickness=0, bd=0, cursor="hand2")
|
||
state = {"hover": False, "press": False}
|
||
|
||
def _colors():
|
||
if kind == "primary":
|
||
fill = _ACCENT_PRESSED if state["press"] else (
|
||
_ACCENT_HOVER if state["hover"] else _ACCENT)
|
||
return fill, "white", fill
|
||
if kind == "ghost":
|
||
fill = _SOFT if (state["hover"] or state["press"]) else _SURFACE
|
||
border = _ACCENT if (state["hover"] or state["press"]) else _BORDER
|
||
return fill, _TEXT, border
|
||
# default = neutral
|
||
fill = _SOFT if (state["hover"] or state["press"]) else "#F4F8FB"
|
||
border = _ACCENT if (state["hover"] or state["press"]) else _BORDER
|
||
return fill, _TEXT, border
|
||
|
||
def _round_rect(c, x1, y1, x2, y2, r, **kw):
|
||
pts = [
|
||
x1 + r, y1, x2 - r, y1, x2, y1, x2, y1 + r,
|
||
x2, y2 - r, x2, y2, x2 - r, y2, x1 + r, y2,
|
||
x1, y2, x1, y2 - r, x1, y1 + r, x1, y1,
|
||
]
|
||
return c.create_polygon(pts, smooth=True, **kw)
|
||
|
||
def _draw():
|
||
cv.delete("all")
|
||
w_, h_ = BTN_W, BTN_H
|
||
fill, fg, border = _colors()
|
||
r = max(2, min(BTN_R, h_ // 2))
|
||
_round_rect(cv, 0, 0, w_, h_, r, fill=fill, outline=border)
|
||
weight = "bold" if kind == "primary" else "normal"
|
||
cv.create_text(w_ // 2, h_ // 2, text=text, fill=fg,
|
||
font=("Segoe UI", 10, weight))
|
||
|
||
def _on_enter(_e=None):
|
||
state["hover"] = True
|
||
_draw()
|
||
|
||
def _on_leave(_e=None):
|
||
state["hover"] = False
|
||
state["press"] = False
|
||
_draw()
|
||
|
||
def _on_press(_e=None):
|
||
state["press"] = True
|
||
_draw()
|
||
|
||
def _on_release(_e=None):
|
||
was = state["press"]
|
||
state["press"] = False
|
||
_draw()
|
||
if was:
|
||
try:
|
||
command()
|
||
except Exception as exc:
|
||
print(f"[ACTIVATION] Aktion '{text}' fehlgeschlagen: {exc}")
|
||
|
||
cv.bind("<Enter>", _on_enter)
|
||
cv.bind("<Leave>", _on_leave)
|
||
cv.bind("<ButtonPress-1>", _on_press)
|
||
cv.bind("<ButtonRelease-1>", _on_release)
|
||
if tooltip:
|
||
try:
|
||
from aza_ui_helpers import add_tooltip
|
||
add_tooltip(cv, tooltip)
|
||
except Exception:
|
||
pass
|
||
_draw()
|
||
return cv
|
||
|
||
btn_activate = _make_pill(
|
||
btn_row, "✓ Aktivieren", do_activate, kind="primary",
|
||
tooltip="Schlüssel jetzt aktivieren",
|
||
)
|
||
btn_activate.pack(side="left")
|
||
|
||
btn_demo = _make_pill(
|
||
btn_row, "Im Demomodus weiterfahren", do_demo, kind="ghost",
|
||
tooltip=("Mit eingeschränktem Funktionsumfang weiterarbeiten "
|
||
"und später aktivieren"),
|
||
)
|
||
btn_demo.pack(side="left", padx=(10, 0))
|
||
|
||
quit_text = "Schließen" if trial_active else "Beenden"
|
||
btn_quit = _make_pill(
|
||
btn_row, quit_text, do_quit, kind="default",
|
||
tooltip="Dialog schließen" if trial_active else "Programm beenden",
|
||
)
|
||
btn_quit.pack(side="left", padx=(10, 0))
|
||
|
||
import tkinter.ttk as _ttk
|
||
_grip = _ttk.Sizegrip(dlg)
|
||
_grip.place(relx=1.0, rely=1.0, anchor="se")
|
||
|
||
# WM-Close = Demo (Trial aktiv) bzw. Beenden (Trial abgelaufen).
|
||
def _on_wm_close():
|
||
if trial_active:
|
||
do_demo()
|
||
else:
|
||
do_quit()
|
||
dlg.protocol("WM_DELETE_WINDOW", _on_wm_close)
|
||
|
||
key_entry.focus_set()
|
||
dlg.grab_set()
|
||
parent.wait_window(dlg)
|
||
|
||
return result
|
||
|
||
|
||
if __name__ == "__main__":
|
||
_root_env = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
||
if os.path.isfile(_root_env):
|
||
load_dotenv(dotenv_path=_root_env, override=True)
|
||
else:
|
||
load_dotenv(override=True)
|
||
# Windows DPI + AppUserModelID (MUSS vor erstem Tk-Fenster stehen)
|
||
try:
|
||
import ctypes
|
||
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
import ctypes as _ct
|
||
_ct.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
|
||
"ch.aza-medwork.desktop.v2")
|
||
except Exception:
|
||
pass
|
||
|
||
# Aktivierungs-/Ablauf-Check
|
||
if not _show_activation_gate():
|
||
sys.exit(0)
|
||
|
||
try:
|
||
from keygen_license import show_license_dialog_and_exit_if_invalid
|
||
show_license_dialog_and_exit_if_invalid("KG-Diktat")
|
||
except ImportError:
|
||
pass
|
||
|
||
ensure_runtime_config_template_exists()
|
||
|
||
if not _has_remote_backend() and not has_openai_api_key():
|
||
try:
|
||
_show_openai_key_setup_dialog()
|
||
except Exception:
|
||
pass
|
||
|
||
if _has_remote_backend():
|
||
_AZA_BACKEND_READY = True
|
||
else:
|
||
_AZA_BACKEND_READY = start_backend()
|
||
if not _AZA_BACKEND_READY:
|
||
backend_error = get_backend_error().strip()
|
||
error_text = "Lokales AZA-Backend konnte nicht automatisch gestartet werden."
|
||
if backend_error:
|
||
error_text += "\n\nTechnische Details:\n" + backend_error
|
||
try:
|
||
from aza_firewall import get_firewall_hint_text
|
||
error_text += "\n\n---\n" + get_firewall_hint_text()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from tkinter import messagebox
|
||
messagebox.showerror("AZA Backend-Start fehlgeschlagen", error_text)
|
||
except Exception:
|
||
pass
|
||
raise RuntimeError(error_text)
|
||
|
||
try:
|
||
prompt_update_if_available()
|
||
except Exception:
|
||
pass
|
||
|
||
# Stale Tk-Root aufräumen (falls vorherige Dialoge einen impliziten Root hinterlassen haben)
|
||
try:
|
||
_stale = tk._default_root # type: ignore[attr-defined]
|
||
if _stale is not None:
|
||
try:
|
||
_stale.destroy()
|
||
except Exception:
|
||
pass
|
||
tk._default_root = None # type: ignore[attr-defined]
|
||
except Exception:
|
||
pass
|
||
|
||
# Neue AzA Desktop-Huelle: Hauptoberflaeche ist AzA Office.
|
||
# Kein Cockpit-/Launcher-Vorfenster, kein Dev-Status-Popup.
|
||
# Falls der Benutzer aus AzA Office heraus ueber "Zur Startseite"
|
||
# zurueckkehren moechte, blenden wir den klassischen Launcher
|
||
# nur dann ein. Standardmaessig faehrt das Programm direkt mit
|
||
# AzA Office hoch.
|
||
while True:
|
||
app = KGDesktopApp(start_module="kg")
|
||
log_event("APP_START", detail="module=kg")
|
||
app.mainloop()
|
||
log_event("APP_STOP", detail="module=kg")
|
||
|
||
if not getattr(app, "_return_to_launcher", False):
|
||
break
|
||
|
||
# Optional: Klassischen Launcher nur auf expliziten Wunsch zeigen.
|
||
launcher = AzaLauncher()
|
||
selected_module = launcher.run()
|
||
if not selected_module:
|
||
break
|
||
|
||
if selected_module == "translator":
|
||
log_event("APP_START", detail="module=translator")
|
||
try:
|
||
import translate
|
||
translate.main()
|
||
except Exception as e:
|
||
print(f"Uebersetzer-Fehler: {e}")
|
||
log_event("APP_STOP", detail="module=translator")
|
||
continue
|
||
|
||
if selected_module == "empfang":
|
||
log_event("APP_START", detail="module=empfang")
|
||
try:
|
||
from aza_empfang_app import main as empfang_main
|
||
empfang_main()
|
||
except Exception as e:
|
||
print(f"Empfang-Fehler: {e}")
|
||
log_event("APP_STOP", detail="module=empfang")
|
||
continue
|
||
|
||
if selected_module == "notizen":
|
||
log_event("APP_START", detail="module=notizen")
|
||
try:
|
||
from apps.diktat.audio_notiz_app import main as audio_main
|
||
audio_main()
|
||
except Exception as e:
|
||
print(f"Audio-Notiz-Fehler: {e}")
|
||
log_event("APP_STOP", detail="module=notizen")
|
||
continue
|
||
|
||
app = KGDesktopApp(start_module=selected_module)
|
||
log_event("APP_START", detail=f"module={selected_module}")
|
||
app.mainloop()
|
||
log_event("APP_STOP", detail=f"module={selected_module}")
|
||
|
||
if not getattr(app, "_return_to_launcher", False):
|
||
break
|
||
|