Files
aza/AzA march 2026 - Kopie (26)/aza_empfang_smart_pick.py
2026-05-08 22:35:18 +02:00

275 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
Globaler Empfang-Pinsel (Maus-Hook + Clipboard) — gemeinsame Basis fuer
Tkinter-Dialog „An Empfang senden“ und Desktop-WebView-Huelle.
Keine Netz-/Server- oder Log-Ausgabe von Patientendaten.
"""
from __future__ import annotations
import re
import sys
import threading
import time
import tkinter as tk
from typing import Any, Callable
from aza_persistence import _win_clipboard_get
def _pinsel_diag_print(msg: str, enabled: bool) -> None:
if enabled:
print(msg, file=sys.stderr, flush=True)
try:
from pynput.keyboard import Controller as KbdController, Key, KeyCode
except ImportError:
KbdController = None # type: ignore[misc, assignment]
Key = None # type: ignore[misc, assignment]
KeyCode = None # type: ignore[misc, assignment]
try:
from pynput.mouse import Button as MouseButton, Listener as MouseListener
_HAS_PYNPUT_MOUSE = True
except ImportError:
MouseButton = None # type: ignore[misc, assignment]
MouseListener = None # type: ignore[misc, assignment]
_HAS_PYNPUT_MOUSE = False
_SMART_LISTENER: list[Any] = [None]
_SMART_RESET_UI: list[Callable[[], None] | None] = [None]
# Set bei Abbruch (Pinsel togglen); verhindert Pick-Erkennung nach dem Stop.
_PICK_SUPERSEEDED: threading.Event = threading.Event()
def compact_and_validate_patient_nr_pick_text(raw: str) -> str:
"""
Extrahiert eine plausible Patienten-ID (212 Ziffern), konservativ.
Ablehnt GUID/UUID, hex-lastige Artefakte und viele kurze Zifferninseln
in langem alphanumerischem Schnipsel (keine Roh-Uebernahme aus Clipboard).
"""
if not raw:
return ""
s = str(raw).strip()
if not s or len(s) > 220:
return ""
compact_ws = re.sub(r"\s+", "", s)
if re.search(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
compact_ws,
):
return ""
norm = re.sub(r"[\s\-\/\u00ad\.\u202f]+", "", compact_ws)
if not norm:
return ""
digits = sum(1 for ch in norm if ch.isdigit())
hexish = sum(1 for ch in norm.lower() if "a" <= ch <= "f")
letters = sum(1 for ch in norm if ch.isalpha())
if len(norm) >= 18 and hexish >= 6 and digits <= max(12, hexish):
return ""
all_matches = list(re.finditer(r"\d{2,12}", norm))
if not all_matches:
return ""
longest = max((m.group(0) for m in all_matches), key=len)
if (
len(norm) > 24
and len(all_matches) >= 3
and max(len(m.group(0)) for m in all_matches) <= 4
):
return ""
if letters > 4 and len(norm) > 28 and len(longest) <= 6 and hexish >= 4:
return ""
if len(longest) < 4 and len(norm) > 16 and (hexish >= 4 or letters > 6):
return ""
return longest
def _smart_pick_run_ui_reset_only() -> None:
fn = _SMART_RESET_UI[0]
_SMART_RESET_UI[0] = None
if fn:
try:
fn()
except Exception:
pass
def stop_global_patient_nr_pick() -> None:
_PICK_SUPERSEEDED.set()
lis = _SMART_LISTENER[0]
if lis is not None:
try:
lis.stop()
except Exception:
pass
_SMART_LISTENER[0] = None
_smart_pick_run_ui_reset_only()
def _grab_thread_finish() -> None:
"""Nach Clipboard-Polling: Listener ist durch return False schon inactive."""
_SMART_LISTENER[0] = None
if not _PICK_SUPERSEEDED.is_set():
_smart_pick_run_ui_reset_only()
_PICK_SUPERSEEDED.set()
def start_global_patient_nr_pick(
on_clipboard_pick: Callable[[str], None],
*,
reset_pick_ui: Callable[[], None] | None = None,
armed_ui: Callable[[], None] | None = None,
owner_tk=None,
schedule_ui: Callable[[Callable[[], None]], None] | None = None,
diagnostics: bool = False,
) -> str:
"""
Startet einen globalen Pick-Zyklus (ein Freigabe-Event, dann Clipboard).
Returns:
"started" — Hoerer aktiv, auf Markierung/Doppelklick warten.
"cancelled" — Vorheriger Zyklus wurde abgebrochen (UI-Reset ausgefuehrt).
"unavailable" — pynput/Tastatur nicht nutzbar.
"""
if _SMART_LISTENER[0] is not None:
stop_global_patient_nr_pick()
_pinsel_diag_print("[pinsel] pick cycle: cancelled (previous listener stopped)", diagnostics)
return "cancelled"
if not _HAS_PYNPUT_MOUSE or MouseListener is None or MouseButton is None:
_pinsel_diag_print("[pinsel] unavailable: pynput mouse missing", diagnostics)
return "unavailable"
if KbdController is None:
_pinsel_diag_print(
"[pinsel] unavailable: keyboard controller missing (pynput.keyboard Import?)",
diagnostics,
)
return "unavailable"
if Key is None:
_pinsel_diag_print("[pinsel] unavailable: keyboard Key missing", diagnostics)
return "unavailable"
if KeyCode is None:
_pinsel_diag_print("[pinsel] unavailable: keyboard KeyCode missing", diagnostics)
return "unavailable"
_pinsel_diag_print("[pinsel] imports_ok", diagnostics)
_PICK_SUPERSEEDED.clear()
try:
old_clip = ""
if sys.platform == "win32":
old_clip = (_win_clipboard_get() or "").strip()
if not old_clip and owner_tk is not None:
try:
old_clip = owner_tk.clipboard_get().strip()
except tk.TclError:
old_clip = ""
except Exception:
old_clip = ""
_SMART_RESET_UI[0] = reset_pick_ui
if armed_ui:
try:
armed_ui()
except Exception:
pass
def schedule(fn: Callable[[], None]) -> None:
if schedule_ui is not None:
schedule_ui(fn)
else:
# Nicht im pynput-Listener-Thread ausfuehren (lis.stop() / Clipboard wuerden riskieren).
threading.Thread(target=fn, daemon=True).start()
started_ts = time.time()
mouse_was_pressed = False
press_time = 0.0
def on_click(x: object, y: object, button: object, pressed: bool) -> bool | None:
nonlocal mouse_was_pressed, press_time
assert MouseButton is not None
if button != MouseButton.left:
return None
if pressed:
if time.time() - started_ts > 0.3:
mouse_was_pressed = True
press_time = time.time()
return None
if not mouse_was_pressed:
return None
mouse_was_pressed = False
_pinsel_diag_print("[pinsel] mouse_up_seen", diagnostics)
hold_duration = time.time() - press_time
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 as ex:
_pinsel_diag_print(
f"[pinsel] copy_sent failed: {type(ex).__name__}: {ex}",
diagnostics,
)
else:
_pinsel_diag_print("[pinsel] copy_sent", diagnostics)
time.sleep(0.38)
def grab() -> None:
cur = ""
changed = False
try:
for _attempt in range(8):
cur = ""
if sys.platform == "win32":
cur = (_win_clipboard_get() or "").strip()
if not cur and owner_tk is not None:
try:
cur = owner_tk.clipboard_get().strip()
except tk.TclError:
cur = ""
if cur and cur != old_clip:
changed = True
break
time.sleep(0.06)
if changed:
_pinsel_diag_print("[pinsel] clipboard_changed", diagnostics)
else:
_pinsel_diag_print("[pinsel] clipboard_unchanged", diagnostics)
if cur and cur != old_clip:
if not _PICK_SUPERSEEDED.is_set():
try:
_pinsel_diag_print("[pinsel] dispatching on_clipboard_pick", diagnostics)
on_clipboard_pick(cur)
except Exception as ex:
_pinsel_diag_print(
f"[pinsel] on_clipboard_pick error: {type(ex).__name__}: {ex}",
diagnostics,
)
except Exception as ex:
_pinsel_diag_print(
f"[pinsel] grab error: {type(ex).__name__}: {ex}",
diagnostics,
)
_grab_thread_finish()
schedule(grab)
return False
assert MouseListener is not None
ml = MouseListener(on_click=on_click)
_SMART_LISTENER[0] = ml
ml.start()
_pinsel_diag_print("[pinsel] mouse_listener_started", diagnostics)
return "started"