# -*- coding: utf-8 -*- """ Standalone persistence/load-save utilities extracted from basis14.py. """ import os import json import sys import time import re import html import hashlib import threading from difflib import SequenceMatcher from datetime import datetime, timedelta from tkinter import messagebox from aza_config import ( CONFIG_FILENAME, WINDOW_CONFIG_FILENAME, SIGNATURE_CONFIG_FILENAME, KORREKTUREN_CONFIG_FILENAME, ABLAGE_BASE_DIR, ABLAGE_SUBFOLDERS, ABLAGE_LABELS, PRUEFEN_WINDOW_CONFIG_FILENAME, ORDNER_WINDOW_CONFIG_FILENAME, TEXT_WINDOW_CONFIG_FILENAME, DIKTAT_WINDOW_CONFIG_FILENAME, DISKUSSION_WINDOW_CONFIG_FILENAME, SETTINGS_WINDOW_CONFIG_FILENAME, TEXTBLOECKE_CONFIG_FILENAME, TEMPLATES_CONFIG_FILENAME, OP_BERICHT_TEMPLATE_CONFIG_FILENAME, ARZTBRIEF_VORLAGE_CONFIG_FILENAME, TODO_CONFIG_FILENAME, TODO_WINDOW_CONFIG_FILENAME, TODO_INBOX_CONFIG_FILENAME, TODO_SETTINGS_CONFIG_FILENAME, NOTES_CONFIG_FILENAME, CHECKLIST_CONFIG_FILENAME, USER_PROFILE_CONFIG_FILENAME, OPACITY_CONFIG_FILENAME, AUTOTEXT_CONFIG_FILENAME, FONT_SCALE_CONFIG_FILENAME, BUTTON_SCALE_CONFIG_FILENAME, TOKEN_USAGE_CONFIG_FILENAME, KG_DETAIL_LEVEL_CONFIG_FILENAME, SOAP_SECTION_LEVELS_CONFIG_FILENAME, FONT_SIZES_CONFIG_FILENAME, PANED_POSITIONS_CONFIG_FILENAME, DEFAULT_OPACITY, MIN_OPACITY, DEFAULT_FONT_SCALE, MIN_FONT_SCALE, MAX_FONT_SCALE, DEFAULT_BUTTON_SCALE, MIN_BUTTON_SCALE, MAX_BUTTON_SCALE, _SOAP_SECTIONS, _SOAP_LABELS, _DEFAULT_KORREKTUREN, ARZTBRIEF_VORLAGE_DEFAULT, KOGU_GRUSS_OPTIONS, KOGU_GRUSS_CONFIG_FILENAME, KOGU_TEMPLATES_CONFIG_FILENAME, DISKUSSION_VORLAGE_CONFIG_FILENAME, ALLOWED_SUMMARY_MODELS, DEFAULT_SUMMARY_MODEL, COMMENT_KEYWORDS, _SUPABASE_URL, _SUPABASE_ANON_KEY, SOAP_ORDER_CONFIG_FILENAME, SOAP_VISIBILITY_CONFIG_FILENAME, SOAP_PRESETS_CONFIG_FILENAME, DEFAULT_SOAP_ORDER, NUM_SOAP_PRESETS, BRIEF_PRESETS_CONFIG_FILENAME, NUM_BRIEF_PRESETS, BRIEF_PROFILE_DEFAULTS, get_writable_data_dir, ) def _config_path(): return os.path.join(get_writable_data_dir(), CONFIG_FILENAME) def _window_config_path(): return os.path.join(get_writable_data_dir(), WINDOW_CONFIG_FILENAME) def _clamp_geometry_str(geom: str, min_w: int, min_h: int) -> str: """Begrenzt gespeicherte Geometrie-String auf Mindestgröße (alle Buttons sichtbar).""" if not geom or "x" not in geom: return f"{min_w}x{min_h}" parts = geom.replace("+", "x").split("x") try: w = max(min_w, int(parts[0].strip())) h = max(min_h, int(parts[1].strip())) if len(parts) >= 4: return f"{w}x{h}+{parts[2].strip()}+{parts[3].strip()}" return f"{w}x{h}" except (ValueError, IndexError): return f"{min_w}x{min_h}" def load_window_geometry(): """Liest gespeicherte Fenstergröße, Position, Sash (Breite) und Transkript-Höhe. Rückgabe: (w, h, x, y, sash_h, sash_v) oder None.""" try: path = _window_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: parts = f.read().strip().split() if len(parts) >= 4: w, h, x, y = int(parts[0]), int(parts[1]), int(parts[2]), int(parts[3]) if w >= 400 and h >= 300: sash_h = int(parts[4]) if len(parts) >= 5 else None sash_v = int(parts[5]) if len(parts) >= 6 else None return (w, h, x, y, sash_h, sash_v) except Exception: pass return None def save_window_geometry( width: int, height: int, x: int, y: int, sash: int = None, sash_transcript: int = None ) -> None: """Speichert Fenstergröße, Position, Sash (Breite) und Transkript-Höhe dauerhaft.""" try: with open(_window_config_path(), "w", encoding="utf-8") as f: if sash is not None and sash_transcript is not None: f.write(f"{width} {height} {x} {y} {sash} {sash_transcript}\n") elif sash is not None: f.write(f"{width} {height} {x} {y} {sash}\n") else: f.write(f"{width} {height} {x} {y}\n") except Exception: pass def reset_all_window_positions() -> None: """Löscht alle gespeicherten Fensterpositionen, damit beim nächsten Start alle Fenster zentriert öffnen.""" base_dir = get_writable_data_dir() # Alle Fenster-Geometrie-Dateien geometry_files = [ WINDOW_CONFIG_FILENAME, PRUEFEN_WINDOW_CONFIG_FILENAME, ORDNER_WINDOW_CONFIG_FILENAME, TEXT_WINDOW_CONFIG_FILENAME, DIKTAT_WINDOW_CONFIG_FILENAME, DISKUSSION_WINDOW_CONFIG_FILENAME, SETTINGS_WINDOW_CONFIG_FILENAME, TODO_WINDOW_CONFIG_FILENAME, PANED_POSITIONS_CONFIG_FILENAME, ] # Auch generische Toplevel-Geometrie-Dateien (autotext, interaktionscheck, ki_kontrolle, etc.) for fname in os.listdir(base_dir): if fname.startswith("kg_diktat_") and fname.endswith("_geometry.txt"): geometry_files.append(fname) # KG Detail-Level und SOAP-Section-Levels zurücksetzen for cfg_name in (KG_DETAIL_LEVEL_CONFIG_FILENAME, SOAP_SECTION_LEVELS_CONFIG_FILENAME): cfg_path = os.path.join(base_dir, cfg_name) try: if os.path.isfile(cfg_path): os.remove(cfg_path) except Exception: pass # Alle löschen deleted = 0 for fname in geometry_files: path = os.path.join(base_dir, fname) try: if os.path.isfile(path): os.remove(path) deleted += 1 except Exception: pass return deleted def _opacity_config_path(): return os.path.join(get_writable_data_dir(), OPACITY_CONFIG_FILENAME) def load_opacity() -> float: """Liest die Fenster-Transparenz (0.4–1.0). Standard 0.9.""" try: path = _opacity_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: v = float(f.read().strip()) return max(MIN_OPACITY, min(1.0, v)) except Exception: pass return DEFAULT_OPACITY def save_opacity(value: float) -> None: """Speichert die Fenster-Transparenz.""" try: v = max(MIN_OPACITY, min(1.0, value)) with open(_opacity_config_path(), "w", encoding="utf-8") as f: f.write(str(v)) except Exception: pass def _autotext_config_path(): return os.path.join(get_writable_data_dir(), AUTOTEXT_CONFIG_FILENAME) def _font_scale_config_path(): return os.path.join(get_writable_data_dir(), FONT_SCALE_CONFIG_FILENAME) def _button_scale_config_path(): return os.path.join(get_writable_data_dir(), BUTTON_SCALE_CONFIG_FILENAME) def load_font_scale() -> float: """Liest den Schriftgrößen-Skalierungsfaktor (0.3–0.8). Standard 1.0.""" try: path = _font_scale_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: v = float(f.read().strip()) return max(MIN_FONT_SCALE, min(MAX_FONT_SCALE, v)) except Exception: pass return DEFAULT_FONT_SCALE def save_font_scale(value: float) -> None: """Speichert den Schriftgrößen-Skalierungsfaktor.""" try: v = max(MIN_FONT_SCALE, min(MAX_FONT_SCALE, value)) with open(_font_scale_config_path(), "w", encoding="utf-8") as f: f.write(str(v)) except Exception: pass def load_button_scale() -> float: """Liest den Button-Größen-Skalierungsfaktor (0.8–2.0). Standard 1.0.""" try: path = _button_scale_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: v = float(f.read().strip()) return max(MIN_BUTTON_SCALE, min(MAX_BUTTON_SCALE, v)) except Exception: pass return DEFAULT_BUTTON_SCALE def save_button_scale(value: float) -> None: """Speichert den Button-Größen-Skalierungsfaktor.""" try: v = max(MIN_BUTTON_SCALE, min(MAX_BUTTON_SCALE, value)) with open(_button_scale_config_path(), "w", encoding="utf-8") as f: f.write(str(v)) except Exception: pass # (Textfeld-Schriftgrößen + add_text_font_size_control sind in aza_ui_helpers.py) def _token_usage_config_path(): return os.path.join(get_writable_data_dir(), TOKEN_USAGE_CONFIG_FILENAME) def load_token_usage() -> dict: """Liest die Token-Nutzung. Format: {'used': int, 'total': int, 'budget_dollars': float, 'used_dollars': float}""" try: path = _token_usage_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.loads(f.read().strip()) return data except Exception: pass return {"used": 0, "total": 1000000, "budget_dollars": 0, "used_dollars": 0} def save_token_usage(used: int = None, total: int = None, budget_dollars: float = None, used_dollars: float = None) -> None: """Speichert die Token-Nutzung.""" try: current = load_token_usage() if used is not None: current["used"] = used if total is not None: current["total"] = total if budget_dollars is not None: current["budget_dollars"] = budget_dollars if used_dollars is not None: current["used_dollars"] = used_dollars with open(_token_usage_config_path(), "w", encoding="utf-8") as f: json.dump(current, f) except Exception: pass def add_token_usage(tokens: int) -> None: """Fügt verbrauchte Tokens hinzu.""" try: data = load_token_usage() data["used"] = data.get("used", 0) + tokens save_token_usage(used=data["used"]) except Exception: pass def fetch_openai_usage(client) -> dict: """Ruft echte Verbrauchs-Daten von OpenAI ab.""" try: # OpenAI API Key aus Client extrahieren api_key = client.api_key if hasattr(client, 'api_key') else None if not api_key: return None # Verwende httpx (bereits von openai installiert) import httpx headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" } # Verbrauch der letzten 30 Tage abrufen end_date = datetime.now() start_date = end_date - timedelta(days=30) url = f"https://api.openai.com/v1/usage?start_date={start_date.strftime('%Y-%m-%d')}&end_date={end_date.strftime('%Y-%m-%d')}" with httpx.Client(timeout=10.0) as http_client: response = http_client.get(url, headers=headers) if response.status_code == 200: data = response.json() # Summiere Verbrauch aus allen Tagen total_cost = 0 for day_data in data.get("data", []): total_cost += day_data.get("cost", 0) / 100 # Cent to Dollar return { "used_dollars": total_cost, "success": True } else: return { "error": f"API returned status {response.status_code}", "success": False } except Exception as e: return { "error": str(e), "success": False } return None def load_autotext() -> dict: """Lädt Autotext und Einstellungen inkl. News/Event-Filter.""" try: path = _autotext_config_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): return { "enabled": data.get("enabled", True), "entries": data.get("entries") if isinstance(data.get("entries"), dict) else {}, "diktat_auto_start": data.get("diktat_auto_start", True), "notizen_open_on_start": data.get("notizen_open_on_start", data.get("diktat_open_on_start", True)), "textbloecke_visible": data.get("textbloecke_visible", True), "addon_visible": data.get("addon_visible", True), "addon_buttons": data.get("addon_buttons", { "uebersetzer": True, "email": True, "autotext": True, "whatsapp": True, "docapp": True }), "kg_auto_delete_old": data.get("kg_auto_delete_old", False), "textbloecke_collapsed": data.get("textbloecke_collapsed", False), "status_color": data.get("status_color", "#BD4500"), "soap_collapsed": data.get("soap_collapsed", False), "autoOpenNews": data.get("autoOpenNews", False), "autoOpenEvents": data.get("autoOpenEvents", True), "newsTemplate": data.get("newsTemplate", "all"), "newsSelectedSpecialties": data.get("newsSelectedSpecialties", []), "newsSelectedRegions": data.get("newsSelectedRegions", ["CH", "EU"]), "newsSort": data.get("newsSort", "newest"), "eventsSelectedSpecialties": data.get("eventsSelectedSpecialties", ["general-medicine"]), "eventsSelectedRegions": data.get("eventsSelectedRegions", ["CH", "EU"]), "eventsTemplate": data.get("eventsTemplate", "general_ch_eu"), "eventsSort": data.get("eventsSort", "soonest"), "eventsMonthsAhead": int(data.get("eventsMonthsAhead", 13)), "selectedLanguage": data.get("selectedLanguage", "system"), "user_specialty_default": data.get("user_specialty_default", "dermatology"), "user_specialties_selected": data.get("user_specialties_selected", []), "ui_font_delta": int(data.get("ui_font_delta", -2)), } except Exception: pass return { "enabled": True, "entries": {}, "diktat_auto_start": True, "notizen_open_on_start": True, "textbloecke_visible": True, "addon_visible": True, "addon_buttons": { "uebersetzer": True, "email": True, "autotext": True, "whatsapp": True, "docapp": True }, "kg_auto_delete_old": False, "textbloecke_collapsed": False, "status_color": "#BD4500", "soap_collapsed": False, "autoOpenNews": False, "autoOpenEvents": True, "newsTemplate": "all", "newsSelectedSpecialties": [], "newsSelectedRegions": ["CH", "EU"], "newsSort": "newest", "eventsSelectedSpecialties": ["general-medicine"], "eventsSelectedRegions": ["CH", "EU"], "eventsTemplate": "general_ch_eu", "eventsSort": "soonest", "eventsMonthsAhead": 13, "selectedLanguage": "system", "user_specialty_default": "dermatology", "user_specialties_selected": [], "ui_font_delta": -2, } def save_autotext(data: dict) -> None: """Speichert Autotext inkl. News/Event-Settings dauerhaft.""" try: with open(_autotext_config_path(), "w", encoding="utf-8") as f: json.dump( { "enabled": data.get("enabled", True), "entries": data.get("entries") or {}, "diktat_auto_start": data.get("diktat_auto_start", True), "notizen_open_on_start": data.get("notizen_open_on_start", data.get("diktat_open_on_start", True)), "textbloecke_visible": data.get("textbloecke_visible", True), "addon_visible": data.get("addon_visible", True), "kg_auto_delete_old": data.get("kg_auto_delete_old", False), "addon_buttons": data.get("addon_buttons", {}), "textbloecke_collapsed": data.get("textbloecke_collapsed", False), "status_color": data.get("status_color", "#BD4500"), "soap_collapsed": data.get("soap_collapsed", False), "autoOpenNews": bool(data.get("autoOpenNews", False)), "autoOpenEvents": bool(data.get("autoOpenEvents", True)), "newsTemplate": data.get("newsTemplate", "all"), "newsSelectedSpecialties": data.get("newsSelectedSpecialties", []), "newsSelectedRegions": data.get("newsSelectedRegions", ["CH", "EU"]), "newsSort": data.get("newsSort", "newest"), "eventsSelectedSpecialties": data.get("eventsSelectedSpecialties", ["general-medicine"]), "eventsSelectedRegions": data.get("eventsSelectedRegions", ["CH", "EU"]), "eventsTemplate": data.get("eventsTemplate", "general_ch_eu"), "eventsSort": data.get("eventsSort", "soonest"), "eventsMonthsAhead": int(data.get("eventsMonthsAhead", 13)), "selectedLanguage": data.get("selectedLanguage", "system"), "user_specialty_default": data.get("user_specialty_default", "dermatology"), "user_specialties_selected": data.get("user_specialties_selected", []), "ui_font_delta": int(data.get("ui_font_delta", -2)), }, f, ensure_ascii=False, indent=2, ) except Exception: pass def _is_admin() -> bool: """Prüft, ob die Anwendung mit Administratorrechten läuft (für globalen Autotext).""" if sys.platform != "win32": return False try: import ctypes return bool(ctypes.windll.shell32.IsUserAnAdmin()) except Exception: return False def _run_as_admin() -> bool: """Startet die Anwendung mit Administratorrechten neu. Beendet die aktuelle Instanz.""" if sys.platform != "win32": return False try: import ctypes args = " ".join([f'"{a}"' if " " in a else a for a in sys.argv]) ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, args, None, 1) sys.exit(0) return True except Exception: return False def sanitize_markdown_for_plain_text(raw_text: str) -> str: """Entfernt Markdown-Syntax für sauberen Plain-Text (ohne *, #, etc.).""" lines = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") out_lines = [] for raw_line in lines: line = raw_line line = re.sub(r"^\s*#{1,6}\s+", "", line) line = re.sub(r"^\s*\d+\.\s+", "", line) line = re.sub(r"^\s*[-*•]\s+", "", line) line = re.sub(r"\*\*(.+?)\*\*", r"\1", line) line = re.sub(r"__(.+?)__", r"\1", line) line = re.sub(r"(? bool: """Text in Windows-Zwischenablage (für globalen Autotext per Strg+V).""" if sys.platform != "win32": return False try: import ctypes from ctypes import wintypes CF_UNICODETEXT = 13 GMEM_MOVEABLE = 0x0002 GMEM_DDESHARE = 0x2000 alloc_flags = GMEM_MOVEABLE | GMEM_DDESHARE kernel32 = ctypes.WinDLL("kernel32") user32 = ctypes.WinDLL("user32") user32.OpenClipboard.argtypes = [wintypes.HWND] user32.OpenClipboard.restype = wintypes.BOOL user32.CloseClipboard.argtypes = [] user32.EmptyClipboard.argtypes = [] user32.RegisterClipboardFormatW.argtypes = [wintypes.LPCWSTR] user32.RegisterClipboardFormatW.restype = wintypes.UINT user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE] user32.SetClipboardData.restype = wintypes.HANDLE kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t] kernel32.GlobalAlloc.restype = wintypes.HGLOBAL kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL] kernel32.GlobalLock.restype = ctypes.c_void_p kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL] def _set_clipboard_data(fmt: int, payload: bytes) -> bool: h = kernel32.GlobalAlloc(alloc_flags, len(payload)) if not h: return False ptr = kernel32.GlobalLock(h) if not ptr: return False ctypes.memmove(ptr, payload, len(payload)) kernel32.GlobalUnlock(h) return bool(user32.SetClipboardData(fmt, h)) def _inline_markdown_to_html(line: str) -> str: escaped = html.escape(line) escaped = re.sub(r"\*\*(.+?)\*\*", r"\1", escaped) escaped = re.sub(r"__(.+?)__", r"\1", escaped) escaped = re.sub(r"(?\1", escaped) escaped = re.sub(r"(?\1", escaped) return escaped def _markdown_like_to_html(raw_text: str) -> str: lines = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") html_parts = [] in_ul = False in_ol = False def _close_lists(): nonlocal in_ul, in_ol if in_ul: html_parts.append("") in_ul = False if in_ol: html_parts.append("") in_ol = False for raw_line in lines: line = raw_line.rstrip() stripped = line.strip() if not stripped: _close_lists() html_parts.append("
") continue m_head = re.match(r"^(#{1,6})\s+(.*)$", stripped) if m_head: _close_lists() level = min(6, len(m_head.group(1))) html_parts.append(f"{_inline_markdown_to_html(m_head.group(2))}") continue m_ol = re.match(r"^\d+\.\s+(.*)$", stripped) if m_ol: if in_ul: html_parts.append("") in_ul = False if not in_ol: html_parts.append("
    ") in_ol = True html_parts.append(f"
  1. {_inline_markdown_to_html(m_ol.group(1))}
  2. ") continue m_ul = re.match(r"^[-*•]\s+(.*)$", stripped) if m_ul: if in_ol: html_parts.append("
") in_ol = False if not in_ul: html_parts.append("