464 lines
18 KiB
Python
464 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""AzA Bibliothek — Werkzeugfenster im AzA-Blau/Karten-Stil (wie KI-Guthaben).
|
|
|
|
Bibliothek ist NUR für Medikamente, Begriffe, Personen/Signaturen, Korrekturen.
|
|
Prompt-Vorlagen sind ein eigenes Modul und NICHT Teil dieser UI.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tkinter as tk
|
|
from tkinter import messagebox, ttk
|
|
from typing import Any, Callable, List
|
|
|
|
from aza_bibliothek import (
|
|
CATEGORY_LABELS,
|
|
CATEGORY_TO_STORE,
|
|
PRIVATE_UI_CATEGORIES,
|
|
PUBLIC_UI_CATEGORIES,
|
|
adopt_public_entry,
|
|
delete_private_correction,
|
|
list_private_correction_rows,
|
|
list_public_entries,
|
|
save_private_correction,
|
|
toggle_private_correction_active,
|
|
)
|
|
|
|
# AzA-Design (identisch KI-Guthaben-Fenster)
|
|
_BG = "#E8F4FA"
|
|
_HDR_BG = "#1A4D6D"
|
|
_HDR_FG = "#FFFFFF"
|
|
_SUB_BG = "#D0E8F5"
|
|
_CARD_BG = "#FFFFFF"
|
|
_CARD_BD = "#C8D8E8"
|
|
_ACCENT = "#5B8DB3"
|
|
_ACCENT_HOV = "#4A7A9E"
|
|
_TEXT = "#1A3D55"
|
|
_TEXT_SUB = "#607890"
|
|
_WARN = "#B06000"
|
|
_FF = "Segoe UI"
|
|
|
|
# Fenstergröße (großes AzA-Werkzeugfenster)
|
|
_WIN_W = 1300
|
|
_WIN_H = 880
|
|
_WIN_MIN_W = 1100
|
|
_WIN_MIN_H = 760
|
|
|
|
|
|
def _pill(parent, text: str, *, active: bool, command: Callable[[], None]):
|
|
lbl = tk.Label(
|
|
parent, text=text, font=(_FF, 10),
|
|
bg=_ACCENT if active else _CARD_BG,
|
|
fg="#FFFFFF" if active else _TEXT,
|
|
padx=12, pady=7, cursor="hand2",
|
|
highlightthickness=1,
|
|
highlightbackground=_CARD_BD,
|
|
anchor="w",
|
|
)
|
|
lbl.bind("<Button-1>", lambda _e: command())
|
|
return lbl
|
|
|
|
|
|
def _btn(parent, text: str, command, *, primary: bool = False, danger: bool = False):
|
|
bg = "#C04040" if danger else (_ACCENT if primary else _CARD_BG)
|
|
fg = "#FFFFFF" if (primary or danger) else _ACCENT
|
|
return tk.Button(
|
|
parent, text=text, command=command,
|
|
font=(_FF, 10), bg=bg, fg=fg,
|
|
activebackground=_ACCENT_HOV if primary else _SUB_BG,
|
|
relief="flat", padx=14, pady=6, cursor="hand2",
|
|
highlightthickness=1, highlightbackground=_CARD_BD,
|
|
)
|
|
|
|
|
|
def _center_top(win, w: int, h: int) -> None:
|
|
"""Zentriert das Fenster horizontal, leicht oben (analog AzA-Werkzeugfenster)."""
|
|
try:
|
|
win.update_idletasks()
|
|
sw = win.winfo_screenwidth()
|
|
sh = win.winfo_screenheight()
|
|
x = max(0, (sw - w) // 2)
|
|
y = max(0, int(sh * 0.06))
|
|
win.geometry(f"{w}x{h}+{x}+{y}")
|
|
except Exception:
|
|
win.geometry(f"{w}x{h}")
|
|
|
|
|
|
def _bring_to_front(win) -> None:
|
|
"""Kurz topmost, dann wieder normal — wie bestehende AzA-Tools."""
|
|
try:
|
|
win.lift()
|
|
win.attributes("-topmost", True)
|
|
win.after(300, lambda: _safe_untopmost(win))
|
|
win.focus_force()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _safe_untopmost(win) -> None:
|
|
try:
|
|
if win.winfo_exists():
|
|
win.attributes("-topmost", False)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def open_bibliothek_window(app: Any) -> None:
|
|
"""Singleton Bibliothek-Fenster."""
|
|
existing = getattr(app, "_bibliothek_win", None)
|
|
if existing is not None:
|
|
try:
|
|
if existing.winfo_exists():
|
|
existing.lift()
|
|
_bring_to_front(existing)
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
app._sync_practice_users_to_bibliothek()
|
|
except Exception:
|
|
pass
|
|
|
|
win = tk.Toplevel(app)
|
|
app._bibliothek_win = win
|
|
win.title("Bibliothek")
|
|
win.configure(bg=_BG)
|
|
win.minsize(_WIN_MIN_W, _WIN_MIN_H)
|
|
_center_top(win, _WIN_W, _WIN_H)
|
|
try:
|
|
win.transient(app)
|
|
except Exception:
|
|
pass
|
|
|
|
state = {"scope": "private", "category": "medication", "selected": None}
|
|
|
|
# ── Header ────────────────────────────────────────────────────────────────
|
|
hdr = tk.Frame(win, bg=_HDR_BG)
|
|
hdr.pack(fill="x")
|
|
tk.Label(hdr, text="Bibliothek", font=(_FF, 15, "bold"),
|
|
bg=_HDR_BG, fg=_HDR_FG, padx=20, pady=14).pack(side="left")
|
|
close_x = tk.Label(hdr, text="✕", font=(_FF, 12), bg=_HDR_BG, fg="#A0C4D8",
|
|
cursor="hand2", padx=18)
|
|
close_x.pack(side="right")
|
|
|
|
sub = tk.Frame(win, bg=_SUB_BG)
|
|
sub.pack(fill="x")
|
|
tk.Label(
|
|
sub,
|
|
text="Medizinische Begriffe, Medikamente, Personen/Signaturen und Korrekturen verwalten. "
|
|
"Eigene Einträge werden bevorzugt verwendet. Öffentliche Einträge können übernommen werden.",
|
|
font=(_FF, 9), bg=_SUB_BG, fg=_TEXT_SUB,
|
|
padx=20, pady=8, wraplength=_WIN_W - 60, justify="left",
|
|
).pack(anchor="w")
|
|
|
|
body = tk.Frame(win, bg=_BG)
|
|
body.pack(fill="both", expand=True, padx=16, pady=12)
|
|
|
|
# ── Linke Navigation ──────────────────────────────────────────────────────
|
|
nav = tk.Frame(body, bg=_CARD_BG, highlightthickness=1, highlightbackground=_CARD_BD, width=260)
|
|
nav.pack(side="left", fill="y", padx=(0, 14))
|
|
nav.pack_propagate(False)
|
|
nav_inner = tk.Frame(nav, bg=_CARD_BG, padx=12, pady=14)
|
|
nav_inner.pack(fill="both", expand=True)
|
|
|
|
tk.Label(nav_inner, text="Bibliothek", font=(_FF, 9, "bold"),
|
|
bg=_CARD_BG, fg=_TEXT_SUB, anchor="w").pack(fill="x", pady=(0, 4))
|
|
|
|
scope_pills: dict[str, tk.Label] = {}
|
|
scope_frame = tk.Frame(nav_inner, bg=_CARD_BG)
|
|
scope_frame.pack(fill="x")
|
|
|
|
tk.Label(nav_inner, text="Kategorien", font=(_FF, 9, "bold"),
|
|
bg=_CARD_BG, fg=_TEXT_SUB, anchor="w").pack(fill="x", pady=(14, 4))
|
|
cat_frame = tk.Frame(nav_inner, bg=_CARD_BG)
|
|
cat_frame.pack(fill="x")
|
|
|
|
# ── Rechte Karte ──────────────────────────────────────────────────────────
|
|
card = tk.Frame(body, bg=_CARD_BG, highlightthickness=1, highlightbackground=_CARD_BD)
|
|
card.pack(side="left", fill="both", expand=True)
|
|
|
|
title_lbl = tk.Label(card, text="", font=(_FF, 13, "bold"), bg=_CARD_BG, fg=_TEXT, anchor="w")
|
|
title_lbl.pack(fill="x", padx=18, pady=(14, 2))
|
|
subtitle_lbl = tk.Label(card, text="", font=(_FF, 9), bg=_CARD_BG, fg=_TEXT_SUB,
|
|
anchor="w", wraplength=_WIN_W - 360, justify="left")
|
|
subtitle_lbl.pack(fill="x", padx=18, pady=(0, 10))
|
|
|
|
search_var = tk.StringVar()
|
|
search_row = tk.Frame(card, bg=_CARD_BG)
|
|
search_row.pack(fill="x", padx=18, pady=(0, 8))
|
|
tk.Label(search_row, text="Suchen:", bg=_CARD_BG, fg=_TEXT, font=(_FF, 10)).pack(side="left")
|
|
search_ent = tk.Entry(search_row, textvariable=search_var, font=(_FF, 11), width=44,
|
|
relief="flat", highlightthickness=1, highlightbackground=_CARD_BD)
|
|
search_ent.pack(side="left", padx=10)
|
|
|
|
hint_lbl = tk.Label(
|
|
card,
|
|
text="Diese Bibliothek hilft bei der korrekten Schreibweise. Sie ersetzt keine medizinische Prüfung.",
|
|
font=(_FF, 9), bg="#FFF8EE", fg=_WARN, padx=12, pady=7, anchor="w",
|
|
)
|
|
hint_lbl.pack(fill="x", padx=18, pady=(0, 8))
|
|
|
|
list_frame = tk.Frame(card, bg=_CARD_BG)
|
|
list_frame.pack(fill="both", expand=True, padx=18, pady=4)
|
|
listbox = tk.Listbox(
|
|
list_frame, font=(_FF, 11), height=16,
|
|
bg="#FAFCFE", fg=_TEXT, selectbackground=_ACCENT,
|
|
selectforeground="#FFFFFF", relief="flat",
|
|
highlightthickness=1, highlightbackground=_CARD_BD,
|
|
)
|
|
scroll = ttk.Scrollbar(list_frame, orient="vertical", command=listbox.yview)
|
|
listbox.configure(yscrollcommand=scroll.set)
|
|
listbox.pack(side="left", fill="both", expand=True)
|
|
scroll.pack(side="right", fill="y")
|
|
|
|
detail = tk.Frame(card, bg=_CARD_BG)
|
|
detail.pack(fill="x", padx=18, pady=10)
|
|
detail_vars = {"falsch": tk.StringVar(), "richtig": tk.StringVar(), "note": tk.StringVar()}
|
|
tk.Label(detail, text="Hörvariante / Begriff:", bg=_CARD_BG, font=(_FF, 9)).grid(row=0, column=0, sticky="w")
|
|
falsch_ent = tk.Entry(detail, textvariable=detail_vars["falsch"], width=34, font=(_FF, 11))
|
|
falsch_ent.grid(row=0, column=1, padx=8)
|
|
tk.Label(detail, text="Bevorzugte Schreibweise:", bg=_CARD_BG, font=(_FF, 9)).grid(row=1, column=0, sticky="w", pady=6)
|
|
richtig_ent = tk.Entry(detail, textvariable=detail_vars["richtig"], width=34, font=(_FF, 11))
|
|
richtig_ent.grid(row=1, column=1, padx=8, pady=6)
|
|
meta_lbl = tk.Label(detail, textvariable=detail_vars["note"], bg=_CARD_BG, fg=_TEXT_SUB,
|
|
font=(_FF, 9), wraplength=_WIN_W - 420, justify="left")
|
|
meta_lbl.grid(row=2, column=0, columnspan=2, sticky="w", pady=(4, 0))
|
|
|
|
btn_row = tk.Frame(card, bg=_CARD_BG)
|
|
btn_row.pack(fill="x", padx=18, pady=(6, 16))
|
|
|
|
rows_cache: List[dict] = []
|
|
|
|
def _close():
|
|
try:
|
|
app._bibliothek_win = None
|
|
except Exception:
|
|
pass
|
|
try:
|
|
win.destroy()
|
|
except Exception:
|
|
pass
|
|
|
|
close_x.bind("<Button-1>", lambda _e: _close())
|
|
win.protocol("WM_DELETE_WINDOW", _close)
|
|
|
|
cat_pills: dict[str, tk.Label] = {}
|
|
|
|
def _rebuild_category_pills():
|
|
for w in cat_frame.winfo_children():
|
|
w.destroy()
|
|
cat_pills.clear()
|
|
cats = PRIVATE_UI_CATEGORIES if state["scope"] == "private" else PUBLIC_UI_CATEGORIES
|
|
for cat in cats:
|
|
p = _pill(cat_frame, CATEGORY_LABELS.get(cat, cat),
|
|
active=(cat == state["category"]),
|
|
command=lambda c=cat: _set_category(c))
|
|
p.pack(fill="x", pady=2)
|
|
cat_pills[cat] = p
|
|
|
|
def _visible_rows() -> List[dict]:
|
|
q = (search_var.get() or "").strip().lower()
|
|
out = []
|
|
for row in rows_cache:
|
|
if q and q not in _format_row_label(row).lower():
|
|
continue
|
|
out.append(row)
|
|
return out
|
|
|
|
def _format_row_label(row: dict) -> str:
|
|
if row.get("scope") == "public" and row.get("category") == "medication":
|
|
sub = row.get("active_substance")
|
|
base = row.get("preferred_spelling") or row.get("term") or ""
|
|
return f"{base} ({sub}) · AzA kuratiert" if sub else f"{base} · AzA kuratiert"
|
|
falsch = row.get("term") or ""
|
|
richtig = row.get("preferred_spelling") or ""
|
|
inact = "" if row.get("active", True) else " [inaktiv]"
|
|
src = row.get("source") or ""
|
|
if falsch and richtig and falsch.lower() != richtig.lower():
|
|
return f"{falsch} → {richtig}{inact} ({src})"
|
|
return f"{richtig or falsch}{inact} ({src})"
|
|
|
|
def _load_rows() -> List[dict]:
|
|
if state["scope"] == "private":
|
|
from aza_persistence import load_korrekturen
|
|
rows = list_private_correction_rows(load_korrekturen())
|
|
if state["category"] == "correction":
|
|
return rows
|
|
return [r for r in rows if r.get("category") == state["category"]]
|
|
return list_public_entries(category=state["category"])
|
|
|
|
def _refresh_list():
|
|
nonlocal rows_cache
|
|
rows_cache = _load_rows()
|
|
listbox.delete(0, "end")
|
|
state["selected"] = None
|
|
detail_vars["falsch"].set("")
|
|
detail_vars["richtig"].set("")
|
|
detail_vars["note"].set("")
|
|
|
|
if state["scope"] == "private":
|
|
title_lbl.config(text="Eigene Bibliothek")
|
|
subtitle_lbl.config(
|
|
text="Diese Einträge werden für Ihre Transkriptionen, Korrekturen und Dokumente bevorzugt verwendet.")
|
|
else:
|
|
title_lbl.config(text="Öffentliche Bibliothek")
|
|
subtitle_lbl.config(
|
|
text="Von AzA kuratierte oder von Ärztinnen und Ärzten veröffentlichte Einträge. "
|
|
"Übernommene Einträge werden als eigene Kopie gespeichert.")
|
|
|
|
for row in _visible_rows():
|
|
listbox.insert("end", _format_row_label(row))
|
|
_rebuild_buttons()
|
|
|
|
def _on_select(_e=None):
|
|
sel = listbox.curselection()
|
|
if not sel:
|
|
return
|
|
visible = _visible_rows()
|
|
if sel[0] >= len(visible):
|
|
return
|
|
row = visible[sel[0]]
|
|
state["selected"] = row
|
|
detail_vars["falsch"].set(row.get("term") or "")
|
|
detail_vars["richtig"].set(row.get("preferred_spelling") or row.get("term") or "")
|
|
parts = []
|
|
if row.get("active_substance"):
|
|
parts.append(f"Wirkstoff: {row['active_substance']}")
|
|
if row.get("source"):
|
|
parts.append(f"Herkunft: {row['source']}")
|
|
if not row.get("active", True):
|
|
parts.append("Status: inaktiv")
|
|
detail_vars["note"].set(" · ".join(parts))
|
|
_rebuild_buttons()
|
|
|
|
listbox.bind("<<ListboxSelect>>", _on_select)
|
|
search_var.trace_add("write", lambda *_a: _refresh_list())
|
|
|
|
# ── Aktionen ───────────────────────────────────────────────────────────────
|
|
def _store_for_state() -> str:
|
|
sel = state.get("selected")
|
|
if sel and sel.get("store_category"):
|
|
return sel["store_category"]
|
|
return CATEGORY_TO_STORE.get(state["category"], "begriffe")
|
|
|
|
def _new_entry():
|
|
state["selected"] = None
|
|
detail_vars["falsch"].set("")
|
|
detail_vars["richtig"].set("")
|
|
detail_vars["note"].set("")
|
|
listbox.selection_clear(0, "end")
|
|
falsch_ent.focus_set()
|
|
_rebuild_buttons()
|
|
|
|
def _save_private():
|
|
f = detail_vars["falsch"].get().strip()
|
|
r = detail_vars["richtig"].get().strip()
|
|
if not f or not r:
|
|
messagebox.showinfo("Bibliothek", "Bitte Hörvariante und bevorzugte Schreibweise ausfüllen.", parent=win)
|
|
return
|
|
save_private_correction(_store_for_state(), f, r, active=True)
|
|
_refresh_list()
|
|
try:
|
|
app.set_status("Bibliothek: Eintrag gespeichert.")
|
|
except Exception:
|
|
pass
|
|
|
|
def _toggle_active():
|
|
row = state.get("selected")
|
|
if not row:
|
|
return
|
|
toggle_private_correction_active(row.get("store_category") or "begriffe", row.get("term") or "")
|
|
_refresh_list()
|
|
|
|
def _delete_private():
|
|
row = state.get("selected")
|
|
if not row:
|
|
return
|
|
if not messagebox.askyesno("Löschen?", "Eintrag wirklich löschen?", parent=win):
|
|
return
|
|
delete_private_correction(row.get("store_category") or "begriffe", row.get("term") or "")
|
|
_refresh_list()
|
|
|
|
def _adopt_public():
|
|
row = state.get("selected")
|
|
if not row:
|
|
messagebox.showinfo("Bibliothek", "Bitte zuerst einen öffentlichen Eintrag auswählen.", parent=win)
|
|
return
|
|
ok, msg = adopt_public_entry(row)
|
|
messagebox.showinfo("Bibliothek", msg, parent=win)
|
|
|
|
def _details_public():
|
|
row = state.get("selected")
|
|
if not row:
|
|
messagebox.showinfo("Bibliothek", "Bitte zuerst einen Eintrag auswählen.", parent=win)
|
|
return
|
|
lines = []
|
|
if row.get("brand_name"):
|
|
lines.append(f"Markenname: {row['brand_name']}")
|
|
if row.get("active_substance"):
|
|
lines.append(f"Wirkstoff: {row['active_substance']}")
|
|
if row.get("term"):
|
|
lines.append(f"Begriff: {row['term']}")
|
|
if row.get("preferred_spelling"):
|
|
lines.append(f"Bevorzugt: {row['preferred_spelling']}")
|
|
if row.get("market_region"):
|
|
lines.append(f"Region: {row['market_region']}")
|
|
if row.get("source"):
|
|
lines.append(f"Herkunft: {row['source']}")
|
|
messagebox.showinfo("Details", "\n".join(lines) or "Keine Details.", parent=win)
|
|
|
|
def _rebuild_buttons():
|
|
for w in btn_row.winfo_children():
|
|
w.destroy()
|
|
has_sel = state.get("selected") is not None
|
|
if state["scope"] == "private":
|
|
_btn(btn_row, "Neuer Eintrag", _new_entry).pack(side="left", padx=(0, 8))
|
|
_btn(btn_row, "Speichern", _save_private, primary=True).pack(side="left", padx=(0, 8))
|
|
if has_sel:
|
|
row = state["selected"]
|
|
if row.get("active", True):
|
|
_btn(btn_row, "Deaktivieren", _toggle_active).pack(side="left", padx=(0, 8))
|
|
else:
|
|
_btn(btn_row, "Aktivieren", _toggle_active).pack(side="left", padx=(0, 8))
|
|
_btn(btn_row, "Löschen", _delete_private, danger=True).pack(side="left", padx=(0, 8))
|
|
else:
|
|
_btn(btn_row, "Übernehmen", _adopt_public, primary=True).pack(side="left", padx=(0, 8))
|
|
_btn(btn_row, "Details", _details_public).pack(side="left", padx=(0, 8))
|
|
_btn(btn_row, "Aktualisieren", _refresh_list).pack(side="left", padx=(0, 8))
|
|
_btn(btn_row, "Schliessen", _close).pack(side="right")
|
|
|
|
def _set_scope(scope: str):
|
|
state["scope"] = scope
|
|
cats = PRIVATE_UI_CATEGORIES if scope == "private" else PUBLIC_UI_CATEGORIES
|
|
if state["category"] not in cats:
|
|
state["category"] = cats[0]
|
|
for k, p in scope_pills.items():
|
|
p.config(bg=_ACCENT if k == scope else _CARD_BG,
|
|
fg="#FFFFFF" if k == scope else _TEXT)
|
|
# Detailfelder bei öffentlicher Bibliothek nur lesend nutzen
|
|
editable = "normal" if scope == "private" else "disabled"
|
|
try:
|
|
falsch_ent.config(state=editable)
|
|
richtig_ent.config(state=editable)
|
|
except Exception:
|
|
pass
|
|
_rebuild_category_pills()
|
|
_refresh_list()
|
|
|
|
def _set_category(cat: str):
|
|
state["category"] = cat
|
|
_rebuild_category_pills()
|
|
_refresh_list()
|
|
|
|
scope_pills["private"] = _pill(scope_frame, "Eigene Bibliothek", active=True,
|
|
command=lambda: _set_scope("private"))
|
|
scope_pills["private"].pack(fill="x", pady=2)
|
|
scope_pills["public"] = _pill(scope_frame, "Öffentliche Bibliothek", active=False,
|
|
command=lambda: _set_scope("public"))
|
|
scope_pills["public"].pack(fill="x", pady=2)
|
|
|
|
_rebuild_category_pills()
|
|
_refresh_list()
|
|
_bring_to_front(win)
|