This commit is contained in:
2026-05-08 22:35:18 +02:00
parent 3ca2fea861
commit 520d3924af
10699 changed files with 2956416 additions and 670 deletions

View File

@@ -0,0 +1,3 @@
Restore: copy files from this folder back to project root (basis14.py, aza_empfang_webview.py, web/empfang.html, web/style.css).
This backup predates aza_empfang_smart_pick.py — restore that file from repo if rolling back to pre-bridge state.

View File

@@ -0,0 +1,205 @@
# -*- 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
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]
def compact_and_validate_patient_nr_pick_text(raw: str) -> str:
"""
Extrahiert eine plausible Patienten-ID (212 Ziffern), konservativ.
Ganze Saetze / sehr langer Text werden nicht uebernommen.
"""
if not raw:
return ""
s = str(raw).strip()
if not s or len(s) > 220:
return ""
compact_ws = re.sub(r"\s+", "", s)
norm = re.sub(r"[\s\-\/\u00ad\.\u202f]+", "", compact_ws)
if not norm:
return ""
letters = sum(1 for ch in norm if ch.isalpha())
if letters > 4 and len(norm) > 28:
m = re.search(r"\d{2,12}", norm)
return m.group(0) if m else ""
m = re.search(r"\d{2,12}", norm)
return m.group(0) if m else ""
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:
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 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,
) -> 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()
return "cancelled"
if (
not _HAS_PYNPUT_MOUSE
or MouseListener is None
or MouseButton is None
or KbdController is None
or Key is None
or KeyCode is None
):
return "unavailable"
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
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:
pass
time.sleep(0.38)
def grab() -> None:
cur = ""
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:
break
time.sleep(0.06)
if cur and cur != old_clip:
try:
on_clipboard_pick(cur)
except Exception:
pass
except Exception:
pass
stop_global_patient_nr_pick()
schedule(grab)
return False
assert MouseListener is not None
ml = MouseListener(on_click=on_click)
_SMART_LISTENER[0] = ml
ml.start()
return "started"

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""
AzA Empfang Web-Huelle: eigener Desktop-Prozess (pywebview).
Wird vom Desktop per subprocess gestartet, damit keine GUI-Kollision mit Tkinter entsteht.
Argument: erste Start-URL (z.B. GET /empfang/shell/launch?token=...).
WebView2-Profil: pywebview nutzt standardmaessig ``private_mode=True`` (ephemeral) und
speichert keine Site-Permissions zwischen Sessions. Für die Huelle setzen wir einen
festen ``storage_path`` unter %APPDATA%\\AzA\\EmpfangWebView und ``private_mode=False``,
damit z. B. Mikrofon-Erlaubnis für die App-URL persistiert (wie im normalen Edge-Profil),
ohne den Systembrowser zu verwenden.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
def _empfang_webview_storage_dir() -> str:
"""Eigener User-Data-Ordner, isoliert vom normalen Browser."""
appdata = (os.environ.get("APPDATA") or "").strip()
if appdata:
return str(Path(appdata) / "AzA" / "EmpfangWebView")
return str(Path.home() / ".aza_empfang_webview")
def main(argv: list[str] | None = None) -> int:
argv = argv if argv is not None else sys.argv[1:]
if not argv or not argv[0].strip():
print(
"Usage:\n"
' python aza_empfang_webview.py "https://host/empfang/shell/launch?token=..."',
file=sys.stderr,
)
return 2
url = argv[0].strip()
w = int(argv[1]) if len(argv) > 1 and str(argv[1]).isdigit() else 1180
h = int(argv[2]) if len(argv) > 2 and str(argv[2]).isdigit() else 820
storage_path = _empfang_webview_storage_dir()
try:
Path(storage_path).mkdir(parents=True, exist_ok=True)
except OSError as exc:
print(
f"WebView-Profil-Ordner konnte nicht angelegt werden ({storage_path}): {exc}",
file=sys.stderr,
)
return 13
try:
import webview # noqa: WPS433 (runtime dependency)
except ImportError:
print(
"pywebview fehlt. Bitte installieren:\n"
" pip install pywebview>=5",
file=sys.stderr,
)
return 11
try:
webview.create_window("AzA-Empfang", url, width=w, height=h)
webview.start(storage_path=storage_path, private_mode=False)
return 0
except Exception as exc:
print(str(exc), file=sys.stderr)
return 12
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,38 @@
body {
font-family: Arial, sans-serif;
margin: 40px;
background: #f5f5f5;
}
header {
margin-bottom: 40px;
}
h1 {
margin-bottom: 5px;
}
main {
background: white;
padding: 30px;
border-radius: 8px;
}
.button {
display: inline-block;
padding: 10px 18px;
background: #2d6cdf;
color: white;
text-decoration: none;
border-radius: 5px;
}
.button:hover {
background: #1b4fad;
}
footer {
margin-top: 40px;
font-size: 12px;
color: #777;
}