# -*- 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, LAUNCHER_CONFIG_FILENAME, DEFAULT_TOKEN_QUOTA, SOFT_LOCK_THRESHOLD, AVG_TOKENS_PER_REPORT, 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 get_remaining_tokens() -> int: """Verbleibende KI-Einheiten (Token-Guthaben).""" data = load_token_usage() return max(0, data.get("total", DEFAULT_TOKEN_QUOTA) - data.get("used", 0)) def get_capacity_fraction() -> float: """Anteil verbleibender Kapazität (0.0 – 1.0).""" data = load_token_usage() total = data.get("total", DEFAULT_TOKEN_QUOTA) if total <= 0: return 1.0 return max(0.0, min(1.0, (total - data.get("used", 0)) / total)) def is_capacity_low() -> bool: """True wenn verbleibende Kapazität unter dem Soft-Lock-Schwellenwert liegt.""" return get_remaining_tokens() <= SOFT_LOCK_THRESHOLD def estimated_reports_remaining() -> int: """Geschätzte verbleibende Berichte basierend auf Durchschnittsverbrauch.""" remaining = get_remaining_tokens() if AVG_TOKENS_PER_REPORT <= 0: return 0 return remaining // AVG_TOKENS_PER_REPORT def reset_token_allowance(total: int = None) -> None: """Setzt das Token-Guthaben zurück (Admin-Funktion).""" if total is None: total = DEFAULT_TOKEN_QUOTA save_token_usage(used=0, total=total) # ─── Installations-Standort (anonymisiert) ─────────────────────────────────── _LOCATION_FILENAME = "aza_installation_location.json" def _location_path() -> str: return os.path.join(get_writable_data_dir(), _LOCATION_FILENAME) def log_installation_location() -> dict | None: """Ermittelt den ungefähren Standort via IP (ip-api.com) und speichert ihn anonymisiert. Gespeichert werden nur Stadt, Region und Land – keine IP-Adresse. Wird im Hintergrund-Thread aufgerufen, blockiert die UI nicht. """ import urllib.request try: req = urllib.request.Request( "http://ip-api.com/json/?fields=status,city,regionName,country,countryCode", headers={"User-Agent": "AZA-Desktop/1.0"}, ) resp = urllib.request.urlopen(req, timeout=5) data = json.loads(resp.read().decode("utf-8")) if data.get("status") != "success": return None location = { "city": data.get("city", ""), "region": data.get("regionName", ""), "country": data.get("country", ""), "country_code": data.get("countryCode", ""), "updated": datetime.now().isoformat(timespec="seconds"), } with open(_location_path(), "w", encoding="utf-8") as f: json.dump(location, f, ensure_ascii=False, indent=2) return location except Exception: return None def load_installation_location() -> dict: """Liest den gespeicherten Installations-Standort.""" try: path = _location_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return {} def get_location_display() -> str: """Gibt den Standort als lesbaren String zurück.""" loc = load_installation_location() if not loc or not loc.get("city"): return "Nicht ermittelt" parts = [loc.get("city", "")] if loc.get("region"): parts.append(loc["region"]) if loc.get("country"): parts.append(loc["country"]) return ", ".join(parts) _REGISTRY_URL = "https://aza-medwork.ch/api/installations" def register_installation() -> int | None: """Registriert diese Installation anonym am AZA-Netzwerk und gibt die Gesamt-Anzahl einzigartiger Installationen zurück. Falls der Server nicht erreichbar ist, wird None zurückgegeben. Die Geräte-ID wird als anonymer SHA256-Hash gesendet. """ import urllib.request import platform try: raw = f"{platform.node()}-{platform.machine()}-{os.getlogin()}" device_hash = hashlib.sha256(raw.encode()).hexdigest()[:16] except Exception: device_hash = "unknown" loc = load_installation_location() payload = json.dumps({ "device_id": device_hash, "city": loc.get("city", ""), "country_code": loc.get("country_code", ""), }).encode("utf-8") try: req = urllib.request.Request( _REGISTRY_URL, data=payload, headers={ "Content-Type": "application/json", "User-Agent": "AZA-Desktop/1.0", }, method="POST", ) resp = urllib.request.urlopen(req, timeout=5) data = json.loads(resp.read().decode("utf-8")) count = data.get("total_installations") if isinstance(count, int): _save_cached_install_count(count) return count except Exception: pass return None def _cached_install_count_path() -> str: return os.path.join(get_writable_data_dir(), "install_count_cache.json") def _save_cached_install_count(count: int): try: path = _cached_install_count_path() with open(path, "w", encoding="utf-8") as f: json.dump({"count": count, "updated": datetime.now().isoformat()}, f) except Exception: pass def get_install_count() -> tuple[int, bool]: """Gibt (Anzahl, ist_live) zurück. Versucht zuerst den Server, fällt auf Cache zurück, zuletzt auf 1. """ live = register_installation() if live is not None: return live, True try: path = _cached_install_count_path() if os.path.isfile(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) cached = data.get("count", 1) if isinstance(cached, int) and cached > 0: return cached, False except Exception: pass return 1, False 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)), "global_right_click_paste": data.get("global_right_click_paste", True), "todo_auto_open": data.get("todo_auto_open", False), "autocopy_after_diktat": data.get("autocopy_after_diktat", True), "kommentare_auto_open": data.get("kommentare_auto_open", False), "medikament_quelle": data.get("medikament_quelle", "compendium.ch"), "diagnose_quelle": data.get("diagnose_quelle", ""), "dokumente_collapsed": data.get("dokumente_collapsed", False), "active_brief_profile": data.get("active_brief_profile", ""), "stilprofil_enabled": data.get("stilprofil_enabled", False), "stilprofil_name": data.get("stilprofil_name", ""), "stilprofil_default_brief": data.get("stilprofil_default_brief", False), } 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, "dokumente_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, "global_right_click_paste": True, "todo_auto_open": False, "autocopy_after_diktat": True, "kommentare_auto_open": False, "medikament_quelle": "compendium.ch", "diagnose_quelle": "", "active_brief_profile": "", "stilprofil_enabled": False, "stilprofil_name": "", "stilprofil_default_brief": False, } 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)), "global_right_click_paste": bool(data.get("global_right_click_paste", True)), "todo_auto_open": bool(data.get("todo_auto_open", False)), "autocopy_after_diktat": bool(data.get("autocopy_after_diktat", True)), "kommentare_auto_open": bool(data.get("kommentare_auto_open", False)), "medikament_quelle": data.get("medikament_quelle", "compendium.ch"), "diagnose_quelle": data.get("diagnose_quelle", ""), "dokumente_collapsed": bool(data.get("dokumente_collapsed", False)), "active_brief_profile": data.get("active_brief_profile", ""), "stilprofil_enabled": bool(data.get("stilprofil_enabled", False)), "stilprofil_name": data.get("stilprofil_name", ""), "stilprofil_default_brief": bool(data.get("stilprofil_default_brief", False)), }, f, ensure_ascii=False, indent=2, ) except Exception: pass def is_autocopy_after_diktat_enabled() -> bool: """Ob nach Diktat/Transkription automatisch in Zwischenablage kopiert wird (Standard: ja).""" try: return bool(load_autotext().get("autocopy_after_diktat", True)) except Exception: return True def is_global_right_click_paste_enabled() -> bool: """Ob Rechtsklick in externen Apps direkt einfügt (Standard: ja).""" try: return bool(load_autotext().get("global_right_click_paste", True)) except Exception: return True def save_autocopy_prefs(autocopy: bool | None = None, global_right_click: bool | None = None) -> None: """Speichert Autocopy/Rechtsklick-Einstellungen (nur gegebene Werte werden aktualisiert).""" try: data = load_autotext() if autocopy is not None: data["autocopy_after_diktat"] = bool(autocopy) if global_right_click is not None: data["global_right_click_paste"] = bool(global_right_click) save_autotext(data) 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("