275 lines
9.0 KiB
Python
275 lines
9.0 KiB
Python
|
|
# -*- 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 (2–12 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"
|